view-contracts 0.2.0 → 0.3.0
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/cli-contract.yaml +1 -1
- package/dist/view-contracts.bundle.mjs +120 -107
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +1 -1
- package/package.json +5 -1
- package/renderers/compose/templates/Screen.kt.hbs +15 -0
- package/renderers/compose/templates/VcTheme.kt.hbs +19 -0
- package/renderers/react/defaults/theme.yaml +41 -0
- package/renderers/react/defaults/tokens.yaml +59 -0
- package/renderers/react/element-config.meta.json +0 -1
- package/renderers/react/elements.yaml +20 -21
- package/renderers/react/lowering/lowerToJsx.ts +63 -27
- package/renderers/react/renderRuntime.ts +28 -5
- package/renderers/react/runtime/style.ts +27 -22
- package/renderers/react/runtime/theme.css +50 -35
- package/renderers/react/runtime/types.ts +2 -2
- package/renderers/react/templates/runtime-types.ts.hbs +5 -0
- package/renderers/react/templates/style-helpers.ts.hbs +1 -1
- package/renderers/swiftui/templates/Screen.swift.hbs +21 -0
- package/renderers/swiftui/templates/VcTheme.swift.hbs +21 -0
- package/spec/ui-dsl.schema.json +24 -16
- package/src/generated/schema/allowed-components.ts +1 -1
- package/src/generated/schema/{tokens.ts → dsl-enums.ts} +1 -1
- package/src/generated/schema/dsl-properties.ts +3 -2
- package/src/generated/schema/index.ts +2 -2
- package/src/generated/schema/lowering-style-properties.ts +1 -9
- package/src/generated/schema/react-renderer-element-config.ts +1 -2
- package/src/generated/schema/schema-document.ts +2 -2
package/docs/cli-reference.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "view-contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"dist/view-contracts.bundle.mjs",
|
|
14
14
|
"dist/view-contracts.bundle.mjs.map",
|
|
15
15
|
"renderers/react/runtime",
|
|
16
|
+
"renderers/react/defaults",
|
|
16
17
|
"src/generated/schema",
|
|
17
18
|
"spec",
|
|
18
19
|
"renderers",
|
|
@@ -93,6 +94,9 @@
|
|
|
93
94
|
"ajv": "^8.18.0",
|
|
94
95
|
"cli-contracts": "^0.33.13",
|
|
95
96
|
"esbuild": "^0.28.0",
|
|
97
|
+
"pixelmatch": "^7.2.0",
|
|
98
|
+
"playwright": "^1.61.0",
|
|
99
|
+
"pngjs": "^7.0.0",
|
|
96
100
|
"tsx": "^4.21.0",
|
|
97
101
|
"vite": "^6.4.1",
|
|
98
102
|
"vitest": "^4.1.9"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// {{screenName}} — Jetpack Compose scaffold (Phase 5)
|
|
2
|
+
// Theme IR: {{themeJsonPath}}
|
|
3
|
+
// Token IR: {{tokensJsonPath}}
|
|
4
|
+
//
|
|
5
|
+
// TODO(Phase C): Load theme.json and emit Modifier.primaryButton(), vcTextMuted(), etc.
|
|
6
|
+
|
|
7
|
+
package {{packageName}}
|
|
8
|
+
|
|
9
|
+
import androidx.compose.material3.Text
|
|
10
|
+
import androidx.compose.runtime.Composable
|
|
11
|
+
|
|
12
|
+
@Composable
|
|
13
|
+
fun {{screenName}}Screen() {
|
|
14
|
+
Text("{{screenName}} — Compose scaffold")
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// VcTheme — Compose theme scaffold (Phase 5)
|
|
2
|
+
// Load Token IR + Theme IR from assets:
|
|
3
|
+
// {{tokensJsonPath}}
|
|
4
|
+
// {{themeJsonPath}}
|
|
5
|
+
//
|
|
6
|
+
// TODO(Phase C): Map Theme IR variants to Modifier extensions and ColorScheme.
|
|
7
|
+
|
|
8
|
+
package {{packageName}}
|
|
9
|
+
|
|
10
|
+
import android.content.Context
|
|
11
|
+
|
|
12
|
+
object VcTheme {
|
|
13
|
+
const val TOKENS_ASSET = "{{tokensJsonPath}}"
|
|
14
|
+
const val THEME_ASSET = "{{themeJsonPath}}"
|
|
15
|
+
|
|
16
|
+
/** Placeholder — read theme.json from assets in Phase C. */
|
|
17
|
+
fun loadThemeJson(context: Context): String? =
|
|
18
|
+
context.assets.open(THEME_ASSET).bufferedReader().use { it.readText() }
|
|
19
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Default theme variants — component appearance via .vc-{element}--{variant} rules.
|
|
2
|
+
variants:
|
|
3
|
+
Button:
|
|
4
|
+
primary:
|
|
5
|
+
background: "{ui.primary}"
|
|
6
|
+
foreground: "{ui.on-primary}"
|
|
7
|
+
secondary:
|
|
8
|
+
background: "{ui.button-secondary}"
|
|
9
|
+
foreground: "{ui.text}"
|
|
10
|
+
ghost:
|
|
11
|
+
background: "{ui.transparent}"
|
|
12
|
+
foreground: "{ui.text}"
|
|
13
|
+
danger:
|
|
14
|
+
background: "{ui.danger}"
|
|
15
|
+
foreground: "{ui.on-primary}"
|
|
16
|
+
Text:
|
|
17
|
+
muted:
|
|
18
|
+
foreground: "{ui.text-muted}"
|
|
19
|
+
Card:
|
|
20
|
+
default:
|
|
21
|
+
background: "{ui.surface}"
|
|
22
|
+
Badge:
|
|
23
|
+
default:
|
|
24
|
+
background: "{ui.surface-muted}"
|
|
25
|
+
primary:
|
|
26
|
+
background: "{ui.primary-weak}"
|
|
27
|
+
success:
|
|
28
|
+
background: "{ui.success-weak}"
|
|
29
|
+
warning:
|
|
30
|
+
background: "{ui.warning-weak}"
|
|
31
|
+
danger:
|
|
32
|
+
background: "{ui.danger-weak}"
|
|
33
|
+
Alert:
|
|
34
|
+
default:
|
|
35
|
+
background: "{ui.surface}"
|
|
36
|
+
success:
|
|
37
|
+
background: "{ui.success-weak}"
|
|
38
|
+
warning:
|
|
39
|
+
background: "{ui.warning-weak}"
|
|
40
|
+
danger:
|
|
41
|
+
background: "{ui.danger-weak}"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Default design tokens — mirrors renderers/react/runtime/theme.css :root variables.
|
|
2
|
+
ui:
|
|
3
|
+
bg:
|
|
4
|
+
$type: color
|
|
5
|
+
$value: "#f6f8fb"
|
|
6
|
+
surface:
|
|
7
|
+
$type: color
|
|
8
|
+
$value: "#ffffff"
|
|
9
|
+
surface-muted:
|
|
10
|
+
$type: color
|
|
11
|
+
$value: "#f8fafc"
|
|
12
|
+
text:
|
|
13
|
+
$type: color
|
|
14
|
+
$value: "#111827"
|
|
15
|
+
text-muted:
|
|
16
|
+
$type: color
|
|
17
|
+
$value: "#6b7280"
|
|
18
|
+
primary:
|
|
19
|
+
$type: color
|
|
20
|
+
$value: "#2563eb"
|
|
21
|
+
primary-weak:
|
|
22
|
+
$type: color
|
|
23
|
+
$value: "#dbeafe"
|
|
24
|
+
success:
|
|
25
|
+
$type: color
|
|
26
|
+
$value: "#047857"
|
|
27
|
+
success-weak:
|
|
28
|
+
$type: color
|
|
29
|
+
$value: "#d1fae5"
|
|
30
|
+
warning:
|
|
31
|
+
$type: color
|
|
32
|
+
$value: "#b45309"
|
|
33
|
+
warning-weak:
|
|
34
|
+
$type: color
|
|
35
|
+
$value: "#fef3c7"
|
|
36
|
+
danger:
|
|
37
|
+
$type: color
|
|
38
|
+
$value: "#dc2626"
|
|
39
|
+
danger-weak:
|
|
40
|
+
$type: color
|
|
41
|
+
$value: "#fee2e2"
|
|
42
|
+
on-primary:
|
|
43
|
+
$type: color
|
|
44
|
+
$value: "#ffffff"
|
|
45
|
+
button-secondary:
|
|
46
|
+
$type: color
|
|
47
|
+
$value: "#eef2f7"
|
|
48
|
+
transparent:
|
|
49
|
+
$type: color
|
|
50
|
+
$value: "transparent"
|
|
51
|
+
border:
|
|
52
|
+
$type: color
|
|
53
|
+
$value: "#e5e7eb"
|
|
54
|
+
shadow:
|
|
55
|
+
$type: shadow
|
|
56
|
+
$value: "0 10px 30px rgba(15, 23, 42, 0.08)"
|
|
57
|
+
font:
|
|
58
|
+
$type: typography
|
|
59
|
+
$value: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"levelProp": { "type": "string" },
|
|
13
13
|
"defaultLevel": { "type": "number" },
|
|
14
14
|
"actionEvent": { "type": "enum", "values": ["onClick", "onSubmit"] },
|
|
15
|
-
"variantClass": { "type": "boolean" },
|
|
16
15
|
"fieldWrap": { "type": "boolean" },
|
|
17
16
|
"inputType": { "type": "string" },
|
|
18
17
|
"sizeClass": { "type": "string" },
|
|
@@ -5,83 +5,82 @@
|
|
|
5
5
|
elements:
|
|
6
6
|
Page:
|
|
7
7
|
tag: main
|
|
8
|
-
className:
|
|
8
|
+
className: vc-page
|
|
9
9
|
ariaLabel: true
|
|
10
10
|
Box:
|
|
11
11
|
tag: div
|
|
12
|
-
className:
|
|
12
|
+
className: vc-box
|
|
13
13
|
ariaLabel: true
|
|
14
14
|
Column:
|
|
15
15
|
tag: div
|
|
16
|
-
className:
|
|
16
|
+
className: vc-column
|
|
17
17
|
flex: column
|
|
18
18
|
ariaLabel: true
|
|
19
19
|
Row:
|
|
20
20
|
tag: div
|
|
21
|
-
className:
|
|
21
|
+
className: vc-row
|
|
22
22
|
flex: row
|
|
23
23
|
wrapProp: wrap
|
|
24
24
|
ariaLabel: true
|
|
25
25
|
Stack:
|
|
26
26
|
tag: div
|
|
27
|
-
className:
|
|
27
|
+
className: vc-stack
|
|
28
28
|
ariaLabel: true
|
|
29
29
|
Spacer:
|
|
30
30
|
tag: div
|
|
31
|
-
className:
|
|
31
|
+
className: vc-spacer
|
|
32
32
|
ariaHidden: true
|
|
33
33
|
flexGrowProp: weight
|
|
34
34
|
Divider:
|
|
35
35
|
tag: hr
|
|
36
|
-
className:
|
|
36
|
+
className: vc-divider
|
|
37
37
|
void: true
|
|
38
38
|
Heading:
|
|
39
39
|
tag: dynamic-heading
|
|
40
|
-
className:
|
|
40
|
+
className: vc-heading
|
|
41
41
|
levelProp: level
|
|
42
42
|
defaultLevel: 2
|
|
43
43
|
Text:
|
|
44
44
|
tag: p
|
|
45
|
-
className:
|
|
45
|
+
className: vc-text
|
|
46
46
|
Link:
|
|
47
47
|
tag: a
|
|
48
|
-
className:
|
|
48
|
+
className: vc-link
|
|
49
49
|
actionEvent: onClick
|
|
50
50
|
Button:
|
|
51
51
|
tag: button
|
|
52
|
-
className:
|
|
53
|
-
variantClass: true
|
|
52
|
+
className: vc-button
|
|
54
53
|
actionEvent: onClick
|
|
55
54
|
Form:
|
|
56
55
|
tag: form
|
|
57
|
-
className:
|
|
56
|
+
className: vc-form
|
|
58
57
|
actionEvent: onSubmit
|
|
59
58
|
ariaLabel: true
|
|
60
59
|
TextInput:
|
|
61
60
|
tag: input
|
|
62
|
-
className:
|
|
61
|
+
className: vc-text-input
|
|
63
62
|
fieldWrap: true
|
|
64
63
|
inputType: text
|
|
65
|
-
sizeClass:
|
|
64
|
+
sizeClass: vc-field
|
|
66
65
|
TextArea:
|
|
67
66
|
tag: textarea
|
|
68
|
-
className:
|
|
67
|
+
className: vc-text-area
|
|
69
68
|
fieldWrap: true
|
|
70
69
|
Select:
|
|
71
70
|
tag: select
|
|
72
|
-
className:
|
|
71
|
+
className: vc-select
|
|
73
72
|
fieldWrap: true
|
|
74
|
-
sizeClass:
|
|
73
|
+
sizeClass: vc-field
|
|
75
74
|
optionsProp: options
|
|
76
75
|
Checkbox:
|
|
77
76
|
tag: label
|
|
78
|
-
className:
|
|
77
|
+
className: vc-checkbox
|
|
79
78
|
checkbox: true
|
|
80
79
|
List:
|
|
81
80
|
tag: ul
|
|
82
|
-
className:
|
|
81
|
+
className: vc-list
|
|
83
82
|
defaultRole: list
|
|
84
83
|
ListItem:
|
|
85
84
|
tag: li
|
|
86
|
-
className:
|
|
85
|
+
className: vc-list-item
|
|
87
86
|
defaultRole: listitem
|
|
@@ -119,22 +119,47 @@ function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSub
|
|
|
119
119
|
return `onClick={() => { void dispatchAction(${expr}); }}`;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
function
|
|
122
|
+
function componentToKebab(name: string): string {
|
|
123
|
+
return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function variantClassSuffix(elementName: string, props: Record<string, IrValue>): string | undefined {
|
|
127
|
+
const variantLit = propLiteral(props, 'variant');
|
|
128
|
+
if (typeof variantLit === 'string') {
|
|
129
|
+
return ` vc-${componentToKebab(elementName)}--${variantLit}`;
|
|
130
|
+
}
|
|
131
|
+
const variant = propExpr(props, 'variant');
|
|
132
|
+
if (variant) return ` vc-${componentToKebab(elementName)}--\${${variant}}`;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function classNameAttr(
|
|
137
|
+
elementName: string,
|
|
138
|
+
entry: ElementMapEntry,
|
|
139
|
+
props: Record<string, IrValue>,
|
|
140
|
+
headingLevel?: number,
|
|
141
|
+
): string {
|
|
123
142
|
const base = entry.className ?? '';
|
|
124
143
|
if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
|
|
125
|
-
|
|
144
|
+
const levelClass = `${base} vc-heading-${headingLevel}`;
|
|
145
|
+
const variantSuffix = variantClassSuffix(elementName, props);
|
|
146
|
+
if (!variantSuffix) return `className="${levelClass}"`;
|
|
147
|
+
const variantLit = propLiteral(props, 'variant');
|
|
148
|
+
if (typeof variantLit === 'string') {
|
|
149
|
+
return `className="${levelClass}${variantSuffix}"`;
|
|
150
|
+
}
|
|
151
|
+
return `className={\`${levelClass}${variantSuffix}\`}`;
|
|
126
152
|
}
|
|
127
|
-
|
|
153
|
+
const variantSuffix = variantClassSuffix(elementName, props);
|
|
154
|
+
if (variantSuffix) {
|
|
128
155
|
const variantLit = propLiteral(props, 'variant');
|
|
129
156
|
if (typeof variantLit === 'string') {
|
|
130
|
-
return `className="${base}
|
|
157
|
+
return `className="${base}${variantSuffix}"`;
|
|
131
158
|
}
|
|
132
|
-
|
|
133
|
-
if (variant) return `className={\`${base} ui-button-\${${variant} ?? 'secondary'}\`}`;
|
|
134
|
-
return `className="${base} ui-button-secondary"`;
|
|
159
|
+
return `className={\`${base}${variantSuffix}\`}`;
|
|
135
160
|
}
|
|
136
161
|
if (entry.wrapProp && propLiteral(props, entry.wrapProp) === true) {
|
|
137
|
-
return `className="${base}
|
|
162
|
+
return `className="${base} vc-wrap"`;
|
|
138
163
|
}
|
|
139
164
|
if (entry.sizeClass && props.size) {
|
|
140
165
|
const size = propExpr(props, 'size');
|
|
@@ -150,22 +175,22 @@ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: num
|
|
|
150
175
|
const label = propExpr(props, 'label');
|
|
151
176
|
const help = propExpr(props, 'help');
|
|
152
177
|
const error = propExpr(props, 'error');
|
|
153
|
-
const lines = [`${indent(level)}<label className="
|
|
178
|
+
const lines = [`${indent(level)}<label className="vc-field">`];
|
|
154
179
|
if (typeof labelLit === 'string') {
|
|
155
|
-
lines.push(`${indent(level + 1)}<span className="
|
|
180
|
+
lines.push(`${indent(level + 1)}<span className="vc-field-label">{${JSON.stringify(labelLit)}}</span>`);
|
|
156
181
|
} else if (label) {
|
|
157
|
-
lines.push(`${indent(level + 1)}{${label} ? <span className="
|
|
182
|
+
lines.push(`${indent(level + 1)}{${label} ? <span className="vc-field-label">{${label}}</span> : null}`);
|
|
158
183
|
}
|
|
159
184
|
lines.push(`${indent(level + 1)}${inner}`);
|
|
160
185
|
if (typeof helpLit === 'string') {
|
|
161
|
-
lines.push(`${indent(level + 1)}<span className="
|
|
186
|
+
lines.push(`${indent(level + 1)}<span className="vc-field-help">{${JSON.stringify(helpLit)}}</span>`);
|
|
162
187
|
} else if (help) {
|
|
163
|
-
lines.push(`${indent(level + 1)}{${help} ? <span className="
|
|
188
|
+
lines.push(`${indent(level + 1)}{${help} ? <span className="vc-field-help">{${help}}</span> : null}`);
|
|
164
189
|
}
|
|
165
190
|
if (typeof errorLit === 'string') {
|
|
166
|
-
lines.push(`${indent(level + 1)}<span className="
|
|
191
|
+
lines.push(`${indent(level + 1)}<span className="vc-field-error">{${JSON.stringify(errorLit)}}</span>`);
|
|
167
192
|
} else if (error) {
|
|
168
|
-
lines.push(`${indent(level + 1)}{${error} ? <span className="
|
|
193
|
+
lines.push(`${indent(level + 1)}{${error} ? <span className="vc-field-error">{${error}}</span> : null}`);
|
|
169
194
|
}
|
|
170
195
|
lines.push(`${indent(level)}</label>`);
|
|
171
196
|
return lines.join('\n');
|
|
@@ -176,7 +201,7 @@ function emitCheckbox(props: Record<string, IrValue>, level: number): string {
|
|
|
176
201
|
const label = propExpr(props, 'label') ?? '""';
|
|
177
202
|
const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
|
|
178
203
|
const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
|
|
179
|
-
return `${indent(level)}<label className="
|
|
204
|
+
return `${indent(level)}<label className="vc-checkbox">
|
|
180
205
|
${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
|
|
181
206
|
${indent(level + 1)}<span>{${label}}</span>
|
|
182
207
|
${indent(level)}</label>`;
|
|
@@ -193,8 +218,13 @@ function emitSelectOptions(props: Record<string, IrValue>, level: number): strin
|
|
|
193
218
|
return `${indent(level + 1)}{${expr}.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}`;
|
|
194
219
|
}
|
|
195
220
|
|
|
196
|
-
function emitVoidInput(
|
|
197
|
-
|
|
221
|
+
function emitVoidInput(
|
|
222
|
+
elementName: string,
|
|
223
|
+
entry: ElementMapEntry,
|
|
224
|
+
props: Record<string, IrValue>,
|
|
225
|
+
level: number,
|
|
226
|
+
): string {
|
|
227
|
+
const attrs: string[] = [classNameAttr(elementName, entry, props), styleAttr(entry, props)];
|
|
198
228
|
if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
|
|
199
229
|
if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
|
|
200
230
|
if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
|
|
@@ -205,7 +235,13 @@ function emitVoidInput(entry: ElementMapEntry, props: Record<string, IrValue>, l
|
|
|
205
235
|
return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
|
|
206
236
|
}
|
|
207
237
|
|
|
208
|
-
function emitOpenTag(
|
|
238
|
+
function emitOpenTag(
|
|
239
|
+
elementName: string,
|
|
240
|
+
entry: ElementMapEntry,
|
|
241
|
+
props: Record<string, IrValue>,
|
|
242
|
+
tag: string,
|
|
243
|
+
headingLevel?: number,
|
|
244
|
+
): string {
|
|
209
245
|
const attrs: string[] = [];
|
|
210
246
|
if (entry.ariaHidden) attrs.push('aria-hidden');
|
|
211
247
|
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
@@ -223,7 +259,7 @@ function emitOpenTag(entry: ElementMapEntry, props: Record<string, IrValue>, tag
|
|
|
223
259
|
if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
|
|
224
260
|
if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
|
|
225
261
|
if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
|
|
226
|
-
attrs.push(classNameAttr(entry, props, headingLevel));
|
|
262
|
+
attrs.push(classNameAttr(elementName, entry, props, headingLevel));
|
|
227
263
|
attrs.push(styleAttr(entry, props));
|
|
228
264
|
return `<${tag} ${attrs.join(' ')}`;
|
|
229
265
|
}
|
|
@@ -290,10 +326,10 @@ function lowerVocabularyElement(
|
|
|
290
326
|
const tag = entry.tag;
|
|
291
327
|
let inner: string;
|
|
292
328
|
if (entry.optionsProp && props[entry.optionsProp]) {
|
|
293
|
-
const open = emitOpenTag(entry, props, tag);
|
|
329
|
+
const open = emitOpenTag(node.name, entry, props, tag);
|
|
294
330
|
inner = `${indent(level + 1)}${open}>\n${emitSelectOptions(props, level + 2)}\n${indent(level + 1)}</${tag}>`;
|
|
295
331
|
} else {
|
|
296
|
-
inner = emitVoidInput(entry, props, level + 1);
|
|
332
|
+
inner = emitVoidInput(node.name, entry, props, level + 1);
|
|
297
333
|
}
|
|
298
334
|
return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
|
|
299
335
|
}
|
|
@@ -303,7 +339,7 @@ function lowerVocabularyElement(
|
|
|
303
339
|
if (typeof levelLit === 'number') {
|
|
304
340
|
const clamped = Math.min(6, Math.max(1, levelLit));
|
|
305
341
|
const tag = `h${clamped}`;
|
|
306
|
-
const open = emitOpenTag(entry, props, tag, clamped);
|
|
342
|
+
const open = emitOpenTag(node.name, entry, props, tag, clamped);
|
|
307
343
|
const body = childContent
|
|
308
344
|
? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
|
|
309
345
|
: `${indent(level)}${open} />`;
|
|
@@ -311,17 +347,17 @@ function lowerVocabularyElement(
|
|
|
311
347
|
}
|
|
312
348
|
const levelExpr = propExpr(props, 'level') ?? '2';
|
|
313
349
|
const inner = childContent
|
|
314
|
-
? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`
|
|
350
|
+
? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`vc-heading vc-heading-\${__level}\`} style={commonStyle(${collectStyleProps(props)})}>{/* children below */}</Tag>; })()}`
|
|
315
351
|
: '';
|
|
316
|
-
return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
|
|
352
|
+
return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
|
|
317
353
|
}
|
|
318
354
|
|
|
319
355
|
const tag = entry.tag;
|
|
320
356
|
if (entry.void) {
|
|
321
|
-
return visibilityGuard(`${indent(level)}${emitOpenTag(entry, props, tag)} />`, props, level);
|
|
357
|
+
return visibilityGuard(`${indent(level)}${emitOpenTag(node.name, entry, props, tag)} />`, props, level);
|
|
322
358
|
}
|
|
323
359
|
|
|
324
|
-
const open = emitOpenTag(entry, props, tag);
|
|
360
|
+
const open = emitOpenTag(node.name, entry, props, tag);
|
|
325
361
|
let body: string;
|
|
326
362
|
if (!childContent && entry.optionsProp && props[entry.optionsProp]) {
|
|
327
363
|
body = `${indent(level)}${open}>\n${emitSelectOptions(props, level + 1)}\n${indent(level)}</${tag}>`;
|
|
@@ -3,6 +3,14 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
import type { ViewContractsConfig } from '../../src/config.js';
|
|
4
4
|
import { projectRoot } from '../../src/config.js';
|
|
5
5
|
import { loadThemeCss } from '../../src/catalog/loadThemeCss.js';
|
|
6
|
+
import {
|
|
7
|
+
defaultThemePath,
|
|
8
|
+
defaultTokensPath,
|
|
9
|
+
emitTokenCss,
|
|
10
|
+
emitVariantCss,
|
|
11
|
+
extractStructuralThemeCss,
|
|
12
|
+
loadValidateDesign,
|
|
13
|
+
} from '../../src/design/index.js';
|
|
6
14
|
import { packageRoot } from '../../src/lib/packageRoot.js';
|
|
7
15
|
import { renderTemplate } from '../../src/renderer/template.js';
|
|
8
16
|
import { reactRuntimeDir, reactTemplatesDir } from './paths.js';
|
|
@@ -32,7 +40,7 @@ async function copySchemaBundle(componentsDir: string): Promise<void> {
|
|
|
32
40
|
|
|
33
41
|
const schemaDest = resolve(componentsDir, 'schema');
|
|
34
42
|
await cp(schemaSrc, schemaDest, { recursive: true });
|
|
35
|
-
for (const name of ['dsl-properties.ts', '
|
|
43
|
+
for (const name of ['dsl-properties.ts', 'dsl-enums.ts', 'schema-document.ts', 'index.ts'] as const) {
|
|
36
44
|
const file = resolve(schemaDest, name);
|
|
37
45
|
if (await pathExists(file)) {
|
|
38
46
|
await writeFile(file, rewriteSchemaImports(await readFile(file, 'utf-8')), 'utf-8');
|
|
@@ -49,15 +57,30 @@ async function copyStyleBundle(componentsDir: string): Promise<void> {
|
|
|
49
57
|
await writeFile(resolve(componentsDir, 'types.ts'), types, 'utf-8');
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
async function writeThemeCss(
|
|
60
|
+
async function writeThemeCss(
|
|
61
|
+
root: string,
|
|
62
|
+
themeOut: string,
|
|
63
|
+
config: ViewContractsConfig,
|
|
64
|
+
templatesDir?: string,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const tokensPath = config.design?.tokens ?? defaultTokensPath();
|
|
67
|
+
const themePath = config.design?.theme ?? defaultThemePath();
|
|
68
|
+
const { tokenIR, themeIR } = await loadValidateDesign(tokensPath, themePath);
|
|
53
69
|
const runtimeTheme = resolve(reactRuntimeDir(), 'theme.css');
|
|
54
|
-
|
|
55
|
-
const
|
|
70
|
+
const runtimeCss = await loadThemeCss(runtimeTheme);
|
|
71
|
+
const variantCss = emitVariantCss(tokenIR, themeIR);
|
|
72
|
+
let css = `${emitTokenCss(tokenIR)}\n\n${variantCss}\n\n${extractStructuralThemeCss(runtimeCss)}`;
|
|
73
|
+
const extensionsCss = config.design?.extensionsCss ?? resolve(root, 'src/theme.extensions.css');
|
|
56
74
|
if (await pathExists(extensionsCss)) {
|
|
57
75
|
css += `\n\n${await readFile(extensionsCss, 'utf-8')}`;
|
|
58
76
|
}
|
|
59
77
|
const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
|
|
60
78
|
await writeFile(themeOut, content, 'utf-8');
|
|
79
|
+
|
|
80
|
+
const themeDir = resolve(config.generated.reactDir, '../theme');
|
|
81
|
+
await mkdir(themeDir, { recursive: true });
|
|
82
|
+
await writeFile(resolve(themeDir, 'tokens.json'), `${JSON.stringify(tokenIR, null, 2)}\n`, 'utf-8');
|
|
83
|
+
await writeFile(resolve(themeDir, 'theme.json'), `${JSON.stringify(themeIR, null, 2)}\n`, 'utf-8');
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
async function removeStaleGeneratedFiles(componentsDir: string): Promise<void> {
|
|
@@ -117,7 +140,7 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
|
|
|
117
140
|
'utf-8',
|
|
118
141
|
);
|
|
119
142
|
|
|
120
|
-
await writeThemeCss(root, resolve(config.generated.reactDir, '../theme.css'), templatesDir);
|
|
143
|
+
await writeThemeCss(root, resolve(config.generated.reactDir, '../theme.css'), config, templatesDir);
|
|
121
144
|
await writeFile(
|
|
122
145
|
resolve(runtimeDir, 'types.ts'),
|
|
123
146
|
await renderTemplate(templates, 'runtime-types.ts.hbs', {}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CSSProperties } from 'react';
|
|
2
|
-
import type { Align,
|
|
2
|
+
import type { Align, ContentAlign, FontWeight, Justify } from '../../../src/generated/schema/dsl-enums.js';
|
|
3
3
|
import type { CommonProps } from './types.js';
|
|
4
4
|
|
|
5
5
|
export function mapAlign(value?: Align): string | undefined {
|
|
@@ -23,12 +23,6 @@ function len(value: number): string {
|
|
|
23
23
|
return `${value}px`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const SHADOW: Record<Exclude<Shadow, 'none'>, string> = {
|
|
27
|
-
sm: '0 1px 3px rgba(15,23,42,0.08)',
|
|
28
|
-
md: '0 10px 30px rgba(15, 23, 42, 0.08)',
|
|
29
|
-
lg: '0 20px 40px rgba(15, 23, 42, 0.12)',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
26
|
const CONTENT_ALIGN: Record<ContentAlign, string> = {
|
|
33
27
|
topStart: 'start start',
|
|
34
28
|
top: 'center start',
|
|
@@ -41,12 +35,6 @@ const CONTENT_ALIGN: Record<ContentAlign, string> = {
|
|
|
41
35
|
bottomEnd: 'end end',
|
|
42
36
|
};
|
|
43
37
|
|
|
44
|
-
function shadowStyle(shadow: Shadow | boolean | undefined): string | undefined {
|
|
45
|
-
if (shadow === true) return SHADOW.md;
|
|
46
|
-
if (!shadow || shadow === 'none') return undefined;
|
|
47
|
-
return SHADOW[shadow];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
38
|
function fontSizeStyle(size: number | string | undefined): string | undefined {
|
|
51
39
|
if (size === undefined) return undefined;
|
|
52
40
|
return typeof size === 'number' ? len(size) : size;
|
|
@@ -65,14 +53,6 @@ export function commonStyle(props: CommonProps): CSSProperties {
|
|
|
65
53
|
if (typeof props.padding === 'number') style.padding = len(props.padding);
|
|
66
54
|
if (typeof (props as { margin?: number }).margin === 'number') style.margin = len((props as { margin: number }).margin);
|
|
67
55
|
if (typeof props.gap === 'number') style.gap = len(props.gap);
|
|
68
|
-
if (typeof props.radius === 'number') style.borderRadius = len(props.radius);
|
|
69
|
-
if (props.border) style.border = '1px solid var(--ui-border)';
|
|
70
|
-
const shadow = shadowStyle(props.shadow);
|
|
71
|
-
if (shadow) style.boxShadow = shadow;
|
|
72
|
-
if (props.opacity !== undefined) style.opacity = props.opacity;
|
|
73
|
-
if (props.clip && props.clip !== 'none') style.overflow = 'hidden';
|
|
74
|
-
if (props.background) style.background = `var(--ui-${props.background}, ${props.background})`;
|
|
75
|
-
if (props.foreground) style.color = `var(--ui-${props.foreground}, ${props.foreground})`;
|
|
76
56
|
if (props.textAlign) style.textAlign = props.textAlign === 'start' ? 'left' : props.textAlign === 'end' ? 'right' : 'center';
|
|
77
57
|
if (props.contentAlign) style.placeItems = CONTENT_ALIGN[props.contentAlign];
|
|
78
58
|
if ((props as { overflow?: string }).overflow) style.overflow = (props as { overflow: string }).overflow;
|
|
@@ -93,4 +73,29 @@ export function toneClass(tone: string | undefined): string {
|
|
|
93
73
|
return tone ? `ui-tone-${tone}` : 'ui-tone-default';
|
|
94
74
|
}
|
|
95
75
|
|
|
96
|
-
|
|
76
|
+
function componentToKebab(name: string): string {
|
|
77
|
+
return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_VARIANT_ALIASES = new Set(['default', 'muted']);
|
|
81
|
+
|
|
82
|
+
/** Resolve theme variant name from variant or legacy tone prop. */
|
|
83
|
+
export function resolveExtensionVariant(variant?: string, tone?: string): string {
|
|
84
|
+
const raw = variant ?? tone;
|
|
85
|
+
if (!raw || DEFAULT_VARIANT_ALIASES.has(raw)) return 'default';
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build vc-* base + --variant modifier for themed extension components. */
|
|
90
|
+
export function extensionClassName(
|
|
91
|
+
component: string,
|
|
92
|
+
options?: { variant?: string; tone?: string; extra?: string | false },
|
|
93
|
+
): string {
|
|
94
|
+
const base = `vc-${componentToKebab(component)}`;
|
|
95
|
+
const variant = resolveExtensionVariant(options?.variant, options?.tone);
|
|
96
|
+
const parts = [base, `${base}--${variant}`];
|
|
97
|
+
if (options?.extra) parts.push(options.extra);
|
|
98
|
+
return parts.filter(Boolean).join(' ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type { ContentAlign };
|