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.
- package/README.md +10 -3
- package/cli-contract.yaml +40 -0
- package/dist/preview-style-helpers.js +2 -0
- package/dist/preview-style-helpers.js.map +7 -0
- package/dist/preview.mjs +152 -0
- package/dist/preview.mjs.map +7 -0
- package/dist/view-contracts.bundle.mjs +457 -248
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +36 -0
- package/package.json +28 -1
- package/renderers/compose/renderRuntime.ts +40 -0
- package/renderers/react/defaults/theme.yaml +52 -0
- package/renderers/react/defaults/tokens.yaml +76 -1
- package/renderers/react/elementMap.ts +30 -6
- package/renderers/react/elements.yaml +17 -5
- package/renderers/react/lowering/foldStyle.ts +13 -1
- package/renderers/react/lowering/lowerToJsx.ts +21 -40
- package/renderers/react/paths.ts +5 -10
- package/renderers/react/preview/components.ts +23 -0
- package/renderers/react/preview/index.ts +3 -0
- package/renderers/react/preview/start.tsx +42 -0
- package/renderers/react/preview/style-helpers.ts +5 -0
- package/renderers/react/preview/vocabulary.tsx +75 -0
- package/renderers/react/runtime/structural.css +38 -0
- package/renderers/react/style/foldLiterals.ts +25 -4
- package/renderers/swiftui/README.md +48 -10
- package/renderers/swiftui/elementMap.ts +45 -0
- package/renderers/swiftui/elements.yaml +82 -0
- package/renderers/swiftui/index.ts +5 -0
- package/renderers/swiftui/lowering/lowerToSwiftUI.ts +761 -0
- package/renderers/swiftui/lowering/modifiers.ts +221 -0
- package/renderers/swiftui/renderComponents.ts +79 -0
- package/renderers/swiftui/renderRuntime.ts +139 -0
- package/renderers/swiftui/renderTheme.ts +274 -0
- package/spec/ui-dsl.linter-rules.json +0 -7
- package/spec/ui-dsl.meta.json +5 -0
- package/spec/ui-dsl.schema.json +333 -259
- package/renderers/react/runtime/theme.css +0 -65
- package/src/generated/schema/allowed-components.ts +0 -36
- package/src/generated/schema/dsl-enums.ts +0 -19
- package/src/generated/schema/dsl-properties.ts +0 -64
- package/src/generated/schema/index.ts +0 -4
- package/src/generated/schema/lowering-style-properties.ts +0 -35
- package/src/generated/schema/react-renderer-element-config.ts +0 -27
- package/src/generated/schema/schema-document.ts +0 -33
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate VcTheme.swift with variant style dictionary for runtime lookup.
|
|
3
|
+
* Enables dynamic variant resolution (IrConcat / IrBinding) in SwiftUI lowering.
|
|
4
|
+
*/
|
|
5
|
+
import type { ThemeIR, TokenIR, VariantPropertyRef } from '../../src/design/types.js';
|
|
6
|
+
|
|
7
|
+
function resolveTokenValue(tokens: TokenIR, ref: string): string {
|
|
8
|
+
const entry = tokens.entries.find((item) => item.path === ref);
|
|
9
|
+
return entry?.value ?? '';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseHexColor(value: string): string | null {
|
|
13
|
+
const match = value.match(/^#([0-9a-fA-F]{6})$/);
|
|
14
|
+
if (!match) return null;
|
|
15
|
+
const hex = match[1]!;
|
|
16
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
17
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
18
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
19
|
+
return `Color(red: ${(r / 255).toFixed(3)}, green: ${(g / 255).toFixed(3)}, blue: ${(b / 255).toFixed(3)})`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parsePx(value: string): number | null {
|
|
23
|
+
const match = value.match(/^([\d.]+)px$/);
|
|
24
|
+
return match ? Number(match[1]) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FONT_WEIGHT_MAP: Record<string, string> = {
|
|
28
|
+
bold: '.bold', semibold: '.semibold', medium: '.medium', regular: '.regular',
|
|
29
|
+
'600': '.semibold', '700': '.bold', '500': '.medium', '400': '.regular',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function swiftColorLiteral(value: string): string | null {
|
|
33
|
+
return parseHexColor(value) ?? (value ? `Color(${JSON.stringify(value)})` : null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ResolvedEntry {
|
|
37
|
+
property: string;
|
|
38
|
+
swiftValue: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveVariantEntries(
|
|
42
|
+
props: Record<string, VariantPropertyRef>,
|
|
43
|
+
tokens: TokenIR,
|
|
44
|
+
): ResolvedEntry[] {
|
|
45
|
+
const entries: ResolvedEntry[] = [];
|
|
46
|
+
for (const [property, binding] of Object.entries(props)) {
|
|
47
|
+
const value = resolveTokenValue(tokens, binding.ref);
|
|
48
|
+
if (!value) continue;
|
|
49
|
+
|
|
50
|
+
switch (property) {
|
|
51
|
+
case 'background':
|
|
52
|
+
case 'foreground': {
|
|
53
|
+
const color = swiftColorLiteral(value);
|
|
54
|
+
if (color) entries.push({ property, swiftValue: color });
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case 'padding': {
|
|
58
|
+
const px = parsePx(value);
|
|
59
|
+
if (px !== null) {
|
|
60
|
+
entries.push({ property, swiftValue: String(px) });
|
|
61
|
+
} else {
|
|
62
|
+
const parts = value.trim().split(/\s+/);
|
|
63
|
+
if (parts.length === 2) {
|
|
64
|
+
const v = parsePx(parts[0]!);
|
|
65
|
+
const h = parsePx(parts[1]!);
|
|
66
|
+
if (v !== null) entries.push({ property: 'paddingV', swiftValue: String(v) });
|
|
67
|
+
if (h !== null) entries.push({ property: 'paddingH', swiftValue: String(h) });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'radius':
|
|
73
|
+
case 'fontSize':
|
|
74
|
+
case 'minHeight':
|
|
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
|
+
}
|
|
108
|
+
case 'margin': {
|
|
109
|
+
const px = parsePx(value);
|
|
110
|
+
if (px !== null) entries.push({ property, swiftValue: String(px) });
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'fontWeight': {
|
|
114
|
+
const mapped = FONT_WEIGHT_MAP[value];
|
|
115
|
+
if (mapped) entries.push({ property, swiftValue: mapped });
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case 'shadow': {
|
|
119
|
+
entries.push({ property, swiftValue: value === 'true' || value === 'none' ? 'false' : 'true' });
|
|
120
|
+
break;
|
|
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;
|
|
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
|
+
);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return entries;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const STRUCT_FIELD_ORDER = ['foreground', 'background', 'padding', 'paddingH', 'paddingV', 'widthFill', 'heightFill', 'radius', 'fontSize', 'fontWeight', 'shadow', 'borderWidth', 'borderColor', 'minHeight', 'maxWidth'];
|
|
143
|
+
|
|
144
|
+
function emitVariantStyleInit(entries: ResolvedEntry[], indent: string): string {
|
|
145
|
+
if (entries.length === 0) return `${indent}VcVariantStyle()`;
|
|
146
|
+
const sorted = [...entries].sort(
|
|
147
|
+
(a, b) => STRUCT_FIELD_ORDER.indexOf(a.property) - STRUCT_FIELD_ORDER.indexOf(b.property),
|
|
148
|
+
);
|
|
149
|
+
const args = sorted.map(e => `${e.property}: ${e.swiftValue}`).join(', ');
|
|
150
|
+
return `${indent}VcVariantStyle(${args})`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Merge base defaults into variant entries so the dictionary contains
|
|
155
|
+
* the full cascade: base < variant. Variant values override base.
|
|
156
|
+
*/
|
|
157
|
+
function mergeBaseIntoVariant(
|
|
158
|
+
base: Record<string, VariantPropertyRef>,
|
|
159
|
+
variant: Record<string, VariantPropertyRef>,
|
|
160
|
+
): Record<string, VariantPropertyRef> {
|
|
161
|
+
return { ...base, ...variant };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function renderSwiftTheme(theme: ThemeIR, tokens: TokenIR): string {
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
lines.push(`/**`);
|
|
167
|
+
lines.push(` * Auto-generated SwiftUI theme — variant style dictionary.`);
|
|
168
|
+
lines.push(` * Each entry is pre-merged: base theme defaults < variant overrides.`);
|
|
169
|
+
lines.push(` * DO NOT EDIT — regenerate with: view-contracts build`);
|
|
170
|
+
lines.push(` */`);
|
|
171
|
+
lines.push(`import SwiftUI`);
|
|
172
|
+
lines.push(``);
|
|
173
|
+
|
|
174
|
+
lines.push(`struct VcVariantStyle {`);
|
|
175
|
+
lines.push(` var foreground: Color?`);
|
|
176
|
+
lines.push(` var background: Color?`);
|
|
177
|
+
lines.push(` var padding: CGFloat?`);
|
|
178
|
+
lines.push(` var paddingH: CGFloat?`);
|
|
179
|
+
lines.push(` var paddingV: CGFloat?`);
|
|
180
|
+
lines.push(` var widthFill: Bool?`);
|
|
181
|
+
lines.push(` var heightFill: Bool?`);
|
|
182
|
+
lines.push(` var radius: CGFloat?`);
|
|
183
|
+
lines.push(` var fontSize: CGFloat?`);
|
|
184
|
+
lines.push(` var fontWeight: Font.Weight?`);
|
|
185
|
+
lines.push(` var shadow: Bool?`);
|
|
186
|
+
lines.push(` var borderWidth: CGFloat?`);
|
|
187
|
+
lines.push(` var borderColor: Color?`);
|
|
188
|
+
lines.push(` var minHeight: CGFloat?`);
|
|
189
|
+
lines.push(` var maxWidth: CGFloat?`);
|
|
190
|
+
lines.push(`}`);
|
|
191
|
+
lines.push(``);
|
|
192
|
+
|
|
193
|
+
// Nil-transparent: only apply properties the style actually defines.
|
|
194
|
+
// frame(maxWidth/maxHeight: .infinity) MUST come BEFORE background so bg fills expanded area.
|
|
195
|
+
lines.push(`extension View {`);
|
|
196
|
+
lines.push(` func applyVcStyle(_ s: VcVariantStyle) -> some View {`);
|
|
197
|
+
lines.push(` self`);
|
|
198
|
+
lines.push(` .ifSet(s.foreground) { $0.foregroundStyle($1) }`);
|
|
199
|
+
lines.push(` .ifSet(s.fontSize) { $0.font(.system(size: $1, weight: s.fontWeight ?? .regular)) }`);
|
|
200
|
+
lines.push(` .ifSet(s.padding) { $0.padding($1) }`);
|
|
201
|
+
lines.push(` .ifSet(s.paddingH) { $0.padding(.horizontal, $1) }`);
|
|
202
|
+
lines.push(` .ifSet(s.paddingV) { $0.padding(.vertical, $1) }`);
|
|
203
|
+
lines.push(` .vcFillFrame(widthFill: s.widthFill ?? false, heightFill: s.heightFill ?? false)`);
|
|
204
|
+
lines.push(` .ifSet(s.background) { $0.background($1) }`);
|
|
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)) }`);
|
|
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) }`);
|
|
208
|
+
lines.push(` }`);
|
|
209
|
+
lines.push(`}`);
|
|
210
|
+
lines.push(``);
|
|
211
|
+
lines.push(`extension View {`);
|
|
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`);
|
|
228
|
+
lines.push(` func ifSet<T, V: View>(_ value: T?, _ transform: (Self, T) -> V) -> some View {`);
|
|
229
|
+
lines.push(` if let value { transform(self, value) } else { self }`);
|
|
230
|
+
lines.push(` }`);
|
|
231
|
+
lines.push(`}`);
|
|
232
|
+
lines.push(``);
|
|
233
|
+
|
|
234
|
+
lines.push(`enum VcTheme {`);
|
|
235
|
+
|
|
236
|
+
// Emit base defaults per element
|
|
237
|
+
lines.push(` static let defaults: [String: VcVariantStyle] = [`);
|
|
238
|
+
for (const [elementName, baseProps] of Object.entries(theme.defaults)) {
|
|
239
|
+
if (Object.keys(baseProps).length === 0) continue;
|
|
240
|
+
const resolved = resolveVariantEntries(baseProps, tokens);
|
|
241
|
+
if (resolved.length === 0) continue;
|
|
242
|
+
lines.push(` ${JSON.stringify(elementName)}: ${emitVariantStyleInit(resolved, '').trim()},`);
|
|
243
|
+
}
|
|
244
|
+
lines.push(` ]`);
|
|
245
|
+
lines.push(``);
|
|
246
|
+
|
|
247
|
+
// Emit pre-merged (base + variant) dictionary
|
|
248
|
+
lines.push(` static let variants: [String: [String: VcVariantStyle]] = [`);
|
|
249
|
+
for (const [elementName, elementVariants] of Object.entries(theme.variants)) {
|
|
250
|
+
const variantEntries = Object.entries(elementVariants);
|
|
251
|
+
if (variantEntries.length === 0) continue;
|
|
252
|
+
|
|
253
|
+
const baseProps = theme.defaults[elementName] ?? {};
|
|
254
|
+
lines.push(` ${JSON.stringify(elementName)}: [`);
|
|
255
|
+
for (const [variantName, variantProps] of variantEntries) {
|
|
256
|
+
const merged = mergeBaseIntoVariant(baseProps, variantProps);
|
|
257
|
+
const resolved = resolveVariantEntries(merged, tokens);
|
|
258
|
+
const init = emitVariantStyleInit(resolved, ' ');
|
|
259
|
+
lines.push(` ${JSON.stringify(variantName)}: ${init.trim()},`);
|
|
260
|
+
}
|
|
261
|
+
lines.push(` ],`);
|
|
262
|
+
}
|
|
263
|
+
lines.push(` ]`);
|
|
264
|
+
lines.push(``);
|
|
265
|
+
|
|
266
|
+
// resolve() falls back to base defaults, not empty style
|
|
267
|
+
lines.push(` static func resolve(_ element: String, variant: String) -> VcVariantStyle {`);
|
|
268
|
+
lines.push(` variants[element]?[variant] ?? defaults[element] ?? VcVariantStyle()`);
|
|
269
|
+
lines.push(` }`);
|
|
270
|
+
lines.push(`}`);
|
|
271
|
+
lines.push(``);
|
|
272
|
+
|
|
273
|
+
return lines.join('\n');
|
|
274
|
+
}
|
|
@@ -45,13 +45,6 @@
|
|
|
45
45
|
"element": "Icon",
|
|
46
46
|
"requiredProperties": ["name"]
|
|
47
47
|
},
|
|
48
|
-
{
|
|
49
|
-
"id": "heading-level-required",
|
|
50
|
-
"severity": "error",
|
|
51
|
-
"description": "Heading requires headingLevel for semantic hierarchy",
|
|
52
|
-
"element": "Heading",
|
|
53
|
-
"requiredProperties": ["headingLevel"]
|
|
54
|
-
},
|
|
55
48
|
{
|
|
56
49
|
"id": "form-no-onclick",
|
|
57
50
|
"severity": "error",
|
package/spec/ui-dsl.meta.json
CHANGED
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
"type": "array",
|
|
53
53
|
"items": { "type": "string" }
|
|
54
54
|
},
|
|
55
|
+
"x-lowering": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"enum": ["inline-css", "class-name", "attribute", "none"],
|
|
58
|
+
"description": "How this property is lowered: inline-css → static style, class-name → CSS class, attribute → HTML attr, none → behavioral/semantic"
|
|
59
|
+
},
|
|
55
60
|
"x-notes": { "type": "string" }
|
|
56
61
|
}
|
|
57
62
|
},
|