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.
- package/dist/dsl.js +2 -0
- package/dist/dsl.js.map +7 -0
- package/dist/preview.mjs +61 -62
- package/dist/preview.mjs.map +4 -4
- package/dist/view-contracts.bundle.mjs +232 -176
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/package.json +5 -4
- package/renderers/react/defaults/theme.yaml +29 -0
- package/renderers/react/defaults/tokens.yaml +61 -1
- package/renderers/react/lowering/lowerToJsx.ts +6 -6
- package/renderers/react/paths.ts +5 -1
- package/renderers/react/runtime/structural.css +38 -0
- package/renderers/react/style/foldLiterals.ts +14 -4
- package/renderers/swiftui/lowering/lowerToSwiftUI.ts +250 -31
- package/renderers/swiftui/lowering/modifiers.ts +90 -11
- package/renderers/swiftui/renderRuntime.ts +48 -0
- package/renderers/swiftui/renderTheme.ts +69 -3
- package/src/dsl/index.ts +16 -0
- package/src/dsl/screen.tsx +54 -0
- package/src/dsl/types.ts +68 -0
- package/renderers/react/runtime/theme.css +0 -57
|
@@ -72,8 +72,39 @@ function resolveVariantEntries(
|
|
|
72
72
|
case 'radius':
|
|
73
73
|
case 'fontSize':
|
|
74
74
|
case 'minHeight':
|
|
75
|
-
case 'maxWidth':
|
|
76
|
-
|
|
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(` }`);
|
package/src/dsl/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { screen, view } from './screen.js';
|
|
2
|
+
export type {
|
|
3
|
+
ViewAction,
|
|
4
|
+
ActionHandlers,
|
|
5
|
+
ScreenContext,
|
|
6
|
+
ViewContract,
|
|
7
|
+
ViewPartial,
|
|
8
|
+
ViewScreenProps,
|
|
9
|
+
CommonProps,
|
|
10
|
+
Option,
|
|
11
|
+
Tone,
|
|
12
|
+
Align,
|
|
13
|
+
Justify,
|
|
14
|
+
ButtonVariant,
|
|
15
|
+
FieldSize,
|
|
16
|
+
} from './types.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { FC } from 'react';
|
|
3
|
+
import type { ScreenContext, ViewContract, ViewPartial, ViewScreenProps } from './types.js';
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
ViewAction,
|
|
7
|
+
ActionHandlers,
|
|
8
|
+
ScreenContext,
|
|
9
|
+
ViewContract,
|
|
10
|
+
ViewPartial,
|
|
11
|
+
ViewScreenProps,
|
|
12
|
+
CommonProps,
|
|
13
|
+
Option,
|
|
14
|
+
Tone,
|
|
15
|
+
Align,
|
|
16
|
+
Justify,
|
|
17
|
+
ButtonVariant,
|
|
18
|
+
FieldSize,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wrap a restricted view-contract TSX definition.
|
|
23
|
+
*
|
|
24
|
+
* - **Preview runtime**: returns a React component (`vm` + `actions` props).
|
|
25
|
+
* - **Compiler**: statically extracts JSX from the render callback → View IR.
|
|
26
|
+
*/
|
|
27
|
+
export function screen<TViewModel>(
|
|
28
|
+
render: (ctx: ScreenContext<TViewModel>) => ReactNode,
|
|
29
|
+
): FC<ViewScreenProps<TViewModel>> {
|
|
30
|
+
function ViewScreen({ vm, actions = {} }: ViewScreenProps<TViewModel>) {
|
|
31
|
+
return <>{render({ vm, actions })}</>;
|
|
32
|
+
}
|
|
33
|
+
return Object.assign(ViewScreen, {
|
|
34
|
+
__viewContract: true as const,
|
|
35
|
+
render,
|
|
36
|
+
}) as FC<ViewScreenProps<TViewModel>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reusable view-contract sub-component (composable inside screen() or other view()).
|
|
41
|
+
*
|
|
42
|
+
* Compiled by inlining into the parent View IR; preview runtime renders as a normal React FC.
|
|
43
|
+
*/
|
|
44
|
+
export function view<TProps>(
|
|
45
|
+
render: (props: TProps) => ReactNode,
|
|
46
|
+
): FC<TProps> & ViewPartial<TProps> {
|
|
47
|
+
function ViewComponent(props: TProps) {
|
|
48
|
+
return <>{render(props)}</>;
|
|
49
|
+
}
|
|
50
|
+
return Object.assign(ViewComponent, {
|
|
51
|
+
__viewPartial: true as const,
|
|
52
|
+
render,
|
|
53
|
+
}) as FC<TProps> & ViewPartial<TProps>;
|
|
54
|
+
}
|
package/src/dsl/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Action descriptor referenced from view contracts (names must match OpenAPI operationId). */
|
|
4
|
+
export interface ViewAction {
|
|
5
|
+
name: string;
|
|
6
|
+
params?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ActionHandlers = Record<string, (params?: unknown) => void | Promise<void>>;
|
|
10
|
+
|
|
11
|
+
export interface ScreenContext<TViewModel> {
|
|
12
|
+
vm: TViewModel;
|
|
13
|
+
actions: ActionHandlers;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ViewContract<TViewModel> {
|
|
17
|
+
readonly __viewContract: true;
|
|
18
|
+
readonly render: (ctx: ScreenContext<TViewModel>) => ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Tone = 'default' | 'muted' | 'primary' | 'success' | 'warning' | 'danger';
|
|
22
|
+
export type Align = 'start' | 'center' | 'end' | 'stretch';
|
|
23
|
+
export type Justify = 'start' | 'center' | 'end' | 'between' | 'around';
|
|
24
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
25
|
+
export type FieldSize = 'sm' | 'md' | 'lg';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shared layout/visual props.
|
|
29
|
+
* Dimension props (width, height, gap, padding, …) are numbers in logical units;
|
|
30
|
+
* the web preview maps them to px.
|
|
31
|
+
*/
|
|
32
|
+
export interface CommonProps {
|
|
33
|
+
id?: string;
|
|
34
|
+
testId?: string;
|
|
35
|
+
ariaLabel?: string;
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
hidden?: boolean;
|
|
38
|
+
width?: number;
|
|
39
|
+
minWidth?: number;
|
|
40
|
+
maxWidth?: number;
|
|
41
|
+
height?: number;
|
|
42
|
+
minHeight?: number;
|
|
43
|
+
maxHeight?: number;
|
|
44
|
+
padding?: number;
|
|
45
|
+
margin?: number;
|
|
46
|
+
gap?: number;
|
|
47
|
+
radius?: number;
|
|
48
|
+
border?: boolean;
|
|
49
|
+
shadow?: boolean;
|
|
50
|
+
tone?: Tone;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Option {
|
|
54
|
+
label: string;
|
|
55
|
+
value: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ViewPartial<TProps> {
|
|
59
|
+
readonly __viewPartial: true;
|
|
60
|
+
readonly render: (props: TProps) => ReactNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ViewScreenProps<TViewModel> = {
|
|
64
|
+
vm: TViewModel;
|
|
65
|
+
actions?: ActionHandlers;
|
|
66
|
+
/** Optional theme id — propagated from App element for future multi-theme runtime. */
|
|
67
|
+
theme?: string;
|
|
68
|
+
};
|
|
@@ -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; }
|