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.
@@ -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 {
@@ -72,8 +72,39 @@ function resolveVariantEntries(
72
72
  case 'radius':
73
73
  case 'fontSize':
74
74
  case 'minHeight':
75
- case 'maxWidth':
76
- case 'width':
75
+ case 'maxWidth': {
76
+ const px = parsePx(value);
77
+ if (px !== null) entries.push({ property, swiftValue: String(px) });
78
+ break;
79
+ }
80
+ case 'width': {
81
+ if (value === '100%' || value === 'fill') {
82
+ entries.push({ property: 'widthFill', swiftValue: 'true' });
83
+ } else {
84
+ const px = parsePx(value);
85
+ if (px !== null) entries.push({ property, swiftValue: String(px) });
86
+ }
87
+ break;
88
+ }
89
+ case 'height': {
90
+ if (value === '100%' || value === 'fill') {
91
+ entries.push({ property: 'heightFill', swiftValue: 'true' });
92
+ } else {
93
+ const px = parsePx(value);
94
+ if (px !== null) entries.push({ property, swiftValue: String(px) });
95
+ }
96
+ break;
97
+ }
98
+ case 'border': {
99
+ const match = value.match(/^([\d.]+)px/);
100
+ if (match) entries.push({ property: 'borderWidth', swiftValue: String(Number(match[1])) });
101
+ break;
102
+ }
103
+ case 'borderColor': {
104
+ const color = swiftColorLiteral(value);
105
+ if (color) entries.push({ property: 'borderColor', swiftValue: color });
106
+ break;
107
+ }
77
108
  case 'margin': {
78
109
  const px = parsePx(value);
79
110
  if (px !== null) entries.push({ property, swiftValue: String(px) });
@@ -88,14 +119,27 @@ function resolveVariantEntries(
88
119
  entries.push({ property, swiftValue: value === 'true' || value === 'none' ? 'false' : 'true' });
89
120
  break;
90
121
  }
122
+ case 'display':
123
+ case 'flexDirection':
124
+ case 'gridTemplateColumns':
125
+ case 'alignItems':
126
+ case 'justifyContent':
127
+ case 'typography':
128
+ // Consumed at compile time by lowerToSwiftUI.ts (container type / alignment selection).
129
+ // Not representable as a runtime VcVariantStyle field.
130
+ break;
91
131
  default:
132
+ console.warn(
133
+ `[view-contracts] SwiftUI theme: variant property "${property}" is not handled ` +
134
+ `(value: "${value}"). Add a case to renderTheme.ts resolveVariantEntries().`,
135
+ );
92
136
  break;
93
137
  }
94
138
  }
95
139
  return entries;
96
140
  }
97
141
 
98
- const STRUCT_FIELD_ORDER = ['foreground', 'background', 'padding', 'paddingH', 'paddingV', 'radius', 'fontSize', 'fontWeight', 'shadow', 'minHeight', 'maxWidth'];
142
+ const STRUCT_FIELD_ORDER = ['foreground', 'background', 'padding', 'paddingH', 'paddingV', 'widthFill', 'heightFill', 'radius', 'fontSize', 'fontWeight', 'shadow', 'borderWidth', 'borderColor', 'minHeight', 'maxWidth'];
99
143
 
100
144
  function emitVariantStyleInit(entries: ResolvedEntry[], indent: string): string {
101
145
  if (entries.length === 0) return `${indent}VcVariantStyle()`;
@@ -133,16 +177,21 @@ export function renderSwiftTheme(theme: ThemeIR, tokens: TokenIR): string {
133
177
  lines.push(` var padding: CGFloat?`);
134
178
  lines.push(` var paddingH: CGFloat?`);
135
179
  lines.push(` var paddingV: CGFloat?`);
180
+ lines.push(` var widthFill: Bool?`);
181
+ lines.push(` var heightFill: Bool?`);
136
182
  lines.push(` var radius: CGFloat?`);
137
183
  lines.push(` var fontSize: CGFloat?`);
138
184
  lines.push(` var fontWeight: Font.Weight?`);
139
185
  lines.push(` var shadow: Bool?`);
186
+ lines.push(` var borderWidth: CGFloat?`);
187
+ lines.push(` var borderColor: Color?`);
140
188
  lines.push(` var minHeight: CGFloat?`);
141
189
  lines.push(` var maxWidth: CGFloat?`);
142
190
  lines.push(`}`);
143
191
  lines.push(``);
144
192
 
145
193
  // Nil-transparent: only apply properties the style actually defines.
194
+ // frame(maxWidth/maxHeight: .infinity) MUST come BEFORE background so bg fills expanded area.
146
195
  lines.push(`extension View {`);
147
196
  lines.push(` func applyVcStyle(_ s: VcVariantStyle) -> some View {`);
148
197
  lines.push(` self`);
@@ -151,14 +200,31 @@ export function renderSwiftTheme(theme: ThemeIR, tokens: TokenIR): string {
151
200
  lines.push(` .ifSet(s.padding) { $0.padding($1) }`);
152
201
  lines.push(` .ifSet(s.paddingH) { $0.padding(.horizontal, $1) }`);
153
202
  lines.push(` .ifSet(s.paddingV) { $0.padding(.vertical, $1) }`);
203
+ lines.push(` .vcFillFrame(widthFill: s.widthFill ?? false, heightFill: s.heightFill ?? false)`);
154
204
  lines.push(` .ifSet(s.background) { $0.background($1) }`);
155
205
  lines.push(` .ifSet(s.radius) { $0.clipShape(RoundedRectangle(cornerRadius: $1)) }`);
206
+ lines.push(` .ifSet(s.borderColor) { $0.overlay(RoundedRectangle(cornerRadius: s.radius ?? 0).stroke($1, lineWidth: s.borderWidth ?? 1)) }`);
156
207
  lines.push(` .ifSet(s.shadow) { v, on in on ? AnyView(v.shadow(color: Color.black.opacity(0.08), radius: 4, x: 0, y: 2)) : AnyView(v) }`);
157
208
  lines.push(` }`);
158
209
  lines.push(`}`);
159
210
  lines.push(``);
160
211
  lines.push(`extension View {`);
161
212
  lines.push(` @ViewBuilder`);
213
+ lines.push(` func vcFillFrame(widthFill: Bool, heightFill: Bool) -> some View {`);
214
+ lines.push(` if widthFill && heightFill {`);
215
+ lines.push(` self.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)`);
216
+ lines.push(` } else if widthFill {`);
217
+ lines.push(` self.frame(maxWidth: .infinity, alignment: .leading)`);
218
+ lines.push(` } else if heightFill {`);
219
+ lines.push(` self.frame(maxHeight: .infinity, alignment: .top)`);
220
+ lines.push(` } else {`);
221
+ lines.push(` self`);
222
+ lines.push(` }`);
223
+ lines.push(` }`);
224
+ lines.push(`}`);
225
+ lines.push(``);
226
+ lines.push(`extension View {`);
227
+ lines.push(` @ViewBuilder`);
162
228
  lines.push(` func ifSet<T, V: View>(_ value: T?, _ transform: (Self, T) -> V) -> some View {`);
163
229
  lines.push(` if let value { transform(self, value) } else { self }`);
164
230
  lines.push(` }`);
@@ -1,57 +0,0 @@
1
- * { box-sizing: border-box; }
2
- body { margin: 0; background: var(--ui-bg); color: var(--ui-text); font-family: var(--ui-font); }
3
- button, input, textarea, select { font: inherit; }
4
-
5
- .vc-page { min-height: 100vh; }
6
- .vc-box { display: block; }
7
- .vc-column { display: flex; flex-direction: column; }
8
- .vc-stack { display: flex; flex-direction: column; }
9
- .vc-row { display: flex; flex-direction: row; }
10
- .vc-wrap { flex-wrap: wrap; }
11
- .vc-spacer { min-width: 0; min-height: 0; }
12
- .vc-divider { border: 0; border-top: 1px solid var(--ui-border); width: 100%; }
13
-
14
- .vc-heading { margin: 0; line-height: 1.2; letter-spacing: -0.02em; }
15
- .vc-heading-1 { font-size: 32px; }
16
- .vc-heading-2 { font-size: 22px; }
17
- .vc-heading-3 { font-size: 18px; }
18
- .vc-heading-4 { font-size: 15px; }
19
- .vc-text { margin: 0; line-height: 1.6; }
20
- .vc-link { color: var(--ui-primary); text-decoration: none; }
21
- .vc-link:hover { text-decoration: underline; }
22
-
23
- .vc-button { border: 0; cursor: pointer; }
24
-
25
- .vc-form { display: flex; flex-direction: column; }
26
- .vc-field { display: flex; flex-direction: column; gap: 6px; }
27
- .vc-field-label { font-size: 13px; font-weight: 700; color: var(--ui-text); }
28
- .vc-field-help { font-size: 12px; color: var(--ui-text-muted); }
29
- .vc-field-error { font-size: 12px; color: var(--ui-danger); }
30
- .vc-text-input, .vc-text-area { width: 100%; outline: none; }
31
- .vc-text-input:focus, .vc-text-area:focus { border-color: var(--ui-primary); box-shadow: 0 0 0 3px var(--ui-primary-weak); }
32
- .vc-field-sm { min-height: 34px; padding: 6px 10px; }
33
- .vc-field-md { min-height: 42px; }
34
- .vc-field-lg { min-height: 50px; padding: 12px 14px; }
35
- .vc-select {
36
- width: 100%;
37
- outline: none;
38
- appearance: none;
39
- -webkit-appearance: none;
40
- -moz-appearance: none;
41
- cursor: pointer;
42
- 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");
43
- background-repeat: no-repeat;
44
- background-position: right 12px center;
45
- background-size: 16px;
46
- }
47
- .vc-select:focus { border-color: var(--ui-primary); box-shadow: 0 0 0 3px var(--ui-primary-weak); }
48
- .vc-text-area { resize: vertical; }
49
- .vc-checkbox { display: inline-flex; align-items: center; gap: 8px; }
50
- .vc-list { margin: 0; padding-left: 20px; }
51
- .vc-list-item { margin: 0; padding: 2px 0; }
52
-
53
- .vc-table { width: 100%; display: flex; flex-direction: column; }
54
- .vc-table-header { display: flex; background: var(--ui-surface-muted, #f8f9fa); }
55
- .vc-table-column { text-align: left; padding: 12px 14px; font-size: 12px; font-weight: 600; color: var(--ui-text-muted, #6b7280); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
56
- .vc-table-row { display: flex; align-items: center; border-top: 1px solid var(--ui-border, #e5e7eb); }
57
- .vc-table-cell { padding: 14px; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }