openuispec 0.1.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/LICENSE +21 -0
- package/README.md +214 -0
- package/cli/index.ts +49 -0
- package/cli/init.ts +390 -0
- package/drift/index.ts +398 -0
- package/examples/taskflow/README.md +103 -0
- package/examples/taskflow/contracts/README.md +18 -0
- package/examples/taskflow/contracts/action_trigger.yaml +7 -0
- package/examples/taskflow/contracts/collection.yaml +7 -0
- package/examples/taskflow/contracts/data_display.yaml +7 -0
- package/examples/taskflow/contracts/feedback.yaml +7 -0
- package/examples/taskflow/contracts/input_field.yaml +7 -0
- package/examples/taskflow/contracts/nav_container.yaml +7 -0
- package/examples/taskflow/contracts/surface.yaml +7 -0
- package/examples/taskflow/contracts/x_media_player.yaml +185 -0
- package/examples/taskflow/flows/create_task.yaml +171 -0
- package/examples/taskflow/flows/edit_task.yaml +131 -0
- package/examples/taskflow/locales/en.json +158 -0
- package/examples/taskflow/openuispec.yaml +144 -0
- package/examples/taskflow/platform/android.yaml +32 -0
- package/examples/taskflow/platform/ios.yaml +39 -0
- package/examples/taskflow/platform/web.yaml +35 -0
- package/examples/taskflow/screens/calendar.yaml +23 -0
- package/examples/taskflow/screens/home.yaml +220 -0
- package/examples/taskflow/screens/profile_edit.yaml +70 -0
- package/examples/taskflow/screens/project_detail.yaml +65 -0
- package/examples/taskflow/screens/projects.yaml +142 -0
- package/examples/taskflow/screens/settings.yaml +178 -0
- package/examples/taskflow/screens/task_detail.yaml +317 -0
- package/examples/taskflow/tokens/color.yaml +88 -0
- package/examples/taskflow/tokens/elevation.yaml +27 -0
- package/examples/taskflow/tokens/icons.yaml +189 -0
- package/examples/taskflow/tokens/layout.yaml +156 -0
- package/examples/taskflow/tokens/motion.yaml +41 -0
- package/examples/taskflow/tokens/spacing.yaml +23 -0
- package/examples/taskflow/tokens/themes.yaml +34 -0
- package/examples/taskflow/tokens/typography.yaml +61 -0
- package/package.json +43 -0
- package/schema/custom-contract.schema.json +257 -0
- package/schema/defs/action.schema.json +272 -0
- package/schema/defs/adaptive.schema.json +13 -0
- package/schema/defs/common.schema.json +330 -0
- package/schema/defs/data-binding.schema.json +119 -0
- package/schema/defs/validation.schema.json +121 -0
- package/schema/flow.schema.json +164 -0
- package/schema/locale.schema.json +26 -0
- package/schema/openuispec.schema.json +287 -0
- package/schema/platform.schema.json +95 -0
- package/schema/screen.schema.json +346 -0
- package/schema/tokens/color.schema.json +104 -0
- package/schema/tokens/elevation.schema.json +84 -0
- package/schema/tokens/icons.schema.json +149 -0
- package/schema/tokens/layout.schema.json +170 -0
- package/schema/tokens/motion.schema.json +83 -0
- package/schema/tokens/spacing.schema.json +93 -0
- package/schema/tokens/themes.schema.json +92 -0
- package/schema/tokens/typography.schema.json +106 -0
- package/schema/validate.ts +258 -0
- package/spec/openuispec-v0.1.md +3677 -0
|
@@ -0,0 +1,3677 @@
|
|
|
1
|
+
# OpenUISpec v0.1
|
|
2
|
+
|
|
3
|
+
> A single source of truth design language for AI-native, platform-native app development.
|
|
4
|
+
|
|
5
|
+
**Status:** Draft
|
|
6
|
+
**Version:** 0.1
|
|
7
|
+
**Authors:** Rustam Samandarov
|
|
8
|
+
**Last updated:** 2026-03-13
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Philosophy
|
|
13
|
+
|
|
14
|
+
OpenUISpec is not a cross-platform framework. It is a **semantic design language specification** from which AI generates native platform code. The spec describes *what* UI does and *how it should feel* — never *which widget to use*.
|
|
15
|
+
|
|
16
|
+
### Core principles
|
|
17
|
+
|
|
18
|
+
1. **Semantic over visual.** The spec defines behavioral intent, not pixel layouts. A "primary action trigger" maps to `Button` in SwiftUI, `Button` in Compose, and `<button>` in HTML — the spec never says "button."
|
|
19
|
+
2. **Constrained freedom.** Tokens use ranges, not exact values. Close enough to be recognizably the same brand; loose enough for each platform to feel native.
|
|
20
|
+
3. **Contract-driven.** Every component is a behavioral contract with typed props, a state machine, and accessibility requirements. If a state exists in the spec, the generated code must handle it.
|
|
21
|
+
4. **AI-first authoring.** The spec is structured for machine consumption: strongly typed, validatable, with generation hints that tell AI what it must, should, and may produce.
|
|
22
|
+
5. **Platform respect.** iOS should feel like iOS. Android should feel like Android. Web should feel like the web. The spec preserves platform identity; it does not erase it.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 2. Document structure
|
|
27
|
+
|
|
28
|
+
An OpenUISpec project consists of:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
project/
|
|
32
|
+
├── openuispec.yaml # Root manifest
|
|
33
|
+
├── tokens/
|
|
34
|
+
│ ├── color.yaml
|
|
35
|
+
│ ├── typography.yaml
|
|
36
|
+
│ ├── spacing.yaml
|
|
37
|
+
│ ├── motion.yaml
|
|
38
|
+
│ ├── layout.yaml
|
|
39
|
+
│ ├── themes.yaml
|
|
40
|
+
│ └── icons.yaml
|
|
41
|
+
├── contracts/
|
|
42
|
+
│ ├── action_trigger.yaml
|
|
43
|
+
│ ├── data_display.yaml
|
|
44
|
+
│ ├── input_field.yaml
|
|
45
|
+
│ ├── nav_container.yaml
|
|
46
|
+
│ ├── feedback.yaml
|
|
47
|
+
│ ├── surface.yaml
|
|
48
|
+
│ ├── collection.yaml
|
|
49
|
+
│ └── x_media_player.yaml # Custom contract (Section 12)
|
|
50
|
+
├── screens/
|
|
51
|
+
│ ├── home.yaml
|
|
52
|
+
│ ├── order_detail.yaml
|
|
53
|
+
│ └── settings.yaml
|
|
54
|
+
├── flows/
|
|
55
|
+
│ ├── onboarding.yaml
|
|
56
|
+
│ └── checkout.yaml
|
|
57
|
+
├── locales/
|
|
58
|
+
│ └── en.json
|
|
59
|
+
└── platform/
|
|
60
|
+
├── ios.yaml
|
|
61
|
+
├── android.yaml
|
|
62
|
+
└── web.yaml
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Root manifest
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
# openuispec.yaml
|
|
69
|
+
spec_version: "0.1"
|
|
70
|
+
project:
|
|
71
|
+
name: "MyApp"
|
|
72
|
+
description: "A sample application defined in OpenUISpec"
|
|
73
|
+
|
|
74
|
+
includes:
|
|
75
|
+
tokens: "./tokens/"
|
|
76
|
+
contracts: "./contracts/"
|
|
77
|
+
screens: "./screens/"
|
|
78
|
+
flows: "./flows/"
|
|
79
|
+
platform: "./platform/"
|
|
80
|
+
locales: "./locales/"
|
|
81
|
+
|
|
82
|
+
generation:
|
|
83
|
+
targets: [ios, android, web]
|
|
84
|
+
ai_model: "any" # no model lock-in
|
|
85
|
+
output_format:
|
|
86
|
+
ios: { language: swift, framework: swiftui }
|
|
87
|
+
android: { language: kotlin, framework: compose }
|
|
88
|
+
web: { language: typescript, framework: react }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 3. Token layer
|
|
94
|
+
|
|
95
|
+
Tokens define the visual language. Every token has three parts: a **semantic description** (why it exists), a **reference value** (the canonical default), and a **constraint range** (how far platforms may deviate).
|
|
96
|
+
|
|
97
|
+
### 3.1 Color tokens
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
# tokens/color.yaml
|
|
101
|
+
color:
|
|
102
|
+
brand:
|
|
103
|
+
primary:
|
|
104
|
+
semantic: "Main brand identity, primary actions, key interactive elements"
|
|
105
|
+
reference: "#2563EB"
|
|
106
|
+
range:
|
|
107
|
+
hue: [215, 225]
|
|
108
|
+
saturation: [70, 85]
|
|
109
|
+
lightness: [45, 55]
|
|
110
|
+
on_color:
|
|
111
|
+
semantic: "Text/icons on brand.primary backgrounds"
|
|
112
|
+
reference: "#FFFFFF"
|
|
113
|
+
contrast_min: 4.5 # WCAG AA
|
|
114
|
+
platform:
|
|
115
|
+
ios: { dynamic: true }
|
|
116
|
+
android: { harmonize: true }
|
|
117
|
+
web: { exact: true }
|
|
118
|
+
|
|
119
|
+
secondary:
|
|
120
|
+
semantic: "Supporting brand color for accents and secondary elements"
|
|
121
|
+
reference: "#7C3AED"
|
|
122
|
+
range:
|
|
123
|
+
hue: [265, 275]
|
|
124
|
+
saturation: [65, 80]
|
|
125
|
+
lightness: [40, 55]
|
|
126
|
+
on_color:
|
|
127
|
+
reference: "#FFFFFF"
|
|
128
|
+
contrast_min: 4.5
|
|
129
|
+
|
|
130
|
+
surface:
|
|
131
|
+
primary:
|
|
132
|
+
semantic: "Main content background"
|
|
133
|
+
role: background
|
|
134
|
+
reference: "#FFFFFF"
|
|
135
|
+
secondary:
|
|
136
|
+
semantic: "Grouped/card backgrounds, subtle elevation"
|
|
137
|
+
role: background-variant
|
|
138
|
+
reference: "#F8F9FA"
|
|
139
|
+
tertiary:
|
|
140
|
+
semantic: "Page-level background behind cards"
|
|
141
|
+
role: canvas
|
|
142
|
+
reference: "#F1F3F5"
|
|
143
|
+
|
|
144
|
+
text:
|
|
145
|
+
primary:
|
|
146
|
+
semantic: "High-emphasis body text, headings"
|
|
147
|
+
role: on-surface
|
|
148
|
+
reference: "#111827"
|
|
149
|
+
contrast_min: 7.0 # WCAG AAA
|
|
150
|
+
secondary:
|
|
151
|
+
semantic: "Medium-emphasis supporting text"
|
|
152
|
+
role: on-surface-variant
|
|
153
|
+
reference: "#6B7280"
|
|
154
|
+
contrast_min: 4.5
|
|
155
|
+
tertiary:
|
|
156
|
+
semantic: "Low-emphasis hints, placeholders"
|
|
157
|
+
role: on-surface-dim
|
|
158
|
+
reference: "#9CA3AF"
|
|
159
|
+
contrast_min: 3.0
|
|
160
|
+
disabled:
|
|
161
|
+
semantic: "Non-interactive text"
|
|
162
|
+
role: on-surface-disabled
|
|
163
|
+
reference: "#D1D5DB"
|
|
164
|
+
|
|
165
|
+
semantic:
|
|
166
|
+
success:
|
|
167
|
+
value: { hue: [140, 155], saturation: [60, 80], lightness: [35, 45] }
|
|
168
|
+
reference: "#059669"
|
|
169
|
+
on_color: { reference: "#FFFFFF", contrast_min: 4.5 }
|
|
170
|
+
warning:
|
|
171
|
+
value: { hue: [35, 45], saturation: [85, 95], lightness: [45, 55] }
|
|
172
|
+
reference: "#D97706"
|
|
173
|
+
on_color: { reference: "#FFFFFF", contrast_min: 3.0 }
|
|
174
|
+
danger:
|
|
175
|
+
value: { hue: [0, 10], saturation: [70, 85], lightness: [45, 55] }
|
|
176
|
+
reference: "#DC2626"
|
|
177
|
+
on_color: { reference: "#FFFFFF", contrast_min: 4.5 }
|
|
178
|
+
info:
|
|
179
|
+
value: { hue: [200, 215], saturation: [70, 85], lightness: [45, 55] }
|
|
180
|
+
reference: "#2563EB"
|
|
181
|
+
on_color: { reference: "#FFFFFF", contrast_min: 4.5 }
|
|
182
|
+
|
|
183
|
+
border:
|
|
184
|
+
default:
|
|
185
|
+
semantic: "Standard borders and dividers"
|
|
186
|
+
reference: "#E5E7EB"
|
|
187
|
+
opacity: 0.15
|
|
188
|
+
emphasis:
|
|
189
|
+
semantic: "Hover state, focused borders"
|
|
190
|
+
reference: "#D1D5DB"
|
|
191
|
+
opacity: 0.3
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 3.2 Typography tokens
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
# tokens/typography.yaml
|
|
198
|
+
typography:
|
|
199
|
+
font_family:
|
|
200
|
+
primary:
|
|
201
|
+
semantic: "Brand typeface for all UI text"
|
|
202
|
+
value: "DM Sans"
|
|
203
|
+
fallback_strategy: "geometric-sans"
|
|
204
|
+
platform:
|
|
205
|
+
ios: { system_alternative: "SF Pro" }
|
|
206
|
+
android: { system_alternative: "Google Sans" }
|
|
207
|
+
web: { load_strategy: "swap", source: "google_fonts" }
|
|
208
|
+
accent:
|
|
209
|
+
semantic: "Display and editorial emphasis"
|
|
210
|
+
value: "Fraunces"
|
|
211
|
+
fallback_strategy: "serif-display"
|
|
212
|
+
|
|
213
|
+
scale:
|
|
214
|
+
display_lg:
|
|
215
|
+
semantic: "Hero text, splash screens"
|
|
216
|
+
size: { base: 40, range: [36, 44] }
|
|
217
|
+
weight: 700
|
|
218
|
+
tracking: -0.025
|
|
219
|
+
line_height: 1.15
|
|
220
|
+
display:
|
|
221
|
+
semantic: "Major section headers, onboarding titles"
|
|
222
|
+
size: { base: 32, range: [28, 36] }
|
|
223
|
+
weight: 700
|
|
224
|
+
tracking: -0.02
|
|
225
|
+
line_height: 1.2
|
|
226
|
+
heading_lg:
|
|
227
|
+
semantic: "Screen titles"
|
|
228
|
+
size: { base: 24, range: [22, 26] }
|
|
229
|
+
weight: 600
|
|
230
|
+
tracking: -0.015
|
|
231
|
+
line_height: 1.3
|
|
232
|
+
heading:
|
|
233
|
+
semantic: "Section headers"
|
|
234
|
+
size: { base: 20, range: [18, 22] }
|
|
235
|
+
weight: 600
|
|
236
|
+
tracking: -0.01
|
|
237
|
+
line_height: 1.35
|
|
238
|
+
heading_sm:
|
|
239
|
+
semantic: "Subsection headers, card titles"
|
|
240
|
+
size: { base: 16, range: [15, 17] }
|
|
241
|
+
weight: 600
|
|
242
|
+
tracking: 0
|
|
243
|
+
line_height: 1.4
|
|
244
|
+
body:
|
|
245
|
+
semantic: "Primary readable content"
|
|
246
|
+
size: { base: 16, range: [14, 16] }
|
|
247
|
+
weight: 400
|
|
248
|
+
tracking: 0
|
|
249
|
+
line_height: 1.5
|
|
250
|
+
body_sm:
|
|
251
|
+
semantic: "Secondary content, descriptions"
|
|
252
|
+
size: { base: 14, range: [13, 15] }
|
|
253
|
+
weight: 400
|
|
254
|
+
tracking: 0.005
|
|
255
|
+
line_height: 1.45
|
|
256
|
+
caption:
|
|
257
|
+
semantic: "Labels, timestamps, metadata"
|
|
258
|
+
size: { base: 12, range: [11, 13] }
|
|
259
|
+
weight: 400
|
|
260
|
+
tracking: 0.02
|
|
261
|
+
line_height: 1.35
|
|
262
|
+
overline:
|
|
263
|
+
semantic: "Category labels, section tags"
|
|
264
|
+
size: { base: 11, range: [10, 12] }
|
|
265
|
+
weight: 600
|
|
266
|
+
tracking: 0.08
|
|
267
|
+
transform: uppercase
|
|
268
|
+
line_height: 1.3
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### 3.3 Spacing tokens
|
|
272
|
+
|
|
273
|
+
```yaml
|
|
274
|
+
# tokens/spacing.yaml
|
|
275
|
+
spacing:
|
|
276
|
+
base_unit: 4
|
|
277
|
+
platform_flex: 0.15 # platforms may shift ±15%
|
|
278
|
+
|
|
279
|
+
scale:
|
|
280
|
+
none: 0
|
|
281
|
+
xxs: 2
|
|
282
|
+
xs: 4
|
|
283
|
+
sm: 8
|
|
284
|
+
md: { base: 16, range: [12, 16] }
|
|
285
|
+
lg: { base: 24, range: [20, 24] }
|
|
286
|
+
xl: 32
|
|
287
|
+
xxl: 48
|
|
288
|
+
xxxl: 64
|
|
289
|
+
|
|
290
|
+
# Semantic spacing aliases
|
|
291
|
+
aliases:
|
|
292
|
+
page_margin: { horizontal: lg, vertical: md }
|
|
293
|
+
card_padding: { all: md }
|
|
294
|
+
section_gap: xl
|
|
295
|
+
inline_gap: sm
|
|
296
|
+
stack_gap: md
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### 3.4 Motion tokens
|
|
300
|
+
|
|
301
|
+
```yaml
|
|
302
|
+
# tokens/motion.yaml
|
|
303
|
+
motion:
|
|
304
|
+
duration:
|
|
305
|
+
instant: 100
|
|
306
|
+
quick: 200
|
|
307
|
+
normal: 300
|
|
308
|
+
slow: 500
|
|
309
|
+
|
|
310
|
+
easing:
|
|
311
|
+
default: "ease-out"
|
|
312
|
+
enter: "ease-out"
|
|
313
|
+
exit: "ease-in"
|
|
314
|
+
emphasis: "cubic-bezier(0.2, 0, 0, 1)"
|
|
315
|
+
|
|
316
|
+
# Respect reduced motion globally
|
|
317
|
+
reduced_motion: "remove-animation"
|
|
318
|
+
|
|
319
|
+
patterns:
|
|
320
|
+
press_feedback:
|
|
321
|
+
duration: "instant"
|
|
322
|
+
property: "scale"
|
|
323
|
+
value: 0.97
|
|
324
|
+
state_change:
|
|
325
|
+
duration: "quick"
|
|
326
|
+
property: "opacity, background"
|
|
327
|
+
screen_enter:
|
|
328
|
+
duration: "normal"
|
|
329
|
+
easing: "enter"
|
|
330
|
+
pattern: "slide-from-trailing"
|
|
331
|
+
screen_exit:
|
|
332
|
+
duration: "quick"
|
|
333
|
+
easing: "exit"
|
|
334
|
+
pattern: "slide-to-leading"
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 3.5 Elevation tokens
|
|
338
|
+
|
|
339
|
+
Elevation tokens define shadow depth levels for layering surfaces. Each level has platform-specific shadow implementations.
|
|
340
|
+
|
|
341
|
+
```yaml
|
|
342
|
+
# tokens/elevation.yaml
|
|
343
|
+
elevation:
|
|
344
|
+
none:
|
|
345
|
+
semantic: "Flat, no shadow"
|
|
346
|
+
value: "none"
|
|
347
|
+
|
|
348
|
+
sm:
|
|
349
|
+
semantic: "Subtle lift for hover states, input focus"
|
|
350
|
+
platform:
|
|
351
|
+
ios: { shadow: { color: "black", opacity: 0.04, radius: 2, y: 1 } }
|
|
352
|
+
android: { elevation: 1 }
|
|
353
|
+
web: { box_shadow: "0 1px 2px rgba(0,0,0,0.04)" }
|
|
354
|
+
|
|
355
|
+
md:
|
|
356
|
+
semantic: "Cards, toasts, popovers"
|
|
357
|
+
platform:
|
|
358
|
+
ios: { shadow: { color: "black", opacity: 0.08, radius: 8, y: 2 } }
|
|
359
|
+
android: { elevation: 3 }
|
|
360
|
+
web: { box_shadow: "0 2px 8px rgba(0,0,0,0.08)" }
|
|
361
|
+
|
|
362
|
+
lg:
|
|
363
|
+
semantic: "FABs, modals, sheets"
|
|
364
|
+
platform:
|
|
365
|
+
ios: { shadow: { color: "black", opacity: 0.12, radius: 16, y: 4 } }
|
|
366
|
+
android: { elevation: 6 }
|
|
367
|
+
web: { box_shadow: "0 4px 16px rgba(0,0,0,0.12)" }
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Elevation tokens use a 4-level scale (none/sm/md/lg). Screens reference levels by name: `elevation: lg`. Platform implementations translate these into native shadow APIs — `UIKit`/`SwiftUI` shadow modifiers on iOS, `elevation` dp on Android, and CSS `box-shadow` on web.
|
|
371
|
+
|
|
372
|
+
### 3.6 Layout tokens
|
|
373
|
+
|
|
374
|
+
Layout tokens define the adaptive breakpoint vocabulary and layout primitives. See **Section 5.2** for the full adaptive layout system.
|
|
375
|
+
|
|
376
|
+
```yaml
|
|
377
|
+
# tokens/layout.yaml
|
|
378
|
+
layout:
|
|
379
|
+
size_classes:
|
|
380
|
+
compact: { width: { max: 600 }, columns: 4, margin: "spacing.md" }
|
|
381
|
+
regular: { width: { min: 601, max: 1024 }, columns: 8, margin: "spacing.lg" }
|
|
382
|
+
expanded: { width: { min: 1025 }, columns: 12, margin: "spacing.xl" }
|
|
383
|
+
|
|
384
|
+
primitives: [stack, row, grid, scroll_vertical, split_view, adaptive]
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Every screen and section references size classes by name (`compact`, `regular`, `expanded`), never by pixel values.
|
|
388
|
+
|
|
389
|
+
### 3.7 Themes
|
|
390
|
+
|
|
391
|
+
```yaml
|
|
392
|
+
# tokens/themes.yaml
|
|
393
|
+
themes:
|
|
394
|
+
default: "light"
|
|
395
|
+
|
|
396
|
+
variants:
|
|
397
|
+
light:
|
|
398
|
+
surface.primary: { lightness: [95, 100] }
|
|
399
|
+
surface.secondary: { lightness: [92, 97] }
|
|
400
|
+
surface.tertiary: { lightness: [88, 93] }
|
|
401
|
+
text.primary: { lightness: [5, 15] }
|
|
402
|
+
text.secondary: { lightness: [35, 45] }
|
|
403
|
+
text.tertiary: { lightness: [55, 65] }
|
|
404
|
+
border.default: { opacity: 0.15 }
|
|
405
|
+
|
|
406
|
+
dark:
|
|
407
|
+
surface.primary: { lightness: [8, 15] }
|
|
408
|
+
surface.secondary: { lightness: [12, 20] }
|
|
409
|
+
surface.tertiary: { lightness: [5, 10] }
|
|
410
|
+
text.primary: { lightness: [85, 95] }
|
|
411
|
+
text.secondary: { lightness: [55, 65] }
|
|
412
|
+
text.tertiary: { lightness: [40, 50] }
|
|
413
|
+
border.default: { opacity: 0.2 }
|
|
414
|
+
|
|
415
|
+
brand:
|
|
416
|
+
extends: "light"
|
|
417
|
+
surface.primary: { hue: 35, saturation: [5, 10], lightness: [96, 99] }
|
|
418
|
+
surface.secondary: { hue: 35, saturation: [4, 8], lightness: [92, 96] }
|
|
419
|
+
|
|
420
|
+
platform:
|
|
421
|
+
ios:
|
|
422
|
+
supports_dynamic: true
|
|
423
|
+
system_materials: true # iOS vibrancy/blur
|
|
424
|
+
android:
|
|
425
|
+
material_you: true # wallpaper-based theming
|
|
426
|
+
dynamic_color: true
|
|
427
|
+
web:
|
|
428
|
+
prefers_color_scheme: true
|
|
429
|
+
css_custom_properties: true
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
### 3.8 Icon tokens
|
|
435
|
+
|
|
436
|
+
Icon tokens provide a **semantic registry** that maps abstract icon names to platform-specific implementations. The registry is not an asset library — it is a lookup table that tells generators which native icon to use for each semantic concept.
|
|
437
|
+
|
|
438
|
+
#### Structure
|
|
439
|
+
|
|
440
|
+
```yaml
|
|
441
|
+
icons:
|
|
442
|
+
sizes:
|
|
443
|
+
sm: { semantic: "Inline indicators", value: 16 }
|
|
444
|
+
md: { semantic: "Default actions and list items", value: 20 }
|
|
445
|
+
lg: { semantic: "Prominent icons, section headers", value: 24 }
|
|
446
|
+
xl: { semantic: "Hero icons, onboarding", value: 32 }
|
|
447
|
+
|
|
448
|
+
variants:
|
|
449
|
+
default: "outline"
|
|
450
|
+
suffixes:
|
|
451
|
+
_fill: "Filled/solid variant — selected or active states"
|
|
452
|
+
|
|
453
|
+
fallback:
|
|
454
|
+
strategy: "name_passthrough" # or "error"
|
|
455
|
+
missing_icon: "questionmark_circle"
|
|
456
|
+
|
|
457
|
+
registry:
|
|
458
|
+
navigation:
|
|
459
|
+
chevron_right:
|
|
460
|
+
semantic: "Navigate forward, disclosure indicator"
|
|
461
|
+
platform:
|
|
462
|
+
ios: "chevron.right"
|
|
463
|
+
android: "chevron_right"
|
|
464
|
+
web: "chevron-right"
|
|
465
|
+
actions:
|
|
466
|
+
checkmark:
|
|
467
|
+
semantic: "Confirm, complete, done"
|
|
468
|
+
variants: ["circle", "circle_fill", "list"]
|
|
469
|
+
platform:
|
|
470
|
+
ios: "checkmark"
|
|
471
|
+
android: "check"
|
|
472
|
+
web: "check"
|
|
473
|
+
|
|
474
|
+
custom:
|
|
475
|
+
my_app:
|
|
476
|
+
custom_icon:
|
|
477
|
+
semantic: "App-specific icon"
|
|
478
|
+
platform:
|
|
479
|
+
ios: "custom.symbol"
|
|
480
|
+
android: "custom_icon"
|
|
481
|
+
web: "custom-icon"
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
#### Registry categories
|
|
485
|
+
|
|
486
|
+
Icons are organized by semantic purpose:
|
|
487
|
+
|
|
488
|
+
| Category | Examples |
|
|
489
|
+
|----------|----------|
|
|
490
|
+
| `navigation` | chevron_right, chevron_left, arrow_uturn_left |
|
|
491
|
+
| `actions` | plus, pencil, trash, search, checkmark, square_arrow_up |
|
|
492
|
+
| `status` | flag, circle_fill, exclamationmark_triangle |
|
|
493
|
+
| `objects` | folder, calendar, clock, tag, gear, person |
|
|
494
|
+
| `content` | briefcase, rocket, star, heart, lightbulb |
|
|
495
|
+
|
|
496
|
+
#### Sizing scale
|
|
497
|
+
|
|
498
|
+
Four named sizes map to pixel values:
|
|
499
|
+
|
|
500
|
+
| Size | Value | Use case |
|
|
501
|
+
|------|-------|----------|
|
|
502
|
+
| `sm` | 16px | Inline indicators, badge icons |
|
|
503
|
+
| `md` | 20px | Default for actions and list items |
|
|
504
|
+
| `lg` | 24px | Section headers, empty states |
|
|
505
|
+
| `xl` | 32px | Hero icons, onboarding illustrations |
|
|
506
|
+
|
|
507
|
+
#### Variants
|
|
508
|
+
|
|
509
|
+
Icons default to the **outline** style. Suffixes modify the style:
|
|
510
|
+
|
|
511
|
+
- `_fill` — filled/solid, used for selected or active states (e.g. `folder_fill`, `flag_fill`)
|
|
512
|
+
- Additional suffixes (e.g. `_circle`, `_circle_fill`) are declared per icon in the `variants` array
|
|
513
|
+
|
|
514
|
+
When a variant suffix is appended, the generator applies the corresponding platform convention (e.g. `folder_fill` → SF Symbol `folder.fill` on iOS, `folder` with filled style on Android).
|
|
515
|
+
|
|
516
|
+
#### Fallback
|
|
517
|
+
|
|
518
|
+
Two strategies for icons not found in the registry:
|
|
519
|
+
|
|
520
|
+
- **`name_passthrough`** — pass the icon name directly to the native platform API. Useful during development and for platform-specific icons.
|
|
521
|
+
- **`error`** — fail validation if an icon is not registered. Useful for strict design systems.
|
|
522
|
+
|
|
523
|
+
The `missing_icon` field specifies a fallback icon to display when resolution fails at runtime.
|
|
524
|
+
|
|
525
|
+
#### Custom icons
|
|
526
|
+
|
|
527
|
+
The `custom` section follows the same shape as `registry` categories. App-specific icons are registered here to keep the canonical registry clean.
|
|
528
|
+
|
|
529
|
+
#### AI generation requirements
|
|
530
|
+
|
|
531
|
+
- **MUST** resolve every `icon_ref` string against the registry and use the platform-specific mapping for the target platform.
|
|
532
|
+
- **MUST** support all icons declared in the registry and custom sections.
|
|
533
|
+
- **SHOULD** apply the correct variant style based on suffix conventions.
|
|
534
|
+
- **SHOULD** use the sizing scale when no explicit size is provided.
|
|
535
|
+
- **MAY** use `name_passthrough` to support icons not in the registry when the fallback strategy allows it.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## 4. Component contracts
|
|
540
|
+
|
|
541
|
+
Each contract defines a **behavioral family** — a category of UI elements that share the same role, props shape, state machine, and accessibility pattern. The AI maps each contract to the most appropriate native widget per platform.
|
|
542
|
+
|
|
543
|
+
### Contract anatomy
|
|
544
|
+
|
|
545
|
+
Every contract contains:
|
|
546
|
+
|
|
547
|
+
| Section | Purpose | Required |
|
|
548
|
+
|---------|---------|----------|
|
|
549
|
+
| `semantic` | Human-readable description of what this family does | Yes |
|
|
550
|
+
| `props` | Typed inputs the component accepts | Yes |
|
|
551
|
+
| `states` | Finite state machine with valid transitions | Yes |
|
|
552
|
+
| `a11y` | Accessibility role, label pattern, focus behavior | Yes |
|
|
553
|
+
| `tokens` | Visual token bindings per variant | Yes |
|
|
554
|
+
| `platform_mapping` | Default native widget per platform | Yes |
|
|
555
|
+
| `generation` | AI generation hints, must/should/may rules | No |
|
|
556
|
+
| `test_cases` | Suggested verification scenarios | No |
|
|
557
|
+
|
|
558
|
+
### Modifier: `!exact`
|
|
559
|
+
|
|
560
|
+
Any token value can be suffixed with `!exact` to override the range system and force a precise value. Use sparingly — only for brand-critical values like logo dimensions or signature brand colors at specific usage points.
|
|
561
|
+
|
|
562
|
+
```yaml
|
|
563
|
+
# Example: exact override
|
|
564
|
+
logo_width: { value: 120, modifier: "!exact" }
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
### 4.1 `action_trigger`
|
|
570
|
+
|
|
571
|
+
Initiates an action, mutation, or navigation. The most common interactive contract.
|
|
572
|
+
|
|
573
|
+
```yaml
|
|
574
|
+
# contracts/action_trigger.yaml
|
|
575
|
+
action_trigger:
|
|
576
|
+
semantic: "Initiates an action, state change, or navigation event"
|
|
577
|
+
|
|
578
|
+
props:
|
|
579
|
+
label: { type: string, required: true }
|
|
580
|
+
icon: { type: icon_ref, required: false, position: [leading, trailing] }
|
|
581
|
+
variant:
|
|
582
|
+
type: enum
|
|
583
|
+
values: [primary, secondary, tertiary, destructive, ghost]
|
|
584
|
+
default: primary
|
|
585
|
+
size:
|
|
586
|
+
type: enum
|
|
587
|
+
values: [sm, md, lg]
|
|
588
|
+
default: md
|
|
589
|
+
full_width: { type: bool, default: false }
|
|
590
|
+
loading_label: { type: string, required: false }
|
|
591
|
+
|
|
592
|
+
states:
|
|
593
|
+
default:
|
|
594
|
+
transitions_to: [pressed, focused, disabled, loading]
|
|
595
|
+
pressed:
|
|
596
|
+
transitions_to: [default, loading]
|
|
597
|
+
duration: "motion.instant"
|
|
598
|
+
feedback: "motion.patterns.press_feedback"
|
|
599
|
+
focused:
|
|
600
|
+
transitions_to: [pressed, default]
|
|
601
|
+
style: "platform_focus_ring"
|
|
602
|
+
loading:
|
|
603
|
+
transitions_to: [default, disabled]
|
|
604
|
+
behavior: "disables interaction, shows indeterminate progress"
|
|
605
|
+
label_override: "props.loading_label"
|
|
606
|
+
disabled:
|
|
607
|
+
transitions_to: [default]
|
|
608
|
+
behavior: "visually muted, non-interactive, excluded from tab order"
|
|
609
|
+
|
|
610
|
+
a11y:
|
|
611
|
+
role: button
|
|
612
|
+
label: "props.label"
|
|
613
|
+
traits:
|
|
614
|
+
disabled: { announces: "dimmed | disabled" }
|
|
615
|
+
loading: { announces: "loading" }
|
|
616
|
+
focus:
|
|
617
|
+
order: "document"
|
|
618
|
+
style: "platform_default"
|
|
619
|
+
keyboard: { activate: ["Enter", "Space"] }
|
|
620
|
+
|
|
621
|
+
tokens:
|
|
622
|
+
primary:
|
|
623
|
+
background: "color.brand.primary"
|
|
624
|
+
text: "color.brand.primary.on_color"
|
|
625
|
+
min_height: { sm: 32, md: [44, 48], lg: 56 }
|
|
626
|
+
padding_h: { sm: "spacing.sm", md: "spacing.md", lg: "spacing.lg" }
|
|
627
|
+
radius: "spacing.sm"
|
|
628
|
+
secondary:
|
|
629
|
+
background: "color.surface.secondary"
|
|
630
|
+
text: "color.text.primary"
|
|
631
|
+
border: { width: 1, color: "color.border.emphasis" }
|
|
632
|
+
min_height: { sm: 32, md: [44, 48], lg: 56 }
|
|
633
|
+
padding_h: "spacing.md"
|
|
634
|
+
radius: "spacing.sm"
|
|
635
|
+
tertiary:
|
|
636
|
+
background: transparent
|
|
637
|
+
text: "color.brand.primary"
|
|
638
|
+
min_height: { sm: 28, md: [36, 40], lg: 48 }
|
|
639
|
+
padding_h: "spacing.sm"
|
|
640
|
+
destructive:
|
|
641
|
+
background: "color.semantic.danger"
|
|
642
|
+
text: "color.semantic.danger.on_color"
|
|
643
|
+
min_height: { sm: 32, md: [44, 48], lg: 56 }
|
|
644
|
+
padding_h: "spacing.md"
|
|
645
|
+
radius: "spacing.sm"
|
|
646
|
+
ghost:
|
|
647
|
+
background: transparent
|
|
648
|
+
text: "color.text.secondary"
|
|
649
|
+
min_height: { sm: 28, md: [36, 40], lg: 48 }
|
|
650
|
+
padding_h: "spacing.xs"
|
|
651
|
+
|
|
652
|
+
platform_mapping:
|
|
653
|
+
ios:
|
|
654
|
+
primary: { widget: "Button", style: ".borderedProminent" }
|
|
655
|
+
secondary: { widget: "Button", style: ".bordered" }
|
|
656
|
+
tertiary: { widget: "Button", style: ".borderless" }
|
|
657
|
+
ghost: { widget: "Button", style: ".plain" }
|
|
658
|
+
android:
|
|
659
|
+
primary: { widget: "Button", composable: "Button" }
|
|
660
|
+
secondary: { widget: "OutlinedButton", composable: "OutlinedButton" }
|
|
661
|
+
tertiary: { widget: "TextButton", composable: "TextButton" }
|
|
662
|
+
ghost: { widget: "TextButton", composable: "TextButton" }
|
|
663
|
+
web:
|
|
664
|
+
all: { element: "button", type: "button" }
|
|
665
|
+
|
|
666
|
+
generation:
|
|
667
|
+
must_handle:
|
|
668
|
+
- "All declared states and transitions"
|
|
669
|
+
- "Accessibility labels and role announcement"
|
|
670
|
+
- "Token-accurate colors, spacing, and sizing per variant"
|
|
671
|
+
- "Loading state with progress indicator"
|
|
672
|
+
- "Disabled state removes from tab order"
|
|
673
|
+
should_handle:
|
|
674
|
+
- "Haptic feedback on press (iOS: .impact(style: .light))"
|
|
675
|
+
- "Ripple effect on press (Android)"
|
|
676
|
+
- "Press scale animation per motion.patterns.press_feedback"
|
|
677
|
+
- "Keyboard activation via Enter and Space"
|
|
678
|
+
may_handle:
|
|
679
|
+
- "Long-press secondary action"
|
|
680
|
+
- "Context menu on right-click (web)"
|
|
681
|
+
|
|
682
|
+
test_cases:
|
|
683
|
+
- "Renders with label and primary variant at md size"
|
|
684
|
+
- "Shows loading state with spinner replacing or alongside label"
|
|
685
|
+
- "Disabled state prevents tap/click and is excluded from focus order"
|
|
686
|
+
- "VoiceOver announces: '{label}, button' / TalkBack: '{label}, button'"
|
|
687
|
+
- "Focus ring visible on keyboard navigation"
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
### 4.2 `data_display`
|
|
693
|
+
|
|
694
|
+
Presents read-only information. The most diverse family — covers everything from stat cards to list items.
|
|
695
|
+
|
|
696
|
+
```yaml
|
|
697
|
+
# contracts/data_display.yaml
|
|
698
|
+
data_display:
|
|
699
|
+
semantic: "Presents read-only information in a structured layout"
|
|
700
|
+
|
|
701
|
+
props:
|
|
702
|
+
title: { type: string, required: true }
|
|
703
|
+
subtitle: { type: string, required: false }
|
|
704
|
+
body: { type: string, required: false }
|
|
705
|
+
media: { type: media_ref, required: false, position: [top, leading, background] }
|
|
706
|
+
leading: { type: component_ref, required: false }
|
|
707
|
+
trailing: { type: component_ref, required: false }
|
|
708
|
+
metadata: { type: "map<string, string>", required: false }
|
|
709
|
+
badge: { type: badge_config, required: false }
|
|
710
|
+
variant:
|
|
711
|
+
type: enum
|
|
712
|
+
values: [card, compact, hero, stat, inline]
|
|
713
|
+
default: card
|
|
714
|
+
emphasis:
|
|
715
|
+
type: enum
|
|
716
|
+
values: [default, elevated, muted]
|
|
717
|
+
default: default
|
|
718
|
+
interactive: { type: bool, default: false }
|
|
719
|
+
|
|
720
|
+
states:
|
|
721
|
+
default:
|
|
722
|
+
transitions_to: [pressed, highlighted]
|
|
723
|
+
pressed:
|
|
724
|
+
condition: "props.interactive == true"
|
|
725
|
+
transitions_to: [default]
|
|
726
|
+
duration: "motion.instant"
|
|
727
|
+
highlighted:
|
|
728
|
+
semantic: "Visually emphasized (e.g., unread, new, selected)"
|
|
729
|
+
transitions_to: [default]
|
|
730
|
+
loading:
|
|
731
|
+
behavior: "Shows skeleton placeholder matching layout shape"
|
|
732
|
+
transitions_to: [default]
|
|
733
|
+
empty:
|
|
734
|
+
behavior: "Shows empty state message"
|
|
735
|
+
transitions_to: [default, loading]
|
|
736
|
+
|
|
737
|
+
a11y:
|
|
738
|
+
role:
|
|
739
|
+
interactive: "button"
|
|
740
|
+
static: "group"
|
|
741
|
+
label: "props.title"
|
|
742
|
+
hint: "props.subtitle"
|
|
743
|
+
traits:
|
|
744
|
+
highlighted: { announces: "new" }
|
|
745
|
+
loading: { announces: "loading" }
|
|
746
|
+
|
|
747
|
+
tokens:
|
|
748
|
+
card:
|
|
749
|
+
background: "color.surface.primary"
|
|
750
|
+
border: { width: 0.5, color: "color.border.default" }
|
|
751
|
+
radius: "spacing.md"
|
|
752
|
+
padding: "spacing.md"
|
|
753
|
+
title_style: "typography.heading_sm"
|
|
754
|
+
subtitle_style: "typography.body_sm"
|
|
755
|
+
body_style: "typography.body"
|
|
756
|
+
compact:
|
|
757
|
+
min_height: [44, 48]
|
|
758
|
+
padding_v: "spacing.sm"
|
|
759
|
+
padding_h: "spacing.md"
|
|
760
|
+
title_style: "typography.body"
|
|
761
|
+
subtitle_style: "typography.caption"
|
|
762
|
+
separator: { color: "color.border.default", inset_leading: "spacing.md" }
|
|
763
|
+
hero:
|
|
764
|
+
padding: "spacing.lg"
|
|
765
|
+
title_style: "typography.display"
|
|
766
|
+
subtitle_style: "typography.body"
|
|
767
|
+
media_height: { range: [200, 300] }
|
|
768
|
+
stat:
|
|
769
|
+
padding: "spacing.md"
|
|
770
|
+
background: "color.surface.secondary"
|
|
771
|
+
radius: "spacing.sm"
|
|
772
|
+
label_style: "typography.caption"
|
|
773
|
+
value_style: "typography.heading_lg"
|
|
774
|
+
inline:
|
|
775
|
+
padding: "spacing.xs"
|
|
776
|
+
title_style: "typography.body_sm"
|
|
777
|
+
|
|
778
|
+
platform_mapping:
|
|
779
|
+
ios:
|
|
780
|
+
card: { container: "GroupBox or custom View" }
|
|
781
|
+
compact: { container: "HStack inside List row" }
|
|
782
|
+
hero: { container: "custom header View" }
|
|
783
|
+
stat: { container: "custom View" }
|
|
784
|
+
android:
|
|
785
|
+
card: { composable: "Card or ElevatedCard" }
|
|
786
|
+
compact: { composable: "ListItem" }
|
|
787
|
+
hero: { composable: "custom composable" }
|
|
788
|
+
stat: { composable: "Surface" }
|
|
789
|
+
web:
|
|
790
|
+
card: { element: "article or div", class: "card" }
|
|
791
|
+
compact: { element: "li", class: "list-item" }
|
|
792
|
+
hero: { element: "header or section" }
|
|
793
|
+
stat: { element: "div", class: "stat-card" }
|
|
794
|
+
|
|
795
|
+
generation:
|
|
796
|
+
must_handle:
|
|
797
|
+
- "All variant layouts with correct typography hierarchy"
|
|
798
|
+
- "Loading skeleton matching the variant shape"
|
|
799
|
+
- "Empty state with configurable message"
|
|
800
|
+
- "Interactive mode adds press feedback and button role"
|
|
801
|
+
should_handle:
|
|
802
|
+
- "Image lazy loading for media prop"
|
|
803
|
+
- "Text truncation with configurable line limits"
|
|
804
|
+
- "Badge rendering in top-trailing position"
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
---
|
|
808
|
+
|
|
809
|
+
### 4.3 `input_field`
|
|
810
|
+
|
|
811
|
+
Captures user input. Covers text, selection, toggles, and sliders.
|
|
812
|
+
|
|
813
|
+
```yaml
|
|
814
|
+
# contracts/input_field.yaml
|
|
815
|
+
input_field:
|
|
816
|
+
semantic: "Captures and validates user input"
|
|
817
|
+
|
|
818
|
+
props:
|
|
819
|
+
label: { type: string, required: true }
|
|
820
|
+
placeholder: { type: string, required: false }
|
|
821
|
+
value: { type: any, required: false, binding: true }
|
|
822
|
+
helper_text: { type: string, required: false }
|
|
823
|
+
error_text: { type: string, required: false }
|
|
824
|
+
input_type:
|
|
825
|
+
type: enum
|
|
826
|
+
values: [text, number, email, password, phone, multiline, date, time, select, toggle, slider, checkbox, radio]
|
|
827
|
+
default: text
|
|
828
|
+
required: { type: bool, default: false }
|
|
829
|
+
max_length: { type: int, required: false }
|
|
830
|
+
options: { type: "list<option>", required: false, condition: "input_type in [select, radio]" }
|
|
831
|
+
range: { type: range_config, required: false, condition: "input_type == slider" }
|
|
832
|
+
prefix: { type: string, required: false }
|
|
833
|
+
suffix: { type: string, required: false }
|
|
834
|
+
icon: { type: icon_ref, required: false, position: [leading, trailing] }
|
|
835
|
+
clearable: { type: bool, default: false }
|
|
836
|
+
|
|
837
|
+
states:
|
|
838
|
+
empty:
|
|
839
|
+
transitions_to: [focused]
|
|
840
|
+
visual: "Shows placeholder, label in resting position"
|
|
841
|
+
focused:
|
|
842
|
+
transitions_to: [filled, empty, error]
|
|
843
|
+
visual: "Label animates to active position, border emphasized"
|
|
844
|
+
duration: "motion.quick"
|
|
845
|
+
filled:
|
|
846
|
+
transitions_to: [focused, disabled, error]
|
|
847
|
+
visual: "Shows value, label in active position"
|
|
848
|
+
error:
|
|
849
|
+
transitions_to: [focused]
|
|
850
|
+
visual: "Border and label in danger color, error_text visible"
|
|
851
|
+
disabled:
|
|
852
|
+
transitions_to: [empty, filled]
|
|
853
|
+
behavior: "Non-interactive, visually muted"
|
|
854
|
+
readonly:
|
|
855
|
+
behavior: "Displays value, not editable, selectable for copy"
|
|
856
|
+
|
|
857
|
+
a11y:
|
|
858
|
+
role:
|
|
859
|
+
text: "textfield"
|
|
860
|
+
select: "combobox"
|
|
861
|
+
toggle: "switch"
|
|
862
|
+
slider: "slider"
|
|
863
|
+
checkbox: "checkbox"
|
|
864
|
+
radio: "radio"
|
|
865
|
+
label: "props.label"
|
|
866
|
+
hint: "props.helper_text"
|
|
867
|
+
error: "props.error_text"
|
|
868
|
+
required: "props.required"
|
|
869
|
+
traits:
|
|
870
|
+
error: { announces: "error: {error_text}" }
|
|
871
|
+
disabled: { announces: "dimmed" }
|
|
872
|
+
focus:
|
|
873
|
+
order: "document"
|
|
874
|
+
keyboard:
|
|
875
|
+
text: { submit: "Return", next: "Tab" }
|
|
876
|
+
toggle: { activate: "Space" }
|
|
877
|
+
select: { open: "Space", navigate: "ArrowUp/ArrowDown" }
|
|
878
|
+
|
|
879
|
+
tokens:
|
|
880
|
+
text:
|
|
881
|
+
min_height: [44, 48]
|
|
882
|
+
padding_h: "spacing.md"
|
|
883
|
+
padding_v: "spacing.sm"
|
|
884
|
+
background: "color.surface.primary"
|
|
885
|
+
border: { width: 1, color: "color.border.default" }
|
|
886
|
+
border_focused: { width: 2, color: "color.brand.primary" }
|
|
887
|
+
border_error: { width: 2, color: "color.semantic.danger" }
|
|
888
|
+
radius: "spacing.sm"
|
|
889
|
+
label_style: "typography.caption"
|
|
890
|
+
value_style: "typography.body"
|
|
891
|
+
placeholder_color: "color.text.tertiary"
|
|
892
|
+
error_color: "color.semantic.danger"
|
|
893
|
+
toggle:
|
|
894
|
+
track_width: [51, 52]
|
|
895
|
+
track_height: [31, 32]
|
|
896
|
+
thumb_size: [27, 28]
|
|
897
|
+
track_on: "color.brand.primary"
|
|
898
|
+
track_off: "color.border.emphasis"
|
|
899
|
+
slider:
|
|
900
|
+
track_height: [4, 6]
|
|
901
|
+
thumb_size: [20, 24]
|
|
902
|
+
active_track: "color.brand.primary"
|
|
903
|
+
inactive_track: "color.border.default"
|
|
904
|
+
|
|
905
|
+
platform_mapping:
|
|
906
|
+
ios:
|
|
907
|
+
text: { widget: "TextField" }
|
|
908
|
+
multiline: { widget: "TextEditor" }
|
|
909
|
+
select: { widget: "Picker" }
|
|
910
|
+
toggle: { widget: "Toggle" }
|
|
911
|
+
slider: { widget: "Slider" }
|
|
912
|
+
date: { widget: "DatePicker" }
|
|
913
|
+
android:
|
|
914
|
+
text: { composable: "OutlinedTextField" }
|
|
915
|
+
multiline: { composable: "OutlinedTextField(singleLine=false)" }
|
|
916
|
+
select: { composable: "ExposedDropdownMenuBox" }
|
|
917
|
+
toggle: { composable: "Switch" }
|
|
918
|
+
slider: { composable: "Slider" }
|
|
919
|
+
date: { composable: "DatePicker" }
|
|
920
|
+
web:
|
|
921
|
+
text: { element: "input", type: "text" }
|
|
922
|
+
multiline: { element: "textarea" }
|
|
923
|
+
select: { element: "select" }
|
|
924
|
+
toggle: { element: "input", type: "checkbox", role: "switch" }
|
|
925
|
+
slider: { element: "input", type: "range" }
|
|
926
|
+
date: { element: "input", type: "date" }
|
|
927
|
+
|
|
928
|
+
generation:
|
|
929
|
+
must_handle:
|
|
930
|
+
- "All states including error with visible error_text"
|
|
931
|
+
- "Label animation between resting and active positions"
|
|
932
|
+
- "Accessibility labels, hints, and error announcements"
|
|
933
|
+
- "Keyboard type adaptation per input_type"
|
|
934
|
+
- "Required field validation"
|
|
935
|
+
should_handle:
|
|
936
|
+
- "Character count display when max_length is set"
|
|
937
|
+
- "Clear button when clearable is true and field is filled"
|
|
938
|
+
- "Prefix/suffix rendering without affecting input value"
|
|
939
|
+
- "Secure text entry toggle for password type"
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
---
|
|
943
|
+
|
|
944
|
+
### 4.4 `nav_container`
|
|
945
|
+
|
|
946
|
+
Provides persistent navigation structure. Defines *how* a user moves between sections — not *where* they go (that's in flows).
|
|
947
|
+
|
|
948
|
+
```yaml
|
|
949
|
+
# contracts/nav_container.yaml
|
|
950
|
+
nav_container:
|
|
951
|
+
semantic: "Provides persistent navigation between top-level sections"
|
|
952
|
+
|
|
953
|
+
props:
|
|
954
|
+
items:
|
|
955
|
+
type: "list<nav_item>"
|
|
956
|
+
required: true
|
|
957
|
+
item_shape:
|
|
958
|
+
id: { type: string, required: true }
|
|
959
|
+
label: { type: string, required: true }
|
|
960
|
+
icon: { type: icon_ref, required: true }
|
|
961
|
+
icon_active: { type: icon_ref, required: false }
|
|
962
|
+
badge: { type: badge_config, required: false }
|
|
963
|
+
destination: { type: screen_ref, required: true }
|
|
964
|
+
variant:
|
|
965
|
+
type: enum
|
|
966
|
+
values: [tab_bar, sidebar, drawer, bottom_nav, rail]
|
|
967
|
+
default: tab_bar
|
|
968
|
+
selected: { type: string, binding: true }
|
|
969
|
+
collapsed: { type: bool, default: false, condition: "variant in [sidebar, rail]" }
|
|
970
|
+
|
|
971
|
+
states:
|
|
972
|
+
default:
|
|
973
|
+
transitions_to: [item_pressed]
|
|
974
|
+
item_pressed:
|
|
975
|
+
transitions_to: [default]
|
|
976
|
+
duration: "motion.quick"
|
|
977
|
+
behavior: "Highlights pressed item, navigates to destination"
|
|
978
|
+
collapsed:
|
|
979
|
+
condition: "variant in [sidebar, rail]"
|
|
980
|
+
transitions_to: [default]
|
|
981
|
+
visual: "Compact mode showing only icons"
|
|
982
|
+
expanded:
|
|
983
|
+
condition: "variant in [sidebar, drawer]"
|
|
984
|
+
transitions_to: [collapsed, default]
|
|
985
|
+
visual: "Full mode showing icons and labels"
|
|
986
|
+
|
|
987
|
+
a11y:
|
|
988
|
+
role: "navigation"
|
|
989
|
+
label: "Main navigation"
|
|
990
|
+
item_role: "tab"
|
|
991
|
+
traits:
|
|
992
|
+
selected: { announces: "selected" }
|
|
993
|
+
badge: { announces: "{badge.count} notifications" }
|
|
994
|
+
focus:
|
|
995
|
+
order: "before-content"
|
|
996
|
+
keyboard:
|
|
997
|
+
navigate: "ArrowLeft/ArrowRight (tab_bar) | ArrowUp/ArrowDown (sidebar)"
|
|
998
|
+
activate: "Enter"
|
|
999
|
+
|
|
1000
|
+
tokens:
|
|
1001
|
+
tab_bar:
|
|
1002
|
+
height: [49, 56] # iOS 49pt, Android 56dp
|
|
1003
|
+
background: "color.surface.primary"
|
|
1004
|
+
border_top: { width: 0.5, color: "color.border.default" }
|
|
1005
|
+
icon_size: 24
|
|
1006
|
+
label_style: "typography.caption"
|
|
1007
|
+
active_color: "color.brand.primary"
|
|
1008
|
+
inactive_color: "color.text.tertiary"
|
|
1009
|
+
sidebar:
|
|
1010
|
+
width: { collapsed: [64, 72], expanded: [220, 280] }
|
|
1011
|
+
background: "color.surface.secondary"
|
|
1012
|
+
item_height: [44, 48]
|
|
1013
|
+
item_radius: "spacing.sm"
|
|
1014
|
+
item_padding_h: "spacing.md"
|
|
1015
|
+
active_background: "color.brand.primary"
|
|
1016
|
+
active_text: "color.brand.primary.on_color"
|
|
1017
|
+
icon_size: 20
|
|
1018
|
+
label_style: "typography.body_sm"
|
|
1019
|
+
drawer:
|
|
1020
|
+
width: { range: [280, 320] }
|
|
1021
|
+
overlay_opacity: 0.5
|
|
1022
|
+
background: "color.surface.primary"
|
|
1023
|
+
rail:
|
|
1024
|
+
width: [72, 80]
|
|
1025
|
+
icon_size: 24
|
|
1026
|
+
label_style: "typography.caption"
|
|
1027
|
+
|
|
1028
|
+
platform_mapping:
|
|
1029
|
+
ios:
|
|
1030
|
+
tab_bar: { widget: "TabView", style: ".tabViewStyle(.page) or default" }
|
|
1031
|
+
sidebar: { widget: "NavigationSplitView sidebar" }
|
|
1032
|
+
drawer: { widget: "custom sheet or NavigationSplitView" }
|
|
1033
|
+
android:
|
|
1034
|
+
tab_bar: { composable: "NavigationBar" }
|
|
1035
|
+
sidebar: { composable: "NavigationDrawer(permanent)" }
|
|
1036
|
+
drawer: { composable: "ModalNavigationDrawer" }
|
|
1037
|
+
rail: { composable: "NavigationRail" }
|
|
1038
|
+
web:
|
|
1039
|
+
tab_bar: { element: "nav", pattern: "bottom fixed bar" }
|
|
1040
|
+
sidebar: { element: "aside", pattern: "collapsible sidebar" }
|
|
1041
|
+
drawer: { element: "aside + overlay", pattern: "off-canvas" }
|
|
1042
|
+
|
|
1043
|
+
generation:
|
|
1044
|
+
must_handle:
|
|
1045
|
+
- "Active/inactive state per item with visual differentiation"
|
|
1046
|
+
- "Badge rendering with count or dot indicator"
|
|
1047
|
+
- "Navigation role and tab semantics for screen readers"
|
|
1048
|
+
- "Keyboard arrow navigation between items"
|
|
1049
|
+
should_handle:
|
|
1050
|
+
- "Collapse/expand animation for sidebar variant"
|
|
1051
|
+
- "Swipe gesture to open/close drawer (mobile)"
|
|
1052
|
+
- "Responsive variant switching (sidebar on tablet, tab_bar on phone)"
|
|
1053
|
+
- "Safe area insets (iOS bottom, Android gesture nav)"
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
---
|
|
1057
|
+
|
|
1058
|
+
### 4.5 `feedback`
|
|
1059
|
+
|
|
1060
|
+
Communicates system status, confirmations, warnings, or errors to the user.
|
|
1061
|
+
|
|
1062
|
+
```yaml
|
|
1063
|
+
# contracts/feedback.yaml
|
|
1064
|
+
feedback:
|
|
1065
|
+
semantic: "Communicates system status, outcomes, or required decisions to the user"
|
|
1066
|
+
|
|
1067
|
+
props:
|
|
1068
|
+
message: { type: string, required: true }
|
|
1069
|
+
title: { type: string, required: false }
|
|
1070
|
+
severity:
|
|
1071
|
+
type: enum
|
|
1072
|
+
values: [info, success, warning, error, neutral]
|
|
1073
|
+
default: neutral
|
|
1074
|
+
variant:
|
|
1075
|
+
type: enum
|
|
1076
|
+
values: [toast, banner, dialog, snackbar, inline]
|
|
1077
|
+
default: toast
|
|
1078
|
+
duration:
|
|
1079
|
+
type: int
|
|
1080
|
+
required: false
|
|
1081
|
+
default: 4000
|
|
1082
|
+
condition: "variant in [toast, snackbar]"
|
|
1083
|
+
actions:
|
|
1084
|
+
type: "list<action>"
|
|
1085
|
+
required: false
|
|
1086
|
+
max: 2
|
|
1087
|
+
item_shape:
|
|
1088
|
+
label: { type: string, required: true }
|
|
1089
|
+
action: { type: action_ref, required: true }
|
|
1090
|
+
variant: { type: enum, values: [primary, secondary, destructive], default: primary }
|
|
1091
|
+
dismissible: { type: bool, default: true }
|
|
1092
|
+
icon: { type: icon_ref, required: false, default_from: "severity" }
|
|
1093
|
+
|
|
1094
|
+
states:
|
|
1095
|
+
hidden:
|
|
1096
|
+
transitions_to: [entering]
|
|
1097
|
+
entering:
|
|
1098
|
+
transitions_to: [visible]
|
|
1099
|
+
duration: "motion.quick"
|
|
1100
|
+
animation: "variant-specific enter animation"
|
|
1101
|
+
visible:
|
|
1102
|
+
transitions_to: [exiting]
|
|
1103
|
+
duration: "props.duration (auto) or indefinite (manual dismiss)"
|
|
1104
|
+
exiting:
|
|
1105
|
+
transitions_to: [hidden]
|
|
1106
|
+
duration: "motion.quick"
|
|
1107
|
+
animation: "variant-specific exit animation"
|
|
1108
|
+
|
|
1109
|
+
a11y:
|
|
1110
|
+
role:
|
|
1111
|
+
toast: "status"
|
|
1112
|
+
banner: "alert"
|
|
1113
|
+
dialog: "alertdialog"
|
|
1114
|
+
snackbar: "status"
|
|
1115
|
+
inline: "status"
|
|
1116
|
+
label: "props.title or props.message"
|
|
1117
|
+
live:
|
|
1118
|
+
error: "assertive"
|
|
1119
|
+
warning: "assertive"
|
|
1120
|
+
default: "polite"
|
|
1121
|
+
focus:
|
|
1122
|
+
dialog: "traps focus, returns on dismiss"
|
|
1123
|
+
toast: "does not steal focus"
|
|
1124
|
+
|
|
1125
|
+
tokens:
|
|
1126
|
+
toast:
|
|
1127
|
+
background: "color.surface.primary"
|
|
1128
|
+
border: { width: 0.5, color: "color.border.default" }
|
|
1129
|
+
radius: "spacing.sm"
|
|
1130
|
+
padding: "spacing.md"
|
|
1131
|
+
shadow: "elevation.md"
|
|
1132
|
+
max_width: 400
|
|
1133
|
+
position: "bottom-center | top-center"
|
|
1134
|
+
message_style: "typography.body_sm"
|
|
1135
|
+
banner:
|
|
1136
|
+
padding_v: "spacing.sm"
|
|
1137
|
+
padding_h: "spacing.md"
|
|
1138
|
+
background_by_severity:
|
|
1139
|
+
info: "color.semantic.info (10% opacity)"
|
|
1140
|
+
success: "color.semantic.success (10% opacity)"
|
|
1141
|
+
warning: "color.semantic.warning (10% opacity)"
|
|
1142
|
+
error: "color.semantic.danger (10% opacity)"
|
|
1143
|
+
border_leading: { width: 3, color_from: "severity" }
|
|
1144
|
+
icon_size: 20
|
|
1145
|
+
message_style: "typography.body_sm"
|
|
1146
|
+
dialog:
|
|
1147
|
+
background: "color.surface.primary"
|
|
1148
|
+
radius: "spacing.md"
|
|
1149
|
+
padding: "spacing.lg"
|
|
1150
|
+
max_width: { range: [280, 400] }
|
|
1151
|
+
overlay_opacity: 0.5
|
|
1152
|
+
title_style: "typography.heading"
|
|
1153
|
+
message_style: "typography.body"
|
|
1154
|
+
snackbar:
|
|
1155
|
+
background: "color.text.primary"
|
|
1156
|
+
text: "color.surface.primary"
|
|
1157
|
+
radius: "spacing.sm"
|
|
1158
|
+
padding_v: "spacing.sm"
|
|
1159
|
+
padding_h: "spacing.md"
|
|
1160
|
+
position: "bottom"
|
|
1161
|
+
margin_bottom: "spacing.lg"
|
|
1162
|
+
|
|
1163
|
+
platform_mapping:
|
|
1164
|
+
ios:
|
|
1165
|
+
toast: { pattern: "custom overlay" }
|
|
1166
|
+
banner: { pattern: "custom top banner" }
|
|
1167
|
+
dialog: { widget: "Alert or ConfirmationDialog" }
|
|
1168
|
+
snackbar: { pattern: "custom bottom overlay" }
|
|
1169
|
+
android:
|
|
1170
|
+
toast: { pattern: "custom composable" }
|
|
1171
|
+
banner: { pattern: "custom composable" }
|
|
1172
|
+
dialog: { composable: "AlertDialog" }
|
|
1173
|
+
snackbar: { composable: "Snackbar" }
|
|
1174
|
+
web:
|
|
1175
|
+
toast: { pattern: "toast container, aria-live region" }
|
|
1176
|
+
banner: { element: "div", role: "alert" }
|
|
1177
|
+
dialog: { element: "dialog" }
|
|
1178
|
+
snackbar: { pattern: "bottom fixed, aria-live region" }
|
|
1179
|
+
|
|
1180
|
+
generation:
|
|
1181
|
+
must_handle:
|
|
1182
|
+
- "Severity-based icon and color mapping"
|
|
1183
|
+
- "Auto-dismiss timer for toast/snackbar variants"
|
|
1184
|
+
- "Accessible live region announcements"
|
|
1185
|
+
- "Dialog focus trap and return on dismiss"
|
|
1186
|
+
- "Enter/exit animations per variant"
|
|
1187
|
+
should_handle:
|
|
1188
|
+
- "Swipe-to-dismiss on mobile (toast/snackbar)"
|
|
1189
|
+
- "Stacking multiple toasts without overlap"
|
|
1190
|
+
- "Reduced motion: instant show/hide"
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
---
|
|
1194
|
+
|
|
1195
|
+
### 4.6 `surface`
|
|
1196
|
+
|
|
1197
|
+
Provides a contained UI layer — modals, sheets, panels, overlays. Unlike `data_display` (which shows content), `surface` *contains* other contracts.
|
|
1198
|
+
|
|
1199
|
+
```yaml
|
|
1200
|
+
# contracts/surface.yaml
|
|
1201
|
+
surface:
|
|
1202
|
+
semantic: "Provides a contained layer that hosts other components"
|
|
1203
|
+
|
|
1204
|
+
props:
|
|
1205
|
+
variant:
|
|
1206
|
+
type: enum
|
|
1207
|
+
values: [modal, sheet, panel, popover, fullscreen]
|
|
1208
|
+
default: sheet
|
|
1209
|
+
title: { type: string, required: false }
|
|
1210
|
+
content: { type: "list<component_ref>", required: true }
|
|
1211
|
+
size:
|
|
1212
|
+
type: enum
|
|
1213
|
+
values: [sm, md, lg, full]
|
|
1214
|
+
default: md
|
|
1215
|
+
condition: "variant in [modal, sheet, panel]"
|
|
1216
|
+
dismissible: { type: bool, default: true }
|
|
1217
|
+
close_action: { type: action_ref, required: false }
|
|
1218
|
+
detents:
|
|
1219
|
+
type: "list<enum>"
|
|
1220
|
+
values: [small, medium, large, full]
|
|
1221
|
+
default: [medium, large]
|
|
1222
|
+
condition: "variant == sheet"
|
|
1223
|
+
|
|
1224
|
+
states:
|
|
1225
|
+
hidden:
|
|
1226
|
+
transitions_to: [presenting]
|
|
1227
|
+
presenting:
|
|
1228
|
+
transitions_to: [visible]
|
|
1229
|
+
duration: "motion.normal"
|
|
1230
|
+
animation: "variant-specific"
|
|
1231
|
+
visible:
|
|
1232
|
+
transitions_to: [dismissing]
|
|
1233
|
+
dismissing:
|
|
1234
|
+
transitions_to: [hidden]
|
|
1235
|
+
duration: "motion.quick"
|
|
1236
|
+
resizing:
|
|
1237
|
+
condition: "variant == sheet"
|
|
1238
|
+
transitions_to: [visible]
|
|
1239
|
+
behavior: "Sheet transitions between detent sizes"
|
|
1240
|
+
|
|
1241
|
+
a11y:
|
|
1242
|
+
role:
|
|
1243
|
+
modal: "dialog"
|
|
1244
|
+
sheet: "dialog"
|
|
1245
|
+
panel: "complementary"
|
|
1246
|
+
popover: "dialog"
|
|
1247
|
+
fullscreen: "dialog"
|
|
1248
|
+
label: "props.title"
|
|
1249
|
+
focus:
|
|
1250
|
+
modal: { trap: true, return_on_dismiss: true }
|
|
1251
|
+
sheet: { trap: true, return_on_dismiss: true }
|
|
1252
|
+
panel: { trap: false }
|
|
1253
|
+
popover: { trap: true, return_on_dismiss: true }
|
|
1254
|
+
dismiss:
|
|
1255
|
+
gesture: "swipe-down (sheet), tap-outside (modal, popover)"
|
|
1256
|
+
keyboard: "Escape"
|
|
1257
|
+
|
|
1258
|
+
tokens:
|
|
1259
|
+
modal:
|
|
1260
|
+
background: "color.surface.primary"
|
|
1261
|
+
radius: "spacing.md"
|
|
1262
|
+
padding: "spacing.lg"
|
|
1263
|
+
overlay_opacity: 0.5
|
|
1264
|
+
max_width: { sm: 320, md: 480, lg: 640 }
|
|
1265
|
+
shadow: "elevation.lg"
|
|
1266
|
+
sheet:
|
|
1267
|
+
background: "color.surface.primary"
|
|
1268
|
+
radius_top: "spacing.md"
|
|
1269
|
+
padding: "spacing.md"
|
|
1270
|
+
drag_indicator:
|
|
1271
|
+
width: 36
|
|
1272
|
+
height: 4
|
|
1273
|
+
radius: 2
|
|
1274
|
+
color: "color.border.emphasis"
|
|
1275
|
+
margin_top: "spacing.sm"
|
|
1276
|
+
panel:
|
|
1277
|
+
background: "color.surface.secondary"
|
|
1278
|
+
border_leading: { width: 0.5, color: "color.border.default" }
|
|
1279
|
+
width: { sm: 280, md: 360, lg: 480 }
|
|
1280
|
+
popover:
|
|
1281
|
+
background: "color.surface.primary"
|
|
1282
|
+
radius: "spacing.sm"
|
|
1283
|
+
padding: "spacing.md"
|
|
1284
|
+
shadow: "elevation.md"
|
|
1285
|
+
arrow_size: 8
|
|
1286
|
+
fullscreen:
|
|
1287
|
+
background: "color.surface.primary"
|
|
1288
|
+
safe_area: true
|
|
1289
|
+
|
|
1290
|
+
platform_mapping:
|
|
1291
|
+
ios:
|
|
1292
|
+
modal: { widget: "sheet(detents: [.medium])" }
|
|
1293
|
+
sheet: { widget: ".sheet with detents" }
|
|
1294
|
+
panel: { widget: "NavigationSplitView detail" }
|
|
1295
|
+
popover: { widget: ".popover" }
|
|
1296
|
+
fullscreen: { widget: ".fullScreenCover" }
|
|
1297
|
+
android:
|
|
1298
|
+
modal: { composable: "AlertDialog or Dialog" }
|
|
1299
|
+
sheet: { composable: "ModalBottomSheet" }
|
|
1300
|
+
panel: { composable: "custom side panel" }
|
|
1301
|
+
popover: { composable: "DropdownMenu or Popup" }
|
|
1302
|
+
fullscreen: { composable: "Dialog(fullscreen)" }
|
|
1303
|
+
web:
|
|
1304
|
+
modal: { element: "dialog" }
|
|
1305
|
+
sheet: { pattern: "bottom drawer with drag" }
|
|
1306
|
+
panel: { element: "aside" }
|
|
1307
|
+
popover: { element: "div", attribute: "popover" }
|
|
1308
|
+
fullscreen: { pattern: "full-viewport overlay" }
|
|
1309
|
+
|
|
1310
|
+
generation:
|
|
1311
|
+
must_handle:
|
|
1312
|
+
- "Focus trap for modal/sheet/popover variants"
|
|
1313
|
+
- "Overlay/scrim with correct opacity"
|
|
1314
|
+
- "Dismiss via gesture, tap-outside, and Escape key"
|
|
1315
|
+
- "Return focus to trigger element on dismiss"
|
|
1316
|
+
- "Present/dismiss animations"
|
|
1317
|
+
should_handle:
|
|
1318
|
+
- "Sheet detent snapping with drag gesture"
|
|
1319
|
+
- "Keyboard avoidance when content includes input_field"
|
|
1320
|
+
- "Safe area insets (iOS notch, Android gesture bar)"
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
---
|
|
1324
|
+
|
|
1325
|
+
### 4.7 `collection`
|
|
1326
|
+
|
|
1327
|
+
Renders a repeating set of items. Handles lists, grids, tables, and carousels.
|
|
1328
|
+
|
|
1329
|
+
```yaml
|
|
1330
|
+
# contracts/collection.yaml
|
|
1331
|
+
collection:
|
|
1332
|
+
semantic: "Renders a repeating set of data items in a structured layout"
|
|
1333
|
+
|
|
1334
|
+
props:
|
|
1335
|
+
data: { type: "list<any>", required: true, binding: true }
|
|
1336
|
+
item_contract: { type: contract_ref, required: true }
|
|
1337
|
+
item_props_map:
|
|
1338
|
+
type: "map<string, data_path>"
|
|
1339
|
+
required: true
|
|
1340
|
+
semantic: "Maps item_contract props to data fields"
|
|
1341
|
+
variant:
|
|
1342
|
+
type: enum
|
|
1343
|
+
values: [list, grid, table, carousel, chips]
|
|
1344
|
+
default: list
|
|
1345
|
+
header: { type: component_ref, required: false }
|
|
1346
|
+
footer: { type: component_ref, required: false }
|
|
1347
|
+
empty_state:
|
|
1348
|
+
type: component_ref
|
|
1349
|
+
required: false
|
|
1350
|
+
default_semantic: "Shown when data is empty"
|
|
1351
|
+
separator:
|
|
1352
|
+
type: enum
|
|
1353
|
+
values: [line, space, none]
|
|
1354
|
+
default: line
|
|
1355
|
+
condition: "variant == list"
|
|
1356
|
+
columns:
|
|
1357
|
+
type: int
|
|
1358
|
+
range: [2, 6]
|
|
1359
|
+
default: 2
|
|
1360
|
+
condition: "variant == grid"
|
|
1361
|
+
selectable: { type: bool, default: false }
|
|
1362
|
+
selection_mode: { type: enum, values: [single, multiple], default: single }
|
|
1363
|
+
searchable: { type: bool, default: false }
|
|
1364
|
+
sortable: { type: bool, default: false }
|
|
1365
|
+
paginated: { type: bool, default: false }
|
|
1366
|
+
page_size: { type: int, default: 20 }
|
|
1367
|
+
pull_to_refresh: { type: bool, default: false }
|
|
1368
|
+
|
|
1369
|
+
states:
|
|
1370
|
+
idle:
|
|
1371
|
+
transitions_to: [loading, refreshing]
|
|
1372
|
+
loading:
|
|
1373
|
+
transitions_to: [idle, error]
|
|
1374
|
+
behavior: "Shows skeleton items matching item_contract shape"
|
|
1375
|
+
skeleton_count: 5
|
|
1376
|
+
refreshing:
|
|
1377
|
+
transitions_to: [idle, error]
|
|
1378
|
+
behavior: "Pull-to-refresh indicator, content stays visible"
|
|
1379
|
+
error:
|
|
1380
|
+
transitions_to: [idle, loading]
|
|
1381
|
+
behavior: "Shows error state with retry action"
|
|
1382
|
+
empty:
|
|
1383
|
+
transitions_to: [loading]
|
|
1384
|
+
behavior: "Shows empty_state component"
|
|
1385
|
+
paginating:
|
|
1386
|
+
transitions_to: [idle, error]
|
|
1387
|
+
behavior: "Loading indicator at bottom, existing items visible"
|
|
1388
|
+
|
|
1389
|
+
a11y:
|
|
1390
|
+
role:
|
|
1391
|
+
list: "list"
|
|
1392
|
+
grid: "grid"
|
|
1393
|
+
table: "table"
|
|
1394
|
+
carousel: "region"
|
|
1395
|
+
item_role:
|
|
1396
|
+
list: "listitem"
|
|
1397
|
+
grid: "gridcell"
|
|
1398
|
+
table: "row"
|
|
1399
|
+
traits:
|
|
1400
|
+
selectable: { item_announces: "double-tap to select" }
|
|
1401
|
+
loading: { announces: "loading content" }
|
|
1402
|
+
empty: { announces: "no items" }
|
|
1403
|
+
focus:
|
|
1404
|
+
keyboard:
|
|
1405
|
+
list: "ArrowUp/ArrowDown to navigate items"
|
|
1406
|
+
grid: "Arrow keys for 2D navigation"
|
|
1407
|
+
carousel: "ArrowLeft/ArrowRight"
|
|
1408
|
+
|
|
1409
|
+
tokens:
|
|
1410
|
+
list:
|
|
1411
|
+
item_min_height: [44, 48]
|
|
1412
|
+
separator_color: "color.border.default"
|
|
1413
|
+
separator_inset: "spacing.md"
|
|
1414
|
+
content_padding: "spacing.none"
|
|
1415
|
+
grid:
|
|
1416
|
+
gap: "spacing.md"
|
|
1417
|
+
item_radius: "spacing.sm"
|
|
1418
|
+
content_padding: "spacing.md"
|
|
1419
|
+
table:
|
|
1420
|
+
header_background: "color.surface.secondary"
|
|
1421
|
+
header_style: "typography.caption"
|
|
1422
|
+
header_weight: 600
|
|
1423
|
+
row_min_height: [44, 48]
|
|
1424
|
+
row_separator: { color: "color.border.default" }
|
|
1425
|
+
cell_padding_h: "spacing.md"
|
|
1426
|
+
cell_padding_v: "spacing.sm"
|
|
1427
|
+
carousel:
|
|
1428
|
+
item_gap: "spacing.md"
|
|
1429
|
+
peek_amount: 20
|
|
1430
|
+
snap: true
|
|
1431
|
+
content_padding_h: "spacing.md"
|
|
1432
|
+
chips:
|
|
1433
|
+
gap: "spacing.sm"
|
|
1434
|
+
item_height: 32
|
|
1435
|
+
item_padding_h: "spacing.md"
|
|
1436
|
+
item_radius: 16
|
|
1437
|
+
item_background: "color.surface.secondary"
|
|
1438
|
+
item_background_selected: "color.brand.primary"
|
|
1439
|
+
|
|
1440
|
+
platform_mapping:
|
|
1441
|
+
ios:
|
|
1442
|
+
list: { widget: "List or LazyVStack" }
|
|
1443
|
+
grid: { widget: "LazyVGrid" }
|
|
1444
|
+
table: { widget: "Table (macOS) or custom (iOS)" }
|
|
1445
|
+
carousel: { widget: "ScrollView(.horizontal) + LazyHStack" }
|
|
1446
|
+
android:
|
|
1447
|
+
list: { composable: "LazyColumn" }
|
|
1448
|
+
grid: { composable: "LazyVerticalGrid" }
|
|
1449
|
+
table: { composable: "custom DataTable composable" }
|
|
1450
|
+
carousel: { composable: "LazyRow" }
|
|
1451
|
+
web:
|
|
1452
|
+
list: { element: "ul or ol" }
|
|
1453
|
+
grid: { element: "div", style: "CSS Grid" }
|
|
1454
|
+
table: { element: "table" }
|
|
1455
|
+
carousel: { element: "div", style: "scroll-snap" }
|
|
1456
|
+
|
|
1457
|
+
generation:
|
|
1458
|
+
must_handle:
|
|
1459
|
+
- "Loading skeleton state with correct item count"
|
|
1460
|
+
- "Empty state rendering"
|
|
1461
|
+
- "Pull-to-refresh when enabled"
|
|
1462
|
+
- "Pagination / infinite scroll when enabled"
|
|
1463
|
+
- "Accessible list/grid roles and keyboard navigation"
|
|
1464
|
+
should_handle:
|
|
1465
|
+
- "Swipe-to-delete on list items (iOS)"
|
|
1466
|
+
- "Drag-to-reorder when sortable"
|
|
1467
|
+
- "Search/filter header integration"
|
|
1468
|
+
- "Section headers with sticky behavior"
|
|
1469
|
+
- "Item recycling / virtualization for large datasets"
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
---
|
|
1473
|
+
|
|
1474
|
+
## 5. Screen composition
|
|
1475
|
+
|
|
1476
|
+
Screens compose contracts into layouts. A screen never references platform widgets — only contracts with props.
|
|
1477
|
+
|
|
1478
|
+
```yaml
|
|
1479
|
+
# Example: screens/order_detail.yaml
|
|
1480
|
+
order_detail:
|
|
1481
|
+
semantic: "Displays detailed information about a single order"
|
|
1482
|
+
|
|
1483
|
+
params:
|
|
1484
|
+
order_id: { type: string, required: true }
|
|
1485
|
+
|
|
1486
|
+
data:
|
|
1487
|
+
order: { source: "api.orders.getById", params: { id: "params.order_id" } }
|
|
1488
|
+
|
|
1489
|
+
layout:
|
|
1490
|
+
type: scroll_vertical
|
|
1491
|
+
safe_area: true
|
|
1492
|
+
padding: "spacing.page_margin"
|
|
1493
|
+
|
|
1494
|
+
sections:
|
|
1495
|
+
- id: status_header
|
|
1496
|
+
contract: data_display
|
|
1497
|
+
variant: hero
|
|
1498
|
+
props:
|
|
1499
|
+
title: "Order #{order.id}"
|
|
1500
|
+
subtitle: "{order.status | format:status_label}"
|
|
1501
|
+
badge:
|
|
1502
|
+
text: "{order.status}"
|
|
1503
|
+
severity: "{order.status | map:status_severity}"
|
|
1504
|
+
|
|
1505
|
+
- id: items_section
|
|
1506
|
+
margin_top: "spacing.section_gap"
|
|
1507
|
+
contract: collection
|
|
1508
|
+
variant: list
|
|
1509
|
+
props:
|
|
1510
|
+
data: "order.items"
|
|
1511
|
+
item_contract: data_display
|
|
1512
|
+
item_variant: compact
|
|
1513
|
+
item_props_map:
|
|
1514
|
+
title: "item.name"
|
|
1515
|
+
subtitle: "item.quantity × {item.unit_price | format:currency}"
|
|
1516
|
+
trailing: "{item.total | format:currency}"
|
|
1517
|
+
empty_state:
|
|
1518
|
+
message: "No items in this order"
|
|
1519
|
+
|
|
1520
|
+
- id: total_row
|
|
1521
|
+
contract: data_display
|
|
1522
|
+
variant: inline
|
|
1523
|
+
emphasis: elevated
|
|
1524
|
+
props:
|
|
1525
|
+
title: "Total"
|
|
1526
|
+
trailing: "{order.total | format:currency}"
|
|
1527
|
+
|
|
1528
|
+
- id: actions
|
|
1529
|
+
margin_top: "spacing.lg"
|
|
1530
|
+
layout:
|
|
1531
|
+
type: row
|
|
1532
|
+
spacing: "spacing.sm"
|
|
1533
|
+
children:
|
|
1534
|
+
- contract: action_trigger
|
|
1535
|
+
variant: secondary
|
|
1536
|
+
size: md
|
|
1537
|
+
props:
|
|
1538
|
+
label: "Contact support"
|
|
1539
|
+
icon: "chat_bubble"
|
|
1540
|
+
action:
|
|
1541
|
+
type: navigate
|
|
1542
|
+
destination: "support_chat"
|
|
1543
|
+
params: { order_id: "order.id" }
|
|
1544
|
+
|
|
1545
|
+
- contract: action_trigger
|
|
1546
|
+
variant: primary
|
|
1547
|
+
size: md
|
|
1548
|
+
props:
|
|
1549
|
+
label: "Track delivery"
|
|
1550
|
+
icon: "location_pin"
|
|
1551
|
+
action:
|
|
1552
|
+
type: navigate
|
|
1553
|
+
destination: "tracking_map"
|
|
1554
|
+
params: { order_id: "order.id" }
|
|
1555
|
+
```
|
|
1556
|
+
|
|
1557
|
+
### 5.1 Screen-level keys
|
|
1558
|
+
|
|
1559
|
+
Beyond contract props and layout primitives, screen files use several keys that modify how sections and contract instances behave. These keys are available on any section or contract instance within a screen's `sections:` array.
|
|
1560
|
+
|
|
1561
|
+
#### `tokens_override`
|
|
1562
|
+
|
|
1563
|
+
Locally overrides token values for a specific contract instance. The keys inside `tokens_override` correspond to the token names defined in the contract's `tokens:` block (Section 4).
|
|
1564
|
+
|
|
1565
|
+
```yaml
|
|
1566
|
+
- contract: data_display
|
|
1567
|
+
variant: inline
|
|
1568
|
+
props:
|
|
1569
|
+
title: "Projects"
|
|
1570
|
+
tokens_override:
|
|
1571
|
+
title_style: "typography.heading_lg" # override the default inline title style
|
|
1572
|
+
background: "color.surface.secondary" # override background token
|
|
1573
|
+
radius: "spacing.sm" # override border radius
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
**Rules:**
|
|
1577
|
+
- Keys must match token names defined in the contract's `tokens:` section for the active variant
|
|
1578
|
+
- Values must be valid token references or literal values within the token's constraint range
|
|
1579
|
+
- `tokens_override` on an `adaptive` block applies only to that size class
|
|
1580
|
+
- Overrides are local — they do not propagate to child contracts
|
|
1581
|
+
|
|
1582
|
+
```yaml
|
|
1583
|
+
# Adaptive tokens_override — larger title on expanded screens
|
|
1584
|
+
adaptive:
|
|
1585
|
+
expanded:
|
|
1586
|
+
tokens_override:
|
|
1587
|
+
title_style: "typography.display"
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
#### `condition`
|
|
1591
|
+
|
|
1592
|
+
Controls whether a section or contract instance is rendered. When the expression evaluates to `false`, the element is excluded from layout (not hidden — fully removed from the render tree).
|
|
1593
|
+
|
|
1594
|
+
```yaml
|
|
1595
|
+
- id: description
|
|
1596
|
+
condition: "task.description != null"
|
|
1597
|
+
contract: data_display
|
|
1598
|
+
variant: inline
|
|
1599
|
+
props:
|
|
1600
|
+
title: "{task.description}"
|
|
1601
|
+
```
|
|
1602
|
+
|
|
1603
|
+
**Expression syntax:** Conditions use the same expression grammar as computed expressions (Section 10.5): `data_path comparator value`. Supported comparators: `==`, `!=`, `>`, `<`, `>=`, `<=`. Boolean data paths can be used directly: `condition: "preferences.notifications_enabled"`.
|
|
1604
|
+
|
|
1605
|
+
#### `position`
|
|
1606
|
+
|
|
1607
|
+
Controls the positioning mode of a section. By default, sections flow in document order within their parent layout. The `position` key overrides this.
|
|
1608
|
+
|
|
1609
|
+
| Value | Behavior | Platform mapping |
|
|
1610
|
+
|-------|----------|-----------------|
|
|
1611
|
+
| (default) | Normal document flow | — |
|
|
1612
|
+
| `"floating-bottom-trailing"` | Floats above content, anchored to bottom-trailing corner | iOS: overlay + alignment, Android: Scaffold FAB slot, Web: `position: fixed` |
|
|
1613
|
+
| `"floating-bottom-center"` | Floats above content, anchored to bottom center | Same, centered |
|
|
1614
|
+
| `"inline"` | Explicit normal flow (used in adaptive overrides to un-float an element) | — |
|
|
1615
|
+
|
|
1616
|
+
```yaml
|
|
1617
|
+
- id: fab
|
|
1618
|
+
position: "floating-bottom-trailing"
|
|
1619
|
+
contract: action_trigger
|
|
1620
|
+
variant: primary
|
|
1621
|
+
props: { label: "New task", icon: "plus" }
|
|
1622
|
+
# Un-float on expanded screens:
|
|
1623
|
+
adaptive:
|
|
1624
|
+
expanded:
|
|
1625
|
+
position: "inline"
|
|
1626
|
+
```
|
|
1627
|
+
|
|
1628
|
+
#### `item_variant`
|
|
1629
|
+
|
|
1630
|
+
Used alongside `item_contract` in collection props to specify which variant of the item contract to use for rendering each item.
|
|
1631
|
+
|
|
1632
|
+
```yaml
|
|
1633
|
+
- contract: collection
|
|
1634
|
+
variant: list
|
|
1635
|
+
props:
|
|
1636
|
+
data: "tasks"
|
|
1637
|
+
item_contract: data_display
|
|
1638
|
+
item_variant: compact # each item renders as data_display.compact
|
|
1639
|
+
item_props_map:
|
|
1640
|
+
title: "item.title"
|
|
1641
|
+
```
|
|
1642
|
+
|
|
1643
|
+
When omitted, the item contract's default variant is used.
|
|
1644
|
+
|
|
1645
|
+
#### `state_binding`
|
|
1646
|
+
|
|
1647
|
+
Binds a contract's state machine states to data paths. This allows screen-level data to drive contract states declaratively, without requiring an explicit action.
|
|
1648
|
+
|
|
1649
|
+
```yaml
|
|
1650
|
+
- contract: action_trigger
|
|
1651
|
+
variant: primary
|
|
1652
|
+
props:
|
|
1653
|
+
label: "Save"
|
|
1654
|
+
loading_label: "Saving..."
|
|
1655
|
+
state_binding:
|
|
1656
|
+
loading: "state.is_submitting" # button enters loading state when is_submitting is true
|
|
1657
|
+
disabled: "state.form_invalid" # button enters disabled state when form_invalid is true
|
|
1658
|
+
```
|
|
1659
|
+
|
|
1660
|
+
**Rules:**
|
|
1661
|
+
- Keys must be valid states from the contract's `states:` definition
|
|
1662
|
+
- Values must be data paths that resolve to `bool`
|
|
1663
|
+
- When the bound value is `true`, the contract transitions to that state
|
|
1664
|
+
- When the bound value returns to `false`, the contract transitions back to `default`
|
|
1665
|
+
- If multiple state bindings are `true` simultaneously, priority follows the contract's state machine (e.g., `loading` takes precedence over `disabled`)
|
|
1666
|
+
|
|
1667
|
+
---
|
|
1668
|
+
|
|
1669
|
+
## 5.2 Adaptive layout
|
|
1670
|
+
|
|
1671
|
+
Screens must work across phones, tablets, and desktops. OpenUISpec provides a three-layer adaptive system: **size classes** (global vocabulary), **layout primitives** (building blocks), and **per-section adaptive overrides** (co-located in screen files).
|
|
1672
|
+
|
|
1673
|
+
### 5.2.1 Size classes
|
|
1674
|
+
|
|
1675
|
+
Size classes are the universal breakpoint vocabulary. Every platform maps to the same three semantic classes.
|
|
1676
|
+
|
|
1677
|
+
```yaml
|
|
1678
|
+
# tokens/layout.yaml
|
|
1679
|
+
layout:
|
|
1680
|
+
size_classes:
|
|
1681
|
+
compact:
|
|
1682
|
+
semantic: "Single-column, phone-first layout"
|
|
1683
|
+
width: { max: 600 }
|
|
1684
|
+
columns: 4
|
|
1685
|
+
margin: "spacing.md"
|
|
1686
|
+
|
|
1687
|
+
regular:
|
|
1688
|
+
semantic: "Two-column capable, tablet and large phone layouts"
|
|
1689
|
+
width: { min: 601, max: 1024 }
|
|
1690
|
+
columns: 8
|
|
1691
|
+
margin: "spacing.lg"
|
|
1692
|
+
|
|
1693
|
+
expanded:
|
|
1694
|
+
semantic: "Multi-column, desktop and large tablet layouts"
|
|
1695
|
+
width: { min: 1025 }
|
|
1696
|
+
columns: 12
|
|
1697
|
+
margin: "spacing.xl"
|
|
1698
|
+
|
|
1699
|
+
platform_mapping:
|
|
1700
|
+
ios: { uses: "UIUserInterfaceSizeClass" }
|
|
1701
|
+
android: { uses: "WindowSizeClass" }
|
|
1702
|
+
web: { uses: "media queries" }
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
Screens and sections reference size classes by name (`compact`, `regular`, `expanded`), never by pixel values. This ensures the same spec works across platforms where pixel thresholds differ.
|
|
1706
|
+
|
|
1707
|
+
### 5.2.2 Layout primitives
|
|
1708
|
+
|
|
1709
|
+
Layout primitives are the building blocks for arranging content. They replace the informal `type: horizontal` / `type: vertical` patterns.
|
|
1710
|
+
|
|
1711
|
+
| Primitive | Behavior | iOS | Android | Web |
|
|
1712
|
+
|-----------|----------|-----|---------|-----|
|
|
1713
|
+
| `stack` | Vertical, top to bottom | `VStack` | `Column` | `flex-direction: column` |
|
|
1714
|
+
| `row` | Horizontal, leading to trailing | `HStack` | `Row` | `flex-direction: row` |
|
|
1715
|
+
| `grid` | 2D grid with columns | `LazyVGrid` | `LazyVerticalGrid` | `display: grid` |
|
|
1716
|
+
| `scroll_vertical` | Scrollable content area | `ScrollView` | `LazyColumn` | `overflow-y: auto` |
|
|
1717
|
+
| `split_view` | Side-by-side master-detail | `NavigationSplitView` | `ListDetailPaneScaffold` | CSS Grid |
|
|
1718
|
+
| `adaptive` | Changes layout per size class | — | — | — |
|
|
1719
|
+
|
|
1720
|
+
Each primitive has typed props:
|
|
1721
|
+
|
|
1722
|
+
```yaml
|
|
1723
|
+
stack:
|
|
1724
|
+
spacing: "token_ref"
|
|
1725
|
+
align: [leading, center, trailing, stretch]
|
|
1726
|
+
|
|
1727
|
+
row:
|
|
1728
|
+
spacing: "token_ref"
|
|
1729
|
+
align: [top, center, bottom, baseline, stretch]
|
|
1730
|
+
justify: [start, center, end, space-between]
|
|
1731
|
+
wrap: bool
|
|
1732
|
+
|
|
1733
|
+
grid:
|
|
1734
|
+
columns: "int or adaptive_map"
|
|
1735
|
+
gap: "token_ref"
|
|
1736
|
+
|
|
1737
|
+
split_view:
|
|
1738
|
+
primary_width: "fraction"
|
|
1739
|
+
collapse_at: "size_class" # collapses to single-column below this
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
### 5.2.3 The `adaptive` key
|
|
1743
|
+
|
|
1744
|
+
Any section, contract instance, or layout in a screen file can include an `adaptive` key. It maps size classes to property overrides.
|
|
1745
|
+
|
|
1746
|
+
**On layouts** — changes the arrangement:
|
|
1747
|
+
|
|
1748
|
+
```yaml
|
|
1749
|
+
- id: actions
|
|
1750
|
+
layout:
|
|
1751
|
+
adaptive:
|
|
1752
|
+
compact:
|
|
1753
|
+
type: stack
|
|
1754
|
+
spacing: "spacing.sm"
|
|
1755
|
+
regular:
|
|
1756
|
+
type: row
|
|
1757
|
+
spacing: "spacing.sm"
|
|
1758
|
+
```
|
|
1759
|
+
|
|
1760
|
+
**On contract instances** — changes props per size class:
|
|
1761
|
+
|
|
1762
|
+
```yaml
|
|
1763
|
+
- contract: action_trigger
|
|
1764
|
+
variant: primary
|
|
1765
|
+
props: { label: "Edit task" }
|
|
1766
|
+
adaptive:
|
|
1767
|
+
compact: { full_width: true }
|
|
1768
|
+
regular: { full_width: false }
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
**On screens** — changes the entire screen structure:
|
|
1772
|
+
|
|
1773
|
+
```yaml
|
|
1774
|
+
layout:
|
|
1775
|
+
adaptive:
|
|
1776
|
+
compact:
|
|
1777
|
+
type: scroll_vertical
|
|
1778
|
+
expanded:
|
|
1779
|
+
type: split_view
|
|
1780
|
+
primary_width: 0.38
|
|
1781
|
+
primary: { sections: [list] }
|
|
1782
|
+
secondary: { sections: [detail] }
|
|
1783
|
+
```
|
|
1784
|
+
|
|
1785
|
+
**On surfaces** — changes presentation mode:
|
|
1786
|
+
|
|
1787
|
+
```yaml
|
|
1788
|
+
surfaces:
|
|
1789
|
+
picker:
|
|
1790
|
+
contract: surface
|
|
1791
|
+
adaptive:
|
|
1792
|
+
compact: { variant: sheet, detents: [medium] }
|
|
1793
|
+
expanded: { variant: panel, width: 360 }
|
|
1794
|
+
```
|
|
1795
|
+
|
|
1796
|
+
### 5.2.4 Fallback behavior
|
|
1797
|
+
|
|
1798
|
+
If a size class is not specified, the system falls back to the nearest smaller class:
|
|
1799
|
+
- `expanded` falls back to `regular`, then `compact`
|
|
1800
|
+
- `regular` falls back to `compact`
|
|
1801
|
+
- `compact` is always required when `adaptive` is used
|
|
1802
|
+
|
|
1803
|
+
This means you only need to specify overrides for size classes that differ from the default.
|
|
1804
|
+
|
|
1805
|
+
### 5.2.5 Reflow rules
|
|
1806
|
+
|
|
1807
|
+
Reflow rules define default adaptive behaviors that AI generators should apply automatically, even when screens don't explicitly declare `adaptive` overrides:
|
|
1808
|
+
|
|
1809
|
+
```yaml
|
|
1810
|
+
reflow_rules:
|
|
1811
|
+
action_trigger:
|
|
1812
|
+
compact: { full_width: true }
|
|
1813
|
+
regular: { full_width: false }
|
|
1814
|
+
|
|
1815
|
+
collection_grid:
|
|
1816
|
+
compact: { columns: 1 }
|
|
1817
|
+
regular: { columns: 2 }
|
|
1818
|
+
expanded: { columns: 3 }
|
|
1819
|
+
|
|
1820
|
+
nav_container:
|
|
1821
|
+
compact: { variant: "tab_bar" }
|
|
1822
|
+
regular: { variant: "rail" }
|
|
1823
|
+
expanded: { variant: "sidebar" }
|
|
1824
|
+
|
|
1825
|
+
surface_sheet:
|
|
1826
|
+
compact: { variant: "sheet" }
|
|
1827
|
+
expanded: { variant: "panel" }
|
|
1828
|
+
```
|
|
1829
|
+
|
|
1830
|
+
Explicit `adaptive` overrides in screen files take precedence over reflow rules. Reflow rules serve as sensible defaults so that screens without adaptive annotations still behave reasonably across size classes.
|
|
1831
|
+
|
|
1832
|
+
### 5.2.6 AI generation requirements
|
|
1833
|
+
|
|
1834
|
+
For adaptive layout, AI generators:
|
|
1835
|
+
|
|
1836
|
+
**MUST:**
|
|
1837
|
+
- Implement all three size classes (compact, regular, expanded)
|
|
1838
|
+
- Map size classes to the correct platform API (`UIUserInterfaceSizeClass`, `WindowSizeClass`, media queries)
|
|
1839
|
+
- Apply explicit `adaptive` overrides from screen files
|
|
1840
|
+
- Apply reflow rules as defaults when no explicit override exists
|
|
1841
|
+
- Handle `split_view.collapse_at` — collapse to single column below the specified class
|
|
1842
|
+
|
|
1843
|
+
**SHOULD:**
|
|
1844
|
+
- Animate layout transitions smoothly when the size class changes (e.g., device rotation)
|
|
1845
|
+
- Support `content_max_width` from size class definitions
|
|
1846
|
+
- Apply `margin` from size class definitions as page-level padding
|
|
1847
|
+
|
|
1848
|
+
**MAY:**
|
|
1849
|
+
- Support intermediate breakpoints beyond the three defined classes
|
|
1850
|
+
- Implement `split_view` drag-to-resize on desktop
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
---
|
|
1854
|
+
|
|
1855
|
+
## 6. Navigation flows
|
|
1856
|
+
|
|
1857
|
+
Flows define multi-screen journeys. They are intent-based and platform-agnostic.
|
|
1858
|
+
|
|
1859
|
+
```yaml
|
|
1860
|
+
# flows/checkout.yaml
|
|
1861
|
+
checkout:
|
|
1862
|
+
semantic: "Guides user through order placement"
|
|
1863
|
+
|
|
1864
|
+
entry: "cart_review"
|
|
1865
|
+
|
|
1866
|
+
screens:
|
|
1867
|
+
cart_review:
|
|
1868
|
+
screen: "screens/cart_review"
|
|
1869
|
+
transitions:
|
|
1870
|
+
proceed: { to: "shipping_address", animation: "push" }
|
|
1871
|
+
back: { to: "$dismiss", animation: "pop" }
|
|
1872
|
+
|
|
1873
|
+
shipping_address:
|
|
1874
|
+
screen: "screens/shipping_form"
|
|
1875
|
+
transitions:
|
|
1876
|
+
next: { to: "payment", animation: "push" }
|
|
1877
|
+
back: { to: "cart_review", animation: "pop" }
|
|
1878
|
+
|
|
1879
|
+
payment:
|
|
1880
|
+
screen: "screens/payment_form"
|
|
1881
|
+
transitions:
|
|
1882
|
+
confirm: { to: "processing", animation: "push" }
|
|
1883
|
+
back: { to: "shipping_address", animation: "pop" }
|
|
1884
|
+
|
|
1885
|
+
processing:
|
|
1886
|
+
screen: "screens/order_processing"
|
|
1887
|
+
blocking: true # no back gesture
|
|
1888
|
+
transitions:
|
|
1889
|
+
success: { to: "confirmation", animation: "fade" }
|
|
1890
|
+
failure: { to: "payment", animation: "pop", feedback: { variant: "banner", severity: "error" } }
|
|
1891
|
+
|
|
1892
|
+
confirmation:
|
|
1893
|
+
screen: "screens/order_confirmation"
|
|
1894
|
+
terminal: true # replaces back stack
|
|
1895
|
+
transitions:
|
|
1896
|
+
done: { to: "$root", animation: "fade" }
|
|
1897
|
+
view_order: { to: "screens/order_detail", params_from: "result.order_id" }
|
|
1898
|
+
|
|
1899
|
+
progress:
|
|
1900
|
+
show: true
|
|
1901
|
+
steps: ["Cart", "Address", "Payment", "Done"]
|
|
1902
|
+
style: "stepped_bar"
|
|
1903
|
+
|
|
1904
|
+
platform_hints:
|
|
1905
|
+
ios: { presentation: "navigation_stack" }
|
|
1906
|
+
android: { presentation: "nav_host" }
|
|
1907
|
+
web: { presentation: "route_based", base_path: "/checkout" }
|
|
1908
|
+
```
|
|
1909
|
+
|
|
1910
|
+
---
|
|
1911
|
+
|
|
1912
|
+
## 7. Platform adaptation
|
|
1913
|
+
|
|
1914
|
+
Platform files define overrides, behavioral preferences, and generation rules per target.
|
|
1915
|
+
|
|
1916
|
+
```yaml
|
|
1917
|
+
# platform/ios.yaml
|
|
1918
|
+
ios:
|
|
1919
|
+
framework: swiftui
|
|
1920
|
+
min_version: "17.0"
|
|
1921
|
+
|
|
1922
|
+
overrides:
|
|
1923
|
+
nav_container:
|
|
1924
|
+
tab_bar: { height: 49, uses_system_tab_bar: true }
|
|
1925
|
+
sidebar: { uses_NavigationSplitView: true }
|
|
1926
|
+
surface:
|
|
1927
|
+
sheet: { detents: [.medium, .large], drag_indicator: true }
|
|
1928
|
+
modal: { prefers_sheet: true }
|
|
1929
|
+
feedback:
|
|
1930
|
+
toast: { position: "top" }
|
|
1931
|
+
input_field:
|
|
1932
|
+
date: { uses_system_picker: true, style: "wheel_or_graphical" }
|
|
1933
|
+
|
|
1934
|
+
behaviors:
|
|
1935
|
+
haptics: true
|
|
1936
|
+
large_title_scroll: true
|
|
1937
|
+
swipe_back: true
|
|
1938
|
+
safe_area_respect: true
|
|
1939
|
+
dynamic_type: true
|
|
1940
|
+
|
|
1941
|
+
generation:
|
|
1942
|
+
imports: ["SwiftUI", "Foundation"]
|
|
1943
|
+
architecture: "MVVM with @Observable"
|
|
1944
|
+
naming: "Swift conventions (camelCase, UpperCamelCase types)"
|
|
1945
|
+
|
|
1946
|
+
# platform/android.yaml
|
|
1947
|
+
android:
|
|
1948
|
+
framework: compose
|
|
1949
|
+
min_sdk: 26
|
|
1950
|
+
target_sdk: 35
|
|
1951
|
+
|
|
1952
|
+
overrides:
|
|
1953
|
+
nav_container:
|
|
1954
|
+
tab_bar: { height: 56, uses_NavigationBar: true }
|
|
1955
|
+
surface:
|
|
1956
|
+
sheet: { uses_ModalBottomSheet: true }
|
|
1957
|
+
feedback:
|
|
1958
|
+
snackbar: { uses_system_snackbar: true }
|
|
1959
|
+
action_trigger:
|
|
1960
|
+
primary: { ripple: true }
|
|
1961
|
+
|
|
1962
|
+
behaviors:
|
|
1963
|
+
material_you: true
|
|
1964
|
+
predictive_back: true
|
|
1965
|
+
edge_to_edge: true
|
|
1966
|
+
dynamic_color: true
|
|
1967
|
+
|
|
1968
|
+
generation:
|
|
1969
|
+
dependencies: ["material3", "navigation-compose"]
|
|
1970
|
+
architecture: "MVVM with ViewModel + StateFlow"
|
|
1971
|
+
naming: "Kotlin conventions (camelCase, PascalCase composables)"
|
|
1972
|
+
|
|
1973
|
+
# platform/web.yaml
|
|
1974
|
+
web:
|
|
1975
|
+
framework: react
|
|
1976
|
+
language: typescript
|
|
1977
|
+
|
|
1978
|
+
overrides:
|
|
1979
|
+
# Navigation variant is handled by the adaptive key on nav_container
|
|
1980
|
+
# in screen files — no ad-hoc variant_responsive needed here.
|
|
1981
|
+
surface:
|
|
1982
|
+
sheet: { falls_back_to: "modal" }
|
|
1983
|
+
feedback:
|
|
1984
|
+
dialog: { uses_native_dialog_element: true }
|
|
1985
|
+
|
|
1986
|
+
behaviors:
|
|
1987
|
+
prefers_color_scheme: true
|
|
1988
|
+
keyboard_navigation: true
|
|
1989
|
+
# Breakpoints reference layout.size_classes (defined in tokens/layout.yaml):
|
|
1990
|
+
# compact: max 600px, regular: 601-1024px, expanded: 1025px+
|
|
1991
|
+
keyboard_shortcuts:
|
|
1992
|
+
new_task: { key: "n", modifier: "cmd/ctrl" }
|
|
1993
|
+
search: { key: "k", modifier: "cmd/ctrl" }
|
|
1994
|
+
|
|
1995
|
+
generation:
|
|
1996
|
+
bundler: "vite"
|
|
1997
|
+
css: "tailwind"
|
|
1998
|
+
routing: "react_router"
|
|
1999
|
+
state: "zustand"
|
|
2000
|
+
naming: "React conventions (PascalCase components, camelCase props)"
|
|
2001
|
+
```
|
|
2002
|
+
|
|
2003
|
+
---
|
|
2004
|
+
|
|
2005
|
+
## 8. AI generation contract
|
|
2006
|
+
|
|
2007
|
+
This section defines the rules any AI code generator must follow when producing code from an OpenUISpec document.
|
|
2008
|
+
|
|
2009
|
+
### 8.1 Compliance levels
|
|
2010
|
+
|
|
2011
|
+
| Level | Label | Requirement |
|
|
2012
|
+
|-------|-------|-------------|
|
|
2013
|
+
| **MUST** | Required | Code will not pass validation without this |
|
|
2014
|
+
| **SHOULD** | Recommended | Expected for production quality |
|
|
2015
|
+
| **MAY** | Optional | Nice-to-have, platform-specific polish |
|
|
2016
|
+
|
|
2017
|
+
### 8.2 Universal MUSTs
|
|
2018
|
+
|
|
2019
|
+
Every AI generator, regardless of platform target, MUST:
|
|
2020
|
+
|
|
2021
|
+
1. Produce **compilable code** that builds without errors on the target platform.
|
|
2022
|
+
2. Map every `contract` reference to the correct native widget per `platform_mapping`.
|
|
2023
|
+
3. Apply all `tokens` values within their declared `range` constraints.
|
|
2024
|
+
4. Implement every `state` declared in each used contract, including transitions.
|
|
2025
|
+
5. Set correct `a11y.role` and `a11y.label` for every component instance.
|
|
2026
|
+
6. Respect `themes` by generating light/dark mode support.
|
|
2027
|
+
7. Handle `empty`, `loading`, and `error` states for `collection` contracts.
|
|
2028
|
+
8. Wire all `action.navigate` declarations to the platform's navigation system.
|
|
2029
|
+
9. Apply `motion.reduced_motion` preferences globally.
|
|
2030
|
+
10. Implement all three size classes (`compact`, `regular`, `expanded`) and apply `adaptive` overrides from screen files.
|
|
2031
|
+
11. Validate all `props` types and report spec errors before generating code.
|
|
2032
|
+
12. Generate platform-native localization resources from JSON locale files when `i18n` config is present (see Section 11).
|
|
2033
|
+
|
|
2034
|
+
### 8.3 Validation
|
|
2035
|
+
|
|
2036
|
+
A valid OpenUISpec document:
|
|
2037
|
+
|
|
2038
|
+
- Parses as valid YAML with no syntax errors
|
|
2039
|
+
- Contains a root `openuispec.yaml` manifest with `spec_version`
|
|
2040
|
+
- References only defined tokens, contracts, screens, and flows
|
|
2041
|
+
- Has no circular `flow` transitions
|
|
2042
|
+
- Has every `required: true` prop satisfied in screen compositions
|
|
2043
|
+
- Has every `screen.params` satisfied by its callers
|
|
2044
|
+
|
|
2045
|
+
### 8.4 Drift detection
|
|
2046
|
+
|
|
2047
|
+
After initial generation, AI tools SHOULD support **drift detection**: comparing the current codebase against the spec to identify where manual platform refinements have diverged from the source of truth. Drift is expected and healthy — the goal is awareness, not enforcement.
|
|
2048
|
+
|
|
2049
|
+
---
|
|
2050
|
+
|
|
2051
|
+
## 9. Action system
|
|
2052
|
+
|
|
2053
|
+
Actions define what happens when a user interacts with the UI. Every `action:` property in a screen file references this system. Actions are composable, typed, and have defined error handling semantics.
|
|
2054
|
+
|
|
2055
|
+
### 9.1 Action vocabulary
|
|
2056
|
+
|
|
2057
|
+
Every action has a `type` and typed parameters. The full vocabulary:
|
|
2058
|
+
|
|
2059
|
+
| Type | Purpose | Params |
|
|
2060
|
+
|------|---------|--------|
|
|
2061
|
+
| `navigate` | Move to a screen or flow | `destination`, `params`, `presentation`, `animation` |
|
|
2062
|
+
| `api_call` | Call a backend endpoint | `endpoint`, `params`, `body`, `method`, `on_success`, `on_error` |
|
|
2063
|
+
| `set_state` | Update local screen state | `target`, `value` (or shorthand `{ key: value }`) |
|
|
2064
|
+
| `present` | Show a surface (sheet, modal, popover) | `surface` (reference to surfaces section) |
|
|
2065
|
+
| `dismiss` | Close the current surface or flow | (none, or `{ animate: bool }`) |
|
|
2066
|
+
| `submit_form` | Validate and submit a form | `form_id` |
|
|
2067
|
+
| `confirm` | Show a confirmation before proceeding | `confirmation` (feedback contract instance) |
|
|
2068
|
+
| `refresh` | Re-fetch a data source | `target` (data path, e.g., `"screens/home.tasks"`) |
|
|
2069
|
+
| `open_url` | Open an external URL | `url`, `in_app: bool` |
|
|
2070
|
+
| `share` | Invoke the platform share sheet | `content` (text, url, or data ref) |
|
|
2071
|
+
| `copy` | Copy text to clipboard | `value`, `feedback` (optional toast) |
|
|
2072
|
+
| `sequence` | Execute multiple actions in order | `actions: list<action>` |
|
|
2073
|
+
| `conditional` | Branch based on a condition | `condition`, `then`, `else` |
|
|
2074
|
+
| `feedback` | Emit a feedback contract instance (toast, banner, etc.) | `variant`, `message`, `severity`, `duration`, `title`, `icon` |
|
|
2075
|
+
|
|
2076
|
+
### 9.2 Action definitions
|
|
2077
|
+
|
|
2078
|
+
#### `navigate`
|
|
2079
|
+
|
|
2080
|
+
```yaml
|
|
2081
|
+
action:
|
|
2082
|
+
type: navigate
|
|
2083
|
+
destination: "screens/task_detail" # screen ref or flow ref
|
|
2084
|
+
params: { task_id: "item.id" } # passed as screen params
|
|
2085
|
+
presentation: "push" # push | sheet | modal | fullscreen | replace
|
|
2086
|
+
animation: "default" # default | fade | none
|
|
2087
|
+
```
|
|
2088
|
+
|
|
2089
|
+
**Presentation modes:**
|
|
2090
|
+
|
|
2091
|
+
| Mode | Behavior | iOS | Android | Web |
|
|
2092
|
+
|------|----------|-----|---------|-----|
|
|
2093
|
+
| `push` | Adds to navigation stack | `NavigationLink` | `NavController.navigate` | route push |
|
|
2094
|
+
| `sheet` | Presents as bottom sheet | `.sheet` | `ModalBottomSheet` | modal or drawer |
|
|
2095
|
+
| `modal` | Presents as centered dialog | `.sheet` or `.fullScreenCover` | `Dialog` | `<dialog>` |
|
|
2096
|
+
| `fullscreen` | Covers entire screen | `.fullScreenCover` | fullscreen `Dialog` | full-viewport |
|
|
2097
|
+
| `replace` | Replaces current screen (no back) | replaces stack | `popUpTo` + navigate | route replace |
|
|
2098
|
+
|
|
2099
|
+
**Special destinations:**
|
|
2100
|
+
- `$back` — pop the current screen
|
|
2101
|
+
- `$root` — return to the root of the navigation stack
|
|
2102
|
+
- `$dismiss` — dismiss the current surface (sheet, modal)
|
|
2103
|
+
|
|
2104
|
+
#### `api_call`
|
|
2105
|
+
|
|
2106
|
+
```yaml
|
|
2107
|
+
action:
|
|
2108
|
+
type: api_call
|
|
2109
|
+
endpoint: "api.tasks.create"
|
|
2110
|
+
method: "POST" # GET | POST | PUT | PATCH | DELETE (default: inferred from endpoint)
|
|
2111
|
+
params: { id: "task.id" } # URL/query params
|
|
2112
|
+
body: "form" # request body (form data, or explicit object)
|
|
2113
|
+
headers: {} # optional additional headers
|
|
2114
|
+
|
|
2115
|
+
on_success:
|
|
2116
|
+
type: sequence
|
|
2117
|
+
actions:
|
|
2118
|
+
- { type: set_state, is_submitting: false }
|
|
2119
|
+
- { type: feedback, variant: toast, message: "Task created", severity: success }
|
|
2120
|
+
- { type: dismiss }
|
|
2121
|
+
- { type: refresh, target: "screens/home.tasks" }
|
|
2122
|
+
|
|
2123
|
+
on_error:
|
|
2124
|
+
type: sequence
|
|
2125
|
+
actions:
|
|
2126
|
+
- { type: set_state, is_submitting: false }
|
|
2127
|
+
- { type: feedback, variant: banner, title: "Couldn't create task", message: "{error.message}", severity: error }
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
**Error object shape:**
|
|
2131
|
+
When an API call fails, the `on_error` handler receives an `error` object:
|
|
2132
|
+
```yaml
|
|
2133
|
+
error:
|
|
2134
|
+
message: string # human-readable error message
|
|
2135
|
+
code: string # error code (e.g., "VALIDATION_ERROR", "NOT_FOUND")
|
|
2136
|
+
status: int # HTTP status code (e.g., 400, 404, 500)
|
|
2137
|
+
fields: map<string, string> # per-field validation errors (optional)
|
|
2138
|
+
```
|
|
2139
|
+
|
|
2140
|
+
#### `set_state`
|
|
2141
|
+
|
|
2142
|
+
```yaml
|
|
2143
|
+
# Full form
|
|
2144
|
+
action:
|
|
2145
|
+
type: set_state
|
|
2146
|
+
target: "state.active_filter"
|
|
2147
|
+
value: "today"
|
|
2148
|
+
|
|
2149
|
+
# Shorthand — multiple state updates
|
|
2150
|
+
action:
|
|
2151
|
+
type: set_state
|
|
2152
|
+
is_submitting: true
|
|
2153
|
+
error_message: null
|
|
2154
|
+
|
|
2155
|
+
# Computed value
|
|
2156
|
+
action:
|
|
2157
|
+
type: set_state
|
|
2158
|
+
target: "state.item_count"
|
|
2159
|
+
value: "{state.item_count + 1}"
|
|
2160
|
+
```
|
|
2161
|
+
|
|
2162
|
+
#### `confirm`
|
|
2163
|
+
|
|
2164
|
+
Wraps a destructive or significant action in a confirmation dialog:
|
|
2165
|
+
|
|
2166
|
+
```yaml
|
|
2167
|
+
action:
|
|
2168
|
+
type: confirm
|
|
2169
|
+
confirmation:
|
|
2170
|
+
contract: feedback
|
|
2171
|
+
variant: dialog
|
|
2172
|
+
props:
|
|
2173
|
+
title: "Delete task?"
|
|
2174
|
+
message: "This action cannot be undone."
|
|
2175
|
+
severity: error
|
|
2176
|
+
actions:
|
|
2177
|
+
- label: "Cancel"
|
|
2178
|
+
variant: secondary
|
|
2179
|
+
action: { type: dismiss }
|
|
2180
|
+
- label: "Delete"
|
|
2181
|
+
variant: destructive
|
|
2182
|
+
action: # the actual action, executed on confirm
|
|
2183
|
+
type: api_call
|
|
2184
|
+
endpoint: "api.tasks.delete"
|
|
2185
|
+
params: { id: "task.id" }
|
|
2186
|
+
```
|
|
2187
|
+
|
|
2188
|
+
#### `sequence`
|
|
2189
|
+
|
|
2190
|
+
Executes actions in order. Each action completes before the next begins.
|
|
2191
|
+
|
|
2192
|
+
```yaml
|
|
2193
|
+
action:
|
|
2194
|
+
type: sequence
|
|
2195
|
+
actions:
|
|
2196
|
+
- { type: set_state, is_submitting: true }
|
|
2197
|
+
- type: api_call
|
|
2198
|
+
endpoint: "api.tasks.create"
|
|
2199
|
+
body: "form"
|
|
2200
|
+
- { type: feedback, variant: toast, message: "Created", severity: success }
|
|
2201
|
+
- { type: dismiss }
|
|
2202
|
+
```
|
|
2203
|
+
|
|
2204
|
+
**Sequence stops on failure.** If any action in a sequence fails (e.g., `api_call` returns an error), subsequent actions do not execute. The `on_error` handler of the failed action runs instead.
|
|
2205
|
+
|
|
2206
|
+
#### `conditional`
|
|
2207
|
+
|
|
2208
|
+
Branches based on a runtime condition:
|
|
2209
|
+
|
|
2210
|
+
```yaml
|
|
2211
|
+
action:
|
|
2212
|
+
type: conditional
|
|
2213
|
+
condition: "task.status == done"
|
|
2214
|
+
then:
|
|
2215
|
+
type: api_call
|
|
2216
|
+
endpoint: "api.tasks.reopen"
|
|
2217
|
+
params: { id: "task.id" }
|
|
2218
|
+
else:
|
|
2219
|
+
type: api_call
|
|
2220
|
+
endpoint: "api.tasks.complete"
|
|
2221
|
+
params: { id: "task.id" }
|
|
2222
|
+
```
|
|
2223
|
+
|
|
2224
|
+
#### `feedback`
|
|
2225
|
+
|
|
2226
|
+
Emits a feedback contract instance (toast, banner, snackbar) as a side effect. Unlike `present`, this does not block the action chain — the feedback displays while execution continues.
|
|
2227
|
+
|
|
2228
|
+
```yaml
|
|
2229
|
+
action:
|
|
2230
|
+
type: feedback
|
|
2231
|
+
variant: toast # toast | banner | snackbar | inline
|
|
2232
|
+
message: "Task created"
|
|
2233
|
+
severity: success # info | success | warning | error | neutral
|
|
2234
|
+
title: null # optional title (used by banner and dialog)
|
|
2235
|
+
icon: "checkmark_circle" # optional, defaults from severity
|
|
2236
|
+
duration: 3000 # ms, null = manual dismiss
|
|
2237
|
+
```
|
|
2238
|
+
|
|
2239
|
+
#### `present`
|
|
2240
|
+
|
|
2241
|
+
Shows a named surface (sheet, modal, popover) defined in the screen's `surfaces:` block:
|
|
2242
|
+
|
|
2243
|
+
```yaml
|
|
2244
|
+
action:
|
|
2245
|
+
type: present
|
|
2246
|
+
surface: "assignee_picker" # key from surfaces: block
|
|
2247
|
+
```
|
|
2248
|
+
|
|
2249
|
+
#### `dismiss`
|
|
2250
|
+
|
|
2251
|
+
Closes the current surface or flow. If called from within a sheet/modal, dismisses it. If called from within a flow, exits the flow.
|
|
2252
|
+
|
|
2253
|
+
```yaml
|
|
2254
|
+
action:
|
|
2255
|
+
type: dismiss
|
|
2256
|
+
animate: true # default true; false = instant dismiss
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
#### `submit_form`
|
|
2260
|
+
|
|
2261
|
+
Validates all fields in the referenced form, then triggers the form's `on_submit:` handler. If validation fails, error states are set on individual fields. See Section 13 for the full validation rule system.
|
|
2262
|
+
|
|
2263
|
+
```yaml
|
|
2264
|
+
action:
|
|
2265
|
+
type: submit_form
|
|
2266
|
+
form_id: "task_form" # matches form_id on the form section
|
|
2267
|
+
|
|
2268
|
+
# With extended options (Section 13.8)
|
|
2269
|
+
action:
|
|
2270
|
+
type: submit_form
|
|
2271
|
+
form_id: "task_form"
|
|
2272
|
+
validate_only: true # run validation without triggering on_submit
|
|
2273
|
+
on_validation_error: # action to run when validation fails
|
|
2274
|
+
type: feedback
|
|
2275
|
+
variant: toast
|
|
2276
|
+
message: "Please fix the errors above"
|
|
2277
|
+
severity: warning
|
|
2278
|
+
```
|
|
2279
|
+
|
|
2280
|
+
**Validation behavior:**
|
|
2281
|
+
1. All fields with `required: true` (or `required_when` evaluating to true) are checked for non-empty values
|
|
2282
|
+
2. Fields with `max_length` prop are checked for length (shorthand; see also `validate.max_length`)
|
|
2283
|
+
3. Fields with `input_type: email` are checked for email format (shorthand; see also `validate.format`)
|
|
2284
|
+
4. Fields with a `validate` block have all declared rules checked in order (Section 13.2)
|
|
2285
|
+
5. If any field fails, its state transitions to `error` with `error_text` set to the rule's `message`
|
|
2286
|
+
6. If `validate_only: true`, stop here (do not run `on_submit:`)
|
|
2287
|
+
7. If `on_validation_error` is set and validation failed, execute that action
|
|
2288
|
+
8. If all fields pass, the `on_submit:` handler executes
|
|
2289
|
+
|
|
2290
|
+
#### `refresh`
|
|
2291
|
+
|
|
2292
|
+
Re-fetches a data source. The target is a data path pointing to a screen's data entry.
|
|
2293
|
+
|
|
2294
|
+
```yaml
|
|
2295
|
+
action:
|
|
2296
|
+
type: refresh
|
|
2297
|
+
target: "screens/home.tasks" # screen.data_key
|
|
2298
|
+
```
|
|
2299
|
+
|
|
2300
|
+
#### `open_url`
|
|
2301
|
+
|
|
2302
|
+
Opens an external URL in the system browser or an in-app browser.
|
|
2303
|
+
|
|
2304
|
+
```yaml
|
|
2305
|
+
action:
|
|
2306
|
+
type: open_url
|
|
2307
|
+
url: "https://example.com/help"
|
|
2308
|
+
in_app: false # true = in-app browser; false = system browser
|
|
2309
|
+
```
|
|
2310
|
+
|
|
2311
|
+
#### `share`
|
|
2312
|
+
|
|
2313
|
+
Invokes the platform share sheet:
|
|
2314
|
+
|
|
2315
|
+
```yaml
|
|
2316
|
+
action:
|
|
2317
|
+
type: share
|
|
2318
|
+
content:
|
|
2319
|
+
text: "Check out this task: {task.title}"
|
|
2320
|
+
url: "https://app.taskflow.io/tasks/{task.id}"
|
|
2321
|
+
```
|
|
2322
|
+
|
|
2323
|
+
#### `copy`
|
|
2324
|
+
|
|
2325
|
+
Copies a value to the clipboard, with an optional feedback toast:
|
|
2326
|
+
|
|
2327
|
+
```yaml
|
|
2328
|
+
action:
|
|
2329
|
+
type: copy
|
|
2330
|
+
value: "{task.id}"
|
|
2331
|
+
feedback: { variant: toast, message: "Copied to clipboard", severity: info, duration: 2000 }
|
|
2332
|
+
```
|
|
2333
|
+
|
|
2334
|
+
### 9.3 Inline action shorthand
|
|
2335
|
+
|
|
2336
|
+
For common single-action cases, a shorthand form is allowed:
|
|
2337
|
+
|
|
2338
|
+
```yaml
|
|
2339
|
+
# Full form
|
|
2340
|
+
action:
|
|
2341
|
+
type: navigate
|
|
2342
|
+
destination: "screens/task_detail"
|
|
2343
|
+
params: { task_id: "item.id" }
|
|
2344
|
+
|
|
2345
|
+
# Shorthand for navigate
|
|
2346
|
+
action: { navigate: "screens/task_detail", params: { task_id: "item.id" } }
|
|
2347
|
+
|
|
2348
|
+
# Shorthand for dismiss
|
|
2349
|
+
action: { dismiss: true }
|
|
2350
|
+
|
|
2351
|
+
# Shorthand for set_state
|
|
2352
|
+
action: { set_state: { is_loading: true } }
|
|
2353
|
+
|
|
2354
|
+
# Shorthand for feedback (inline in on_success)
|
|
2355
|
+
on_success:
|
|
2356
|
+
feedback: { variant: toast, message: "Done", severity: success }
|
|
2357
|
+
```
|
|
2358
|
+
|
|
2359
|
+
AI generators MUST support both the full form and shorthand form.
|
|
2360
|
+
|
|
2361
|
+
### 9.4 Optimistic updates
|
|
2362
|
+
|
|
2363
|
+
For actions that modify data, the spec supports optimistic UI updates:
|
|
2364
|
+
|
|
2365
|
+
```yaml
|
|
2366
|
+
action:
|
|
2367
|
+
type: api_call
|
|
2368
|
+
endpoint: "api.tasks.toggleStatus"
|
|
2369
|
+
params: { id: "task.id" }
|
|
2370
|
+
|
|
2371
|
+
optimistic:
|
|
2372
|
+
target: "task.status"
|
|
2373
|
+
value: "{task.status == done ? 'todo' : 'done'}"
|
|
2374
|
+
revert_on_error: true
|
|
2375
|
+
```
|
|
2376
|
+
|
|
2377
|
+
**Behavior:**
|
|
2378
|
+
1. The `target` data path is updated immediately with `value`
|
|
2379
|
+
2. The UI re-renders with the new value (no loading state)
|
|
2380
|
+
3. The API call executes in the background
|
|
2381
|
+
4. On success: the optimistic value is confirmed (or replaced with the server response)
|
|
2382
|
+
5. On error: if `revert_on_error` is true, the value reverts to its previous state and the `on_error` handler runs
|
|
2383
|
+
|
|
2384
|
+
### 9.5 Action composition patterns
|
|
2385
|
+
|
|
2386
|
+
Common patterns that AI generators should recognize:
|
|
2387
|
+
|
|
2388
|
+
**Submit with loading state:**
|
|
2389
|
+
```yaml
|
|
2390
|
+
action:
|
|
2391
|
+
type: sequence
|
|
2392
|
+
actions:
|
|
2393
|
+
- { type: set_state, is_submitting: true }
|
|
2394
|
+
- type: api_call
|
|
2395
|
+
endpoint: "api.tasks.create"
|
|
2396
|
+
body: "form"
|
|
2397
|
+
on_success:
|
|
2398
|
+
type: sequence
|
|
2399
|
+
actions:
|
|
2400
|
+
- { type: set_state, is_submitting: false }
|
|
2401
|
+
- { type: dismiss }
|
|
2402
|
+
on_error:
|
|
2403
|
+
type: sequence
|
|
2404
|
+
actions:
|
|
2405
|
+
- { type: set_state, is_submitting: false }
|
|
2406
|
+
- { type: feedback, variant: banner, severity: error, message: "{error.message}" }
|
|
2407
|
+
```
|
|
2408
|
+
|
|
2409
|
+
**Delete with confirmation and navigation:**
|
|
2410
|
+
```yaml
|
|
2411
|
+
action:
|
|
2412
|
+
type: confirm
|
|
2413
|
+
confirmation:
|
|
2414
|
+
# ... dialog props ...
|
|
2415
|
+
actions:
|
|
2416
|
+
- label: "Delete"
|
|
2417
|
+
variant: destructive
|
|
2418
|
+
action:
|
|
2419
|
+
type: api_call
|
|
2420
|
+
endpoint: "api.tasks.delete"
|
|
2421
|
+
params: { id: "task.id" }
|
|
2422
|
+
on_success:
|
|
2423
|
+
type: sequence
|
|
2424
|
+
actions:
|
|
2425
|
+
- { type: navigate, destination: "$back" }
|
|
2426
|
+
- { type: feedback, variant: toast, message: "Deleted", severity: neutral }
|
|
2427
|
+
```
|
|
2428
|
+
|
|
2429
|
+
**Toggle with optimistic update:**
|
|
2430
|
+
```yaml
|
|
2431
|
+
action:
|
|
2432
|
+
type: api_call
|
|
2433
|
+
endpoint: "api.tasks.toggleStatus"
|
|
2434
|
+
params: { id: "task.id" }
|
|
2435
|
+
optimistic:
|
|
2436
|
+
target: "task.status"
|
|
2437
|
+
value: "{task.status == done ? 'todo' : 'done'}"
|
|
2438
|
+
revert_on_error: true
|
|
2439
|
+
on_error:
|
|
2440
|
+
feedback: { variant: toast, message: "Couldn't update status", severity: error }
|
|
2441
|
+
```
|
|
2442
|
+
|
|
2443
|
+
### 9.6 AI generation requirements
|
|
2444
|
+
|
|
2445
|
+
**MUST:**
|
|
2446
|
+
- Implement all action types in the vocabulary
|
|
2447
|
+
- Support both full form and shorthand syntax
|
|
2448
|
+
- Execute sequences in order, stopping on failure
|
|
2449
|
+
- Wire `on_success` and `on_error` handlers for every `api_call`
|
|
2450
|
+
- Map `navigate` presentations to correct platform APIs
|
|
2451
|
+
- Implement `confirm` as a blocking dialog before the inner action
|
|
2452
|
+
|
|
2453
|
+
**SHOULD:**
|
|
2454
|
+
- Support optimistic updates with automatic revert
|
|
2455
|
+
- Debounce rapid-fire actions (e.g., toggle tapped multiple times)
|
|
2456
|
+
- Show loading indicators during `api_call` when no optimistic update is defined
|
|
2457
|
+
- Handle network errors gracefully with retry affordances
|
|
2458
|
+
|
|
2459
|
+
**MAY:**
|
|
2460
|
+
- Support action middleware (logging, analytics events)
|
|
2461
|
+
- Queue actions when offline and replay on reconnection
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
---
|
|
2465
|
+
|
|
2466
|
+
## 10. Data binding & state management
|
|
2467
|
+
|
|
2468
|
+
Every screen in OpenUISpec connects to data: API responses, local state, user input, and derived values. This section formalizes how data flows through the spec.
|
|
2469
|
+
|
|
2470
|
+
### 10.1 Data sources
|
|
2471
|
+
|
|
2472
|
+
A screen's `data:` block declares its data dependencies. Each entry has a source, optional params, and defined refresh behavior.
|
|
2473
|
+
|
|
2474
|
+
```yaml
|
|
2475
|
+
data:
|
|
2476
|
+
tasks:
|
|
2477
|
+
source: "api.tasks.list"
|
|
2478
|
+
params:
|
|
2479
|
+
filter: "state.active_filter" # reactive — re-fetches when state changes
|
|
2480
|
+
sort: "state.sort_order"
|
|
2481
|
+
refresh:
|
|
2482
|
+
on: [screen_appear, pull_to_refresh]
|
|
2483
|
+
interval: null # no polling (use for real-time: 30000 = 30s)
|
|
2484
|
+
|
|
2485
|
+
task_counts:
|
|
2486
|
+
source: "api.tasks.counts"
|
|
2487
|
+
refresh:
|
|
2488
|
+
on: [screen_appear]
|
|
2489
|
+
|
|
2490
|
+
user:
|
|
2491
|
+
source: "api.auth.currentUser"
|
|
2492
|
+
cache: session # persists for the session
|
|
2493
|
+
```
|
|
2494
|
+
|
|
2495
|
+
**Source types:**
|
|
2496
|
+
|
|
2497
|
+
| Source type | Syntax | Behavior |
|
|
2498
|
+
|-------------|--------|----------|
|
|
2499
|
+
| API endpoint | `"api.tasks.list"` | HTTP request, returns async data |
|
|
2500
|
+
| Local state | `"state.active_filter"` | In-memory, synchronous |
|
|
2501
|
+
| Derived | `"derived.overdue_count"` | Computed from other data sources |
|
|
2502
|
+
| Static | inline value | Literal data, no fetching |
|
|
2503
|
+
| Param | `"params.task_id"` | Passed from caller (navigate action) |
|
|
2504
|
+
|
|
2505
|
+
**API source resolution:**
|
|
2506
|
+
API sources use dot-notation that maps to REST endpoints. The AI generator resolves these based on the project's API conventions:
|
|
2507
|
+
|
|
2508
|
+
```yaml
|
|
2509
|
+
# These are equivalent — the generator infers the HTTP method and path
|
|
2510
|
+
source: "api.tasks.list" # → GET /api/tasks
|
|
2511
|
+
source: "api.tasks.getById" # → GET /api/tasks/:id
|
|
2512
|
+
source: "api.tasks.create" # → POST /api/tasks
|
|
2513
|
+
source: "api.tasks.update" # → PUT /api/tasks/:id
|
|
2514
|
+
source: "api.tasks.delete" # → DELETE /api/tasks/:id
|
|
2515
|
+
```
|
|
2516
|
+
|
|
2517
|
+
The spec does not mandate a specific API format. AI generators adapt to REST, GraphQL, or any backend the project uses.
|
|
2518
|
+
|
|
2519
|
+
**Derived sources:**
|
|
2520
|
+
|
|
2521
|
+
```yaml
|
|
2522
|
+
data:
|
|
2523
|
+
tasks:
|
|
2524
|
+
source: "api.tasks.list"
|
|
2525
|
+
|
|
2526
|
+
overdue_count:
|
|
2527
|
+
source: derived
|
|
2528
|
+
expression: "tasks.filter(t => t.due_date < now && t.status != 'done').length"
|
|
2529
|
+
depends_on: [tasks] # re-computes when tasks changes
|
|
2530
|
+
```
|
|
2531
|
+
|
|
2532
|
+
### 10.2 Screen state
|
|
2533
|
+
|
|
2534
|
+
The `state:` block declares local, ephemeral state that lives only while the screen is mounted.
|
|
2535
|
+
|
|
2536
|
+
```yaml
|
|
2537
|
+
state:
|
|
2538
|
+
active_filter:
|
|
2539
|
+
type: enum
|
|
2540
|
+
values: [all, today, upcoming, done]
|
|
2541
|
+
default: today
|
|
2542
|
+
|
|
2543
|
+
sort_order:
|
|
2544
|
+
type: enum
|
|
2545
|
+
values: [due_date, priority, created_at]
|
|
2546
|
+
default: due_date
|
|
2547
|
+
|
|
2548
|
+
search_query:
|
|
2549
|
+
type: string
|
|
2550
|
+
default: ""
|
|
2551
|
+
|
|
2552
|
+
is_submitting:
|
|
2553
|
+
type: bool
|
|
2554
|
+
default: false
|
|
2555
|
+
|
|
2556
|
+
selected_task_id:
|
|
2557
|
+
type: string
|
|
2558
|
+
default: null
|
|
2559
|
+
```
|
|
2560
|
+
|
|
2561
|
+
**State is reactive.** When state changes (via `set_state` action), any data source or UI element that references that state value re-evaluates automatically. This drives the reactive update model.
|
|
2562
|
+
|
|
2563
|
+
### 10.3 Data path syntax
|
|
2564
|
+
|
|
2565
|
+
Data paths use dot-notation to traverse objects. They appear in props, conditions, format expressions, and action params.
|
|
2566
|
+
|
|
2567
|
+
**Grammar:**
|
|
2568
|
+
|
|
2569
|
+
```
|
|
2570
|
+
data_path := segment ('.' segment)*
|
|
2571
|
+
segment := identifier | indexed
|
|
2572
|
+
identifier := [a-zA-Z_][a-zA-Z0-9_]*
|
|
2573
|
+
indexed := identifier '[' (integer | '*') ']'
|
|
2574
|
+
```
|
|
2575
|
+
|
|
2576
|
+
**Examples:**
|
|
2577
|
+
|
|
2578
|
+
| Path | Resolves to |
|
|
2579
|
+
|------|------------|
|
|
2580
|
+
| `task.title` | The title field of the task object |
|
|
2581
|
+
| `task.project.name` | Nested object traversal |
|
|
2582
|
+
| `order.items[0].name` | First item's name |
|
|
2583
|
+
| `order.items[*].total` | Array of all items' totals |
|
|
2584
|
+
| `state.active_filter` | Local screen state value |
|
|
2585
|
+
| `params.task_id` | Screen parameter from caller |
|
|
2586
|
+
| `form.title` | Form field value |
|
|
2587
|
+
| `error.message` | Error object from `on_error` handler |
|
|
2588
|
+
| `user.first_name` | Current user's first name |
|
|
2589
|
+
|
|
2590
|
+
**Special path prefixes:**
|
|
2591
|
+
|
|
2592
|
+
| Prefix | Scope | Lifetime |
|
|
2593
|
+
|--------|-------|----------|
|
|
2594
|
+
| `state.` | Local screen state | While screen is mounted |
|
|
2595
|
+
| `params.` | Screen parameters | Passed from navigate action |
|
|
2596
|
+
| `form.` | Form field values | While form exists |
|
|
2597
|
+
| `data.` or bare name | Data source results | Fetched from API |
|
|
2598
|
+
| `error.` | Error object | Within `on_error` handler only |
|
|
2599
|
+
| `item.` | Current iteration item | Within collection `item_props_map` |
|
|
2600
|
+
|
|
2601
|
+
### 10.4 Binding direction
|
|
2602
|
+
|
|
2603
|
+
Props can be **read-only** (one-way) or **two-way bound** (read-write).
|
|
2604
|
+
|
|
2605
|
+
**One-way (default):** The prop displays a value but cannot modify it.
|
|
2606
|
+
|
|
2607
|
+
```yaml
|
|
2608
|
+
props:
|
|
2609
|
+
title: "{task.title}" # displays task.title, read-only
|
|
2610
|
+
subtitle: "{task.due_date | format:date_relative}"
|
|
2611
|
+
```
|
|
2612
|
+
|
|
2613
|
+
**Two-way binding:** The prop both displays and modifies a value. Indicated by `binding: true` in the contract prop definition, and `data_binding:` in the screen file.
|
|
2614
|
+
|
|
2615
|
+
```yaml
|
|
2616
|
+
# In the contract definition:
|
|
2617
|
+
props:
|
|
2618
|
+
value: { type: any, required: false, binding: true }
|
|
2619
|
+
|
|
2620
|
+
# In the screen file:
|
|
2621
|
+
- contract: input_field
|
|
2622
|
+
input_type: text
|
|
2623
|
+
props:
|
|
2624
|
+
label: "Title"
|
|
2625
|
+
placeholder: "What needs to be done?"
|
|
2626
|
+
data_binding: "form.title" # two-way: reads from and writes to form.title
|
|
2627
|
+
```
|
|
2628
|
+
|
|
2629
|
+
**Two-way binding targets must be writable:** Only `state.*` and `form.*` paths are writable. API data paths are read-only — to modify API data, use an `api_call` action.
|
|
2630
|
+
|
|
2631
|
+
### 10.5 Format expressions
|
|
2632
|
+
|
|
2633
|
+
Format expressions transform values for display. They appear inside `{}` delimiters in string props.
|
|
2634
|
+
|
|
2635
|
+
**Syntax:**
|
|
2636
|
+
|
|
2637
|
+
```
|
|
2638
|
+
interpolation := '{' (piped_expr | computed_expr) '}'
|
|
2639
|
+
piped_expr := data_path ('|' pipe)*
|
|
2640
|
+
pipe := operation ':' argument
|
|
2641
|
+
operation := 'format' | 'map' | 'default'
|
|
2642
|
+
computed_expr := data_path comparator value '?' literal ':' literal
|
|
2643
|
+
comparator := '==' | '!=' | '>' | '<' | '>=' | '<='
|
|
2644
|
+
locale_ref := '$t:' locale_key
|
|
2645
|
+
locale_key := key_segment ('.' key_segment)*
|
|
2646
|
+
key_segment := identifier | interpolation
|
|
2647
|
+
```
|
|
2648
|
+
|
|
2649
|
+
OpenUISpec supports three expression types for display strings:
|
|
2650
|
+
|
|
2651
|
+
1. **Piped expressions** — a data path optionally transformed by format/map/default pipes: `{task.due_date | format:date_relative}`
|
|
2652
|
+
2. **Computed expressions** — ternary conditionals for inline logic: `{task.status == done ? 'Reopen' : 'Mark complete'}`
|
|
2653
|
+
3. **Locale references** — `$t:key` strings resolved from locale JSON files (see Section 11): `"$t:home.new_task"`
|
|
2654
|
+
|
|
2655
|
+
Computed expressions are intentionally limited to single ternaries. Complex logic belongs in derived data sources (Section 10.1) or conditional actions (Section 9.2), not in display strings.
|
|
2656
|
+
|
|
2657
|
+
**Locale references with parameters:**
|
|
2658
|
+
|
|
2659
|
+
When a locale string contains ICU placeholders, supply data via a sibling `t_params` property:
|
|
2660
|
+
|
|
2661
|
+
```yaml
|
|
2662
|
+
# Locale file: "home.task_count": "{count, plural, =0 {No tasks today} one {# task today} other {# tasks today}}"
|
|
2663
|
+
subtitle: "$t:home.task_count"
|
|
2664
|
+
t_params:
|
|
2665
|
+
count: "task_counts.today"
|
|
2666
|
+
```
|
|
2667
|
+
|
|
2668
|
+
The `t_params` keys map to ICU placeholder names; the values are data paths resolved at runtime.
|
|
2669
|
+
|
|
2670
|
+
**Examples:**
|
|
2671
|
+
|
|
2672
|
+
```yaml
|
|
2673
|
+
# Simple interpolation
|
|
2674
|
+
title: "{task.title}"
|
|
2675
|
+
|
|
2676
|
+
# With formatter
|
|
2677
|
+
subtitle: "{task.due_date | format:date_relative}"
|
|
2678
|
+
|
|
2679
|
+
# With mapper
|
|
2680
|
+
severity: "{task.status | map:status_severity}"
|
|
2681
|
+
|
|
2682
|
+
# With default
|
|
2683
|
+
trailing: "{task.assignee.name | default:'Unassigned'}"
|
|
2684
|
+
|
|
2685
|
+
# Multiple interpolations
|
|
2686
|
+
subtitle: "{item.project.name} · {item.due_date | format:date_relative}"
|
|
2687
|
+
|
|
2688
|
+
# Computed expression
|
|
2689
|
+
label: "{task.status == done ? 'Reopen task' : 'Mark complete'}"
|
|
2690
|
+
|
|
2691
|
+
# Compound with format
|
|
2692
|
+
subtitle: "{item.quantity} × {item.unit_price | format:currency}"
|
|
2693
|
+
```
|
|
2694
|
+
|
|
2695
|
+
**Built-in formatters:**
|
|
2696
|
+
|
|
2697
|
+
| Formatter | Input | Output | Locale-aware |
|
|
2698
|
+
|-----------|-------|--------|-------------|
|
|
2699
|
+
| `currency` | number | "$1,234.56" | Yes |
|
|
2700
|
+
| `date` | date/datetime | "Mar 13, 2026" | Yes |
|
|
2701
|
+
| `date_relative` | date/datetime | "2 hours ago", "yesterday" | Yes |
|
|
2702
|
+
| `date_short` | date/datetime | "Mar 13" | Yes |
|
|
2703
|
+
| `time` | datetime | "3:45 PM" | Yes |
|
|
2704
|
+
| `number` | number | "1,234" | Yes |
|
|
2705
|
+
| `percentage` | number (0-1) | "45%" | No |
|
|
2706
|
+
| `status_label` | enum string | "In Progress" (title case) | No |
|
|
2707
|
+
| `pluralize` | number | "1 task" / "3 tasks" | Yes |
|
|
2708
|
+
| `file_size` | number (bytes) | "2.4 MB" | No |
|
|
2709
|
+
|
|
2710
|
+
**Built-in mappers:**
|
|
2711
|
+
|
|
2712
|
+
| Mapper | Input → Output |
|
|
2713
|
+
|--------|---------------|
|
|
2714
|
+
| `status_severity` | status enum → severity enum (e.g., "done" → "success") |
|
|
2715
|
+
| `priority_to_severity` | priority enum → severity enum (e.g., "urgent" → "error") |
|
|
2716
|
+
| `bool_to_label` | true/false → "Yes"/"No" (or custom mapping) |
|
|
2717
|
+
|
|
2718
|
+
**Custom formatters and mappers** can be defined in the project manifest:
|
|
2719
|
+
|
|
2720
|
+
```yaml
|
|
2721
|
+
# openuispec.yaml
|
|
2722
|
+
formatters:
|
|
2723
|
+
weight:
|
|
2724
|
+
input: number
|
|
2725
|
+
output: string
|
|
2726
|
+
pattern: "{value} kg"
|
|
2727
|
+
|
|
2728
|
+
mappers:
|
|
2729
|
+
status_severity:
|
|
2730
|
+
todo: neutral
|
|
2731
|
+
in_progress: info
|
|
2732
|
+
done: success
|
|
2733
|
+
|
|
2734
|
+
priority_to_severity:
|
|
2735
|
+
low: neutral
|
|
2736
|
+
medium: info
|
|
2737
|
+
high: warning
|
|
2738
|
+
urgent: error
|
|
2739
|
+
```
|
|
2740
|
+
|
|
2741
|
+
### 10.6 Reactive update model
|
|
2742
|
+
|
|
2743
|
+
OpenUISpec uses a **pull-based reactive model**: when a data source or state value changes, all UI elements referencing it re-evaluate.
|
|
2744
|
+
|
|
2745
|
+
**Update triggers:**
|
|
2746
|
+
|
|
2747
|
+
| Trigger | What re-evaluates |
|
|
2748
|
+
|---------|------------------|
|
|
2749
|
+
| `set_state` action | All elements referencing the changed state path |
|
|
2750
|
+
| `api_call` success | The data source that was refreshed |
|
|
2751
|
+
| `refresh` action | The specified data source |
|
|
2752
|
+
| `data_binding` change | The bound state/form path + any elements referencing it |
|
|
2753
|
+
| Screen appear | All data sources with `refresh.on: [screen_appear]` |
|
|
2754
|
+
| Pull-to-refresh gesture | All data sources with `refresh.on: [pull_to_refresh]` |
|
|
2755
|
+
|
|
2756
|
+
**Reactive dependency chain:** If `state.active_filter` changes, and `data.tasks` depends on `state.active_filter` as a param, then:
|
|
2757
|
+
1. The state value updates
|
|
2758
|
+
2. `data.tasks` re-fetches with the new filter param
|
|
2759
|
+
3. The collection rendering `data.tasks` re-renders with new data
|
|
2760
|
+
4. Any derived values depending on `data.tasks` re-compute
|
|
2761
|
+
|
|
2762
|
+
### 10.7 Caching & refresh
|
|
2763
|
+
|
|
2764
|
+
Data sources can declare caching and refresh behavior:
|
|
2765
|
+
|
|
2766
|
+
```yaml
|
|
2767
|
+
data:
|
|
2768
|
+
tasks:
|
|
2769
|
+
source: "api.tasks.list"
|
|
2770
|
+
cache: none # always fetch fresh (default)
|
|
2771
|
+
refresh:
|
|
2772
|
+
on: [screen_appear, pull_to_refresh]
|
|
2773
|
+
|
|
2774
|
+
user:
|
|
2775
|
+
source: "api.auth.currentUser"
|
|
2776
|
+
cache: session # cached for the app session
|
|
2777
|
+
refresh:
|
|
2778
|
+
on: [explicit] # only refreshes when explicitly told to
|
|
2779
|
+
|
|
2780
|
+
config:
|
|
2781
|
+
source: "api.config.get"
|
|
2782
|
+
cache: persistent # survives app restart
|
|
2783
|
+
refresh:
|
|
2784
|
+
interval: 86400000 # refresh every 24 hours
|
|
2785
|
+
```
|
|
2786
|
+
|
|
2787
|
+
**Cache levels:**
|
|
2788
|
+
|
|
2789
|
+
| Level | Lifetime | Survives navigation | Survives restart |
|
|
2790
|
+
|-------|----------|-------------------|-----------------|
|
|
2791
|
+
| `none` | Per-render | No | No |
|
|
2792
|
+
| `screen` | While screen is mounted | No | No |
|
|
2793
|
+
| `session` | While app is running | Yes | No |
|
|
2794
|
+
| `persistent` | Indefinite | Yes | Yes |
|
|
2795
|
+
|
|
2796
|
+
### 10.8 Loading & error states
|
|
2797
|
+
|
|
2798
|
+
Every data source has implicit loading and error states. AI generators must handle these:
|
|
2799
|
+
|
|
2800
|
+
```yaml
|
|
2801
|
+
data:
|
|
2802
|
+
tasks:
|
|
2803
|
+
source: "api.tasks.list"
|
|
2804
|
+
# Implicit states:
|
|
2805
|
+
# tasks.$loading: bool (true while fetching)
|
|
2806
|
+
# tasks.$error: error object (non-null if fetch failed)
|
|
2807
|
+
# tasks.$empty: bool (true if fetch succeeded but returned empty array)
|
|
2808
|
+
```
|
|
2809
|
+
|
|
2810
|
+
These implicit states are available as data paths:
|
|
2811
|
+
|
|
2812
|
+
```yaml
|
|
2813
|
+
# Show loading skeleton
|
|
2814
|
+
condition: "tasks.$loading"
|
|
2815
|
+
|
|
2816
|
+
# Show error state
|
|
2817
|
+
condition: "tasks.$error"
|
|
2818
|
+
props:
|
|
2819
|
+
title: "Something went wrong"
|
|
2820
|
+
body: "{tasks.$error.message}"
|
|
2821
|
+
|
|
2822
|
+
# Show empty state
|
|
2823
|
+
condition: "tasks.$empty"
|
|
2824
|
+
```
|
|
2825
|
+
|
|
2826
|
+
Collection contracts handle `$loading`, `$error`, and `$empty` automatically via their state machines (see Section 4.7). For non-collection data, screens can use `condition:` to show appropriate UI.
|
|
2827
|
+
|
|
2828
|
+
### 10.9 AI generation requirements
|
|
2829
|
+
|
|
2830
|
+
**MUST:**
|
|
2831
|
+
- Resolve all data paths correctly, including nested traversal and array indexing
|
|
2832
|
+
- Implement two-way binding for `data_binding:` props
|
|
2833
|
+
- Re-evaluate UI when state or data changes (reactive model)
|
|
2834
|
+
- Handle `$loading`, `$error`, and `$empty` states for every data source
|
|
2835
|
+
- Implement all built-in formatters with correct locale behavior
|
|
2836
|
+
- Implement all built-in mappers (or generate them from project-defined maps)
|
|
2837
|
+
- Resolve all `$t:` references against the active locale file and pass `t_params` to ICU placeholders
|
|
2838
|
+
- Generate platform-native locale files from JSON source when `i18n` config is present (see Section 11)
|
|
2839
|
+
|
|
2840
|
+
**SHOULD:**
|
|
2841
|
+
- Implement caching at declared levels
|
|
2842
|
+
- Re-fetch data sources when their param dependencies change
|
|
2843
|
+
- Support computed/derived data sources
|
|
2844
|
+
- Debounce rapid state changes (e.g., search input) to avoid excessive re-renders
|
|
2845
|
+
- Show skeleton/loading states during data fetches
|
|
2846
|
+
|
|
2847
|
+
**MAY:**
|
|
2848
|
+
- Support offline data persistence with `cache: persistent`
|
|
2849
|
+
- Implement stale-while-revalidate patterns
|
|
2850
|
+
- Pre-fetch data for likely navigation targets
|
|
2851
|
+
|
|
2852
|
+
|
|
2853
|
+
---
|
|
2854
|
+
|
|
2855
|
+
## 11. Internationalization (i18n)
|
|
2856
|
+
|
|
2857
|
+
### 11.1 Overview
|
|
2858
|
+
|
|
2859
|
+
OpenUISpec treats **JSON locale files as the single source of truth** for all user-facing strings. Platform generators convert these files into native localization resources (iOS `.xcstrings`, Android `strings.xml` + `plurals.xml`, web JSON bundles). The spec defines the source format and string referencing mechanism; platforms handle locale detection and runtime switching.
|
|
2860
|
+
|
|
2861
|
+
Key principles:
|
|
2862
|
+
- All user-facing strings live in locale files, not in screen/flow YAML
|
|
2863
|
+
- YAML files reference strings via `$t:key` syntax
|
|
2864
|
+
- ICU MessageFormat handles plurals, selects, and interpolation within locale strings
|
|
2865
|
+
- One JSON file per locale (e.g., `en.json`, `es.json`, `ja.json`)
|
|
2866
|
+
|
|
2867
|
+
### 11.2 Locale file format
|
|
2868
|
+
|
|
2869
|
+
Locale files use **flat keys with dot-namespacing** and live in the `locales/` directory:
|
|
2870
|
+
|
|
2871
|
+
```json
|
|
2872
|
+
{
|
|
2873
|
+
"$locale": "en",
|
|
2874
|
+
"$direction": "ltr",
|
|
2875
|
+
|
|
2876
|
+
"nav.tasks": "Tasks",
|
|
2877
|
+
"nav.projects": "Projects",
|
|
2878
|
+
|
|
2879
|
+
"home.task_count": "{count, plural, =0 {No tasks today} one {# task today} other {# tasks today}}",
|
|
2880
|
+
"home.greeting.morning": "Good morning, {name}",
|
|
2881
|
+
|
|
2882
|
+
"task_detail.toggle_status": "{is_done, select, true {Reopen task} other {Mark complete}}",
|
|
2883
|
+
"task_detail.delete_message": "This action cannot be undone. The task \"{title}\" will be permanently removed.",
|
|
2884
|
+
|
|
2885
|
+
"common.cancel": "Cancel",
|
|
2886
|
+
"common.delete": "Delete"
|
|
2887
|
+
}
|
|
2888
|
+
```
|
|
2889
|
+
|
|
2890
|
+
**Metadata keys** (prefixed with `$`):
|
|
2891
|
+
- `$locale` — BCP 47 language tag (e.g., `"en"`, `"es"`, `"ja"`)
|
|
2892
|
+
- `$direction` — `"ltr"` or `"rtl"`
|
|
2893
|
+
|
|
2894
|
+
**String values** use [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) for:
|
|
2895
|
+
- **Plurals:** `{count, plural, =0 {No items} one {# item} other {# items}}`
|
|
2896
|
+
- **Selects:** `{status, select, active {Active} archived {Archived} other {Unknown}}`
|
|
2897
|
+
- **Simple interpolation:** `{name}` placeholder filled from `t_params`
|
|
2898
|
+
|
|
2899
|
+
**Key conventions:**
|
|
2900
|
+
- Flat, dot-namespaced: `screen_name.element` (not nested JSON objects)
|
|
2901
|
+
- Grouped by screen/flow: `home.*`, `task_detail.*`, `create_task.*`
|
|
2902
|
+
- Shared strings under `common.*`
|
|
2903
|
+
- Enum labels under their enum name: `priority.*`, `status.*`
|
|
2904
|
+
|
|
2905
|
+
### 11.3 String references
|
|
2906
|
+
|
|
2907
|
+
YAML files reference locale strings with the `$t:` prefix:
|
|
2908
|
+
|
|
2909
|
+
```yaml
|
|
2910
|
+
# Simple reference
|
|
2911
|
+
label: "$t:common.cancel"
|
|
2912
|
+
|
|
2913
|
+
# Reference with ICU parameters
|
|
2914
|
+
subtitle: "$t:home.task_count"
|
|
2915
|
+
t_params:
|
|
2916
|
+
count: "task_counts.today"
|
|
2917
|
+
|
|
2918
|
+
# Dynamic key (using format expression in the key path)
|
|
2919
|
+
title: "$t:home.greeting.{time_of_day | format:greeting}"
|
|
2920
|
+
t_params:
|
|
2921
|
+
name: "user.first_name"
|
|
2922
|
+
```
|
|
2923
|
+
|
|
2924
|
+
**Rules:**
|
|
2925
|
+
- `$t:key` is a standalone string value — it cannot be mixed with other text in the same string
|
|
2926
|
+
- `t_params` is a sibling property of the `$t:` string, mapping ICU placeholder names to data paths
|
|
2927
|
+
- `t_params` values are data paths (see Section 10.3), resolved at runtime
|
|
2928
|
+
- Formatter mappings may also reference locale keys: `mapping: { todo: "$t:status.todo" }`
|
|
2929
|
+
- **Dynamic key validation:** When a locale key contains interpolation segments (e.g., `$t:home.greeting.{time_of_day | format:greeting}`), the key is resolved at runtime. Static validation tools should expand all possible values from the formatter mapping and verify that each resolved key exists in the locale file (e.g., `home.greeting.morning`, `home.greeting.afternoon`, `home.greeting.evening`).
|
|
2930
|
+
|
|
2931
|
+
### 11.4 Formatter localization
|
|
2932
|
+
|
|
2933
|
+
Custom formatters defined in `openuispec.yaml` can reference locale keys in their mappings:
|
|
2934
|
+
|
|
2935
|
+
```yaml
|
|
2936
|
+
formatters:
|
|
2937
|
+
status_label:
|
|
2938
|
+
input: enum
|
|
2939
|
+
output: string
|
|
2940
|
+
mapping:
|
|
2941
|
+
todo: "$t:status.todo"
|
|
2942
|
+
in_progress: "$t:status.in_progress"
|
|
2943
|
+
done: "$t:status.done"
|
|
2944
|
+
```
|
|
2945
|
+
|
|
2946
|
+
This keeps display labels localized while preserving the enum-to-string mapping structure. Built-in locale-aware formatters (`currency`, `date`, `number`, `pluralize`) use the active locale automatically.
|
|
2947
|
+
|
|
2948
|
+
### 11.5 Layout direction
|
|
2949
|
+
|
|
2950
|
+
OpenUISpec already uses **logical directions** (`leading`/`trailing` instead of `left`/`right`). For RTL locales:
|
|
2951
|
+
|
|
2952
|
+
1. Declare direction in the locale file: `"$direction": "rtl"`
|
|
2953
|
+
2. The `i18n` config in the manifest declares which locales are supported
|
|
2954
|
+
3. Platform generators map logical directions to physical directions based on the active locale
|
|
2955
|
+
|
|
2956
|
+
```yaml
|
|
2957
|
+
# Already in the spec — no changes needed
|
|
2958
|
+
icon: { ref: "search", position: leading } # leading = left in LTR, right in RTL
|
|
2959
|
+
trailing: { icon: "chevron_right" } # trailing = right in LTR, left in RTL
|
|
2960
|
+
```
|
|
2961
|
+
|
|
2962
|
+
**Platform mapping:**
|
|
2963
|
+
| Logical | LTR physical | RTL physical |
|
|
2964
|
+
|---------|-------------|-------------|
|
|
2965
|
+
| `leading` | `left` / `start` | `right` / `end` |
|
|
2966
|
+
| `trailing` | `right` / `end` | `left` / `start` |
|
|
2967
|
+
| `floating-bottom-trailing` | Bottom-right | Bottom-left |
|
|
2968
|
+
|
|
2969
|
+
### 11.6 Platform mapping
|
|
2970
|
+
|
|
2971
|
+
Generators produce platform-native localization resources from the JSON source:
|
|
2972
|
+
|
|
2973
|
+
| Platform | Output format | Plurals | Notes |
|
|
2974
|
+
|----------|--------------|---------|-------|
|
|
2975
|
+
| **iOS** | `.xcstrings` (Xcode 15+) | Built-in plural rules | ICU plurals map to `.stringsdict` entries within `.xcstrings` |
|
|
2976
|
+
| **Android** | `res/values-{locale}/strings.xml` + `plurals.xml` | `<plurals>` element | ICU selects map to conditional logic in generated code |
|
|
2977
|
+
| **Web** | JSON bundles per locale | `react-intl` / `i18next` ICU plugin | Direct ICU MessageFormat — no conversion needed |
|
|
2978
|
+
|
|
2979
|
+
The `i18n` config in the project manifest controls generation:
|
|
2980
|
+
|
|
2981
|
+
```yaml
|
|
2982
|
+
i18n:
|
|
2983
|
+
default_locale: "en"
|
|
2984
|
+
supported_locales: [en, es, ja, ar]
|
|
2985
|
+
fallback_strategy: "default" # fall back to default_locale for missing keys
|
|
2986
|
+
```
|
|
2987
|
+
|
|
2988
|
+
### 11.7 AI generation requirements
|
|
2989
|
+
|
|
2990
|
+
**MUST:**
|
|
2991
|
+
- Resolve every `$t:key` reference to the corresponding locale string
|
|
2992
|
+
- Generate platform-native locale files from JSON sources for each supported locale
|
|
2993
|
+
- Pass `t_params` data paths to ICU placeholders at runtime
|
|
2994
|
+
- Apply `$direction` from the active locale to layout direction
|
|
2995
|
+
- Use the `fallback_strategy` for missing keys (default: fall back to `default_locale`)
|
|
2996
|
+
|
|
2997
|
+
**SHOULD:**
|
|
2998
|
+
- Validate that all `$t:` keys in YAML files exist in every supported locale file
|
|
2999
|
+
- Warn when a locale file has keys not referenced by any YAML file
|
|
3000
|
+
- Support locale switching at runtime without app restart (hot swap)
|
|
3001
|
+
- Use platform-native locale detection for initial locale selection
|
|
3002
|
+
|
|
3003
|
+
**MAY:**
|
|
3004
|
+
- Support right-to-left layout preview in development tools
|
|
3005
|
+
- Generate translation key extraction reports
|
|
3006
|
+
- Support pluralization categories beyond `=0`, `one`, `other` (e.g., `two`, `few`, `many` for languages that need them)
|
|
3007
|
+
- Support nested ICU messages (selects within plurals)
|
|
3008
|
+
|
|
3009
|
+
---
|
|
3010
|
+
|
|
3011
|
+
## 12. Custom contract extensions
|
|
3012
|
+
|
|
3013
|
+
Custom contracts allow spec authors to define domain-specific component families — media players, charts, maps, code editors — that follow the same anatomy as built-in contracts but are not part of the core spec. The 7 built-in families cover general UI; custom contracts handle specialized, app-specific components.
|
|
3014
|
+
|
|
3015
|
+
### 12.1 Purpose
|
|
3016
|
+
|
|
3017
|
+
Use a custom contract when the component:
|
|
3018
|
+
|
|
3019
|
+
- Has domain-specific behavior that doesn't map cleanly to any built-in family
|
|
3020
|
+
- Requires a dedicated state machine (e.g., play/pause/seek for media)
|
|
3021
|
+
- Needs platform-specific libraries or frameworks not covered by core contracts
|
|
3022
|
+
- Would clutter the core spec if included as a built-in
|
|
3023
|
+
|
|
3024
|
+
Do **not** use a custom contract when a built-in family with the right variant already covers the use case. A data card is `data_display`, not `x_data_card`.
|
|
3025
|
+
|
|
3026
|
+
### 12.2 Naming
|
|
3027
|
+
|
|
3028
|
+
Custom contract names **MUST**:
|
|
3029
|
+
|
|
3030
|
+
- Start with the `x_` prefix
|
|
3031
|
+
- Use lowercase `snake_case`
|
|
3032
|
+
- Be descriptive of the component's role: `x_media_player`, `x_chart`, `x_code_editor`, `x_map_view`
|
|
3033
|
+
|
|
3034
|
+
The `x_` prefix is reserved for custom contracts. Future spec versions will never define built-in families starting with `x_`.
|
|
3035
|
+
|
|
3036
|
+
### 12.3 Contract definition format
|
|
3037
|
+
|
|
3038
|
+
Custom contracts follow the same anatomy as built-in contracts (Section 4):
|
|
3039
|
+
|
|
3040
|
+
```yaml
|
|
3041
|
+
# contracts/x_media_player.yaml
|
|
3042
|
+
x_media_player:
|
|
3043
|
+
semantic: "Plays audio and video media with transport controls"
|
|
3044
|
+
|
|
3045
|
+
props:
|
|
3046
|
+
source: { type: string, required: true }
|
|
3047
|
+
media_type: { type: enum, values: [audio, video], required: true }
|
|
3048
|
+
variant: { type: enum, values: [inline, fullscreen, mini], default: inline }
|
|
3049
|
+
autoplay: { type: bool, default: false }
|
|
3050
|
+
# ... additional props
|
|
3051
|
+
|
|
3052
|
+
states:
|
|
3053
|
+
idle:
|
|
3054
|
+
transitions_to: [loading]
|
|
3055
|
+
visual: "Shows poster or placeholder"
|
|
3056
|
+
loading:
|
|
3057
|
+
transitions_to: [playing, error]
|
|
3058
|
+
feedback: "Loading indicator visible"
|
|
3059
|
+
playing:
|
|
3060
|
+
transitions_to: [paused, ended, loading, error]
|
|
3061
|
+
paused:
|
|
3062
|
+
transitions_to: [playing, loading]
|
|
3063
|
+
ended:
|
|
3064
|
+
transitions_to: [playing, loading]
|
|
3065
|
+
error:
|
|
3066
|
+
transitions_to: [loading]
|
|
3067
|
+
feedback: "Error message with retry"
|
|
3068
|
+
|
|
3069
|
+
a11y:
|
|
3070
|
+
role: "media"
|
|
3071
|
+
label: "props.title"
|
|
3072
|
+
focus:
|
|
3073
|
+
keyboard:
|
|
3074
|
+
play_pause: "Space"
|
|
3075
|
+
seek_forward: "ArrowRight"
|
|
3076
|
+
|
|
3077
|
+
tokens:
|
|
3078
|
+
inline:
|
|
3079
|
+
min_height: [200, 280]
|
|
3080
|
+
radius: "spacing.md"
|
|
3081
|
+
# ... variant-specific tokens
|
|
3082
|
+
|
|
3083
|
+
platform_mapping:
|
|
3084
|
+
ios:
|
|
3085
|
+
inline: { component: "VideoPlayer", framework: "AVKit" }
|
|
3086
|
+
android:
|
|
3087
|
+
inline: { component: "PlayerView", library: "androidx.media3" }
|
|
3088
|
+
web:
|
|
3089
|
+
inline: { element: "video" }
|
|
3090
|
+
|
|
3091
|
+
# -- Fields unique to custom contracts --
|
|
3092
|
+
|
|
3093
|
+
dependencies:
|
|
3094
|
+
ios:
|
|
3095
|
+
frameworks: [AVKit, AVFoundation]
|
|
3096
|
+
android:
|
|
3097
|
+
libraries: ["androidx.media3:media3-ui", "androidx.media3:media3-exoplayer"]
|
|
3098
|
+
web:
|
|
3099
|
+
packages: []
|
|
3100
|
+
|
|
3101
|
+
generation:
|
|
3102
|
+
must_handle:
|
|
3103
|
+
- "All declared states with correct transitions"
|
|
3104
|
+
- "Keyboard shortcuts for accessibility"
|
|
3105
|
+
should_handle:
|
|
3106
|
+
- "Progress bar with seek"
|
|
3107
|
+
may_handle:
|
|
3108
|
+
- "Picture-in-picture"
|
|
3109
|
+
|
|
3110
|
+
test_cases:
|
|
3111
|
+
- id: play_pause_toggle
|
|
3112
|
+
description: "Play/pause toggles playback state"
|
|
3113
|
+
given: "Player is in idle state with a valid source"
|
|
3114
|
+
when: "User taps play"
|
|
3115
|
+
then: "State transitions to loading, then playing"
|
|
3116
|
+
```
|
|
3117
|
+
|
|
3118
|
+
**Required fields:** `semantic`, `props`, `states`, `a11y`, `tokens`, `platform_mapping`
|
|
3119
|
+
|
|
3120
|
+
**Optional fields:** `dependencies`, `generation`, `test_cases`
|
|
3121
|
+
|
|
3122
|
+
The `dependencies` field is unique to custom contracts — since custom components may require platform-specific libraries that are not part of the standard framework imports.
|
|
3123
|
+
|
|
3124
|
+
### 12.4 Registration
|
|
3125
|
+
|
|
3126
|
+
Custom contracts are registered in the root manifest via the `custom_contracts` array:
|
|
3127
|
+
|
|
3128
|
+
```yaml
|
|
3129
|
+
# openuispec.yaml
|
|
3130
|
+
spec_version: "0.1"
|
|
3131
|
+
project:
|
|
3132
|
+
name: "MyApp"
|
|
3133
|
+
|
|
3134
|
+
custom_contracts:
|
|
3135
|
+
- "./contracts/x_media_player.yaml"
|
|
3136
|
+
- "./contracts/x_chart.yaml"
|
|
3137
|
+
|
|
3138
|
+
includes:
|
|
3139
|
+
tokens: "./tokens/"
|
|
3140
|
+
contracts: "./contracts/"
|
|
3141
|
+
# ...
|
|
3142
|
+
```
|
|
3143
|
+
|
|
3144
|
+
Each entry is a path (relative to the manifest) to a custom contract YAML file. The file must contain exactly one root key matching the `x_` naming convention.
|
|
3145
|
+
|
|
3146
|
+
### 12.5 Usage in screens
|
|
3147
|
+
|
|
3148
|
+
Custom contracts are used in screens identically to built-in contracts:
|
|
3149
|
+
|
|
3150
|
+
```yaml
|
|
3151
|
+
# screens/task_detail.yaml
|
|
3152
|
+
- contract: x_media_player
|
|
3153
|
+
variant: inline
|
|
3154
|
+
props:
|
|
3155
|
+
source: "{task.attachment.url}"
|
|
3156
|
+
media_type: "{task.attachment.media_type}"
|
|
3157
|
+
title: "{task.attachment.filename}"
|
|
3158
|
+
show_controls: true
|
|
3159
|
+
tokens_override:
|
|
3160
|
+
radius: "spacing.md"
|
|
3161
|
+
adaptive:
|
|
3162
|
+
compact: { variant: inline }
|
|
3163
|
+
expanded: { variant: inline, max_width: 640 }
|
|
3164
|
+
```
|
|
3165
|
+
|
|
3166
|
+
The `contract` field accepts both built-in family names (`action_trigger`, `data_display`, etc.) and custom contract names (`x_media_player`, `x_chart`).
|
|
3167
|
+
|
|
3168
|
+
### 12.6 Platform overrides
|
|
3169
|
+
|
|
3170
|
+
Custom contracts support the same platform override pattern as built-in families (Section 7):
|
|
3171
|
+
|
|
3172
|
+
```yaml
|
|
3173
|
+
# platform/ios.yaml
|
|
3174
|
+
ios:
|
|
3175
|
+
overrides:
|
|
3176
|
+
x_media_player:
|
|
3177
|
+
inline:
|
|
3178
|
+
uses_native_player: true
|
|
3179
|
+
pip_enabled: true
|
|
3180
|
+
```
|
|
3181
|
+
|
|
3182
|
+
### 12.7 AI generation requirements
|
|
3183
|
+
|
|
3184
|
+
**MUST:**
|
|
3185
|
+
- Read and parse all registered custom contract definitions before generating code
|
|
3186
|
+
- Handle every declared state in the state machine
|
|
3187
|
+
- Apply `platform_mapping` to select the correct native component
|
|
3188
|
+
- Include all `dependencies` in the generated project configuration (Package.swift, build.gradle, package.json)
|
|
3189
|
+
- Implement all items listed in `generation.must_handle`
|
|
3190
|
+
|
|
3191
|
+
**SHOULD:**
|
|
3192
|
+
- Implement items listed in `generation.should_handle`
|
|
3193
|
+
- Apply token bindings from the `tokens` section per variant
|
|
3194
|
+
- Generate accessibility support matching the `a11y` definition
|
|
3195
|
+
|
|
3196
|
+
**MAY:**
|
|
3197
|
+
- Implement items listed in `generation.may_handle`
|
|
3198
|
+
- Generate test code based on `test_cases`
|
|
3199
|
+
- Add platform-specific enhancements beyond what the contract specifies
|
|
3200
|
+
|
|
3201
|
+
---
|
|
3202
|
+
|
|
3203
|
+
## 13. Form validation and field dependencies
|
|
3204
|
+
|
|
3205
|
+
Form validation extends the `input_field` contract with co-located validation rules, field dependencies, and i18n-integrated error messages. Rules are declared inline on each field — there are no separate validation files.
|
|
3206
|
+
|
|
3207
|
+
### 13.1 Overview
|
|
3208
|
+
|
|
3209
|
+
Existing shorthand properties (`required`, `max_length`, `input_type: email`) continue to work unchanged. The `validate` block adds structured rules with explicit error messages and i18n support. Both can coexist on the same field — shorthands are checked first, then `validate` rules in declaration order.
|
|
3210
|
+
|
|
3211
|
+
```yaml
|
|
3212
|
+
- contract: input_field
|
|
3213
|
+
input_type: text
|
|
3214
|
+
props:
|
|
3215
|
+
label: "Username"
|
|
3216
|
+
required: true # shorthand — still works
|
|
3217
|
+
validate: # structured rules — additive
|
|
3218
|
+
min_length: { value: 3, message: "$t:validation.min_length" }
|
|
3219
|
+
pattern: { regex: "^[a-z0-9_]+$", message: "Lowercase letters, numbers, and underscores only" }
|
|
3220
|
+
async: { endpoint: "api.users.check_username", debounce: 500, message: "Username already taken" }
|
|
3221
|
+
data_binding: "form.username"
|
|
3222
|
+
```
|
|
3223
|
+
|
|
3224
|
+
### 13.2 Validation rules
|
|
3225
|
+
|
|
3226
|
+
Each rule is an object with a value/config and an optional `message`. If `message` is omitted, the platform uses a sensible default. Messages support `$t:` locale references (Section 11).
|
|
3227
|
+
|
|
3228
|
+
#### `pattern`
|
|
3229
|
+
|
|
3230
|
+
Validates the field value against a regular expression.
|
|
3231
|
+
|
|
3232
|
+
```yaml
|
|
3233
|
+
validate:
|
|
3234
|
+
pattern:
|
|
3235
|
+
regex: "^[A-Z]{2}\\d{6}$"
|
|
3236
|
+
message: "$t:validation.pattern"
|
|
3237
|
+
```
|
|
3238
|
+
|
|
3239
|
+
The `regex` value is a standard regular expression string. Backslashes must be escaped in YAML (`\\d` for `\d`).
|
|
3240
|
+
|
|
3241
|
+
#### `min_length`
|
|
3242
|
+
|
|
3243
|
+
Validates minimum string length.
|
|
3244
|
+
|
|
3245
|
+
```yaml
|
|
3246
|
+
validate:
|
|
3247
|
+
min_length:
|
|
3248
|
+
value: 3
|
|
3249
|
+
message: "$t:validation.min_length" # receives {min} placeholder
|
|
3250
|
+
```
|
|
3251
|
+
|
|
3252
|
+
#### `max_length`
|
|
3253
|
+
|
|
3254
|
+
Validates maximum string length. This is the structured equivalent of the `max_length` prop shorthand.
|
|
3255
|
+
|
|
3256
|
+
```yaml
|
|
3257
|
+
validate:
|
|
3258
|
+
max_length:
|
|
3259
|
+
value: 200
|
|
3260
|
+
message: "$t:validation.max_length" # receives {max} placeholder
|
|
3261
|
+
```
|
|
3262
|
+
|
|
3263
|
+
When both `props.max_length` and `validate.max_length` are present, the `validate` version takes precedence (its message is used for errors).
|
|
3264
|
+
|
|
3265
|
+
#### `min`
|
|
3266
|
+
|
|
3267
|
+
Validates minimum numeric value. Use with `input_type: number`, `slider`, or `stepper`.
|
|
3268
|
+
|
|
3269
|
+
```yaml
|
|
3270
|
+
validate:
|
|
3271
|
+
min:
|
|
3272
|
+
value: 0
|
|
3273
|
+
message: "$t:validation.min_value" # receives {min} placeholder
|
|
3274
|
+
```
|
|
3275
|
+
|
|
3276
|
+
#### `max`
|
|
3277
|
+
|
|
3278
|
+
Validates maximum numeric value.
|
|
3279
|
+
|
|
3280
|
+
```yaml
|
|
3281
|
+
validate:
|
|
3282
|
+
max:
|
|
3283
|
+
value: 100
|
|
3284
|
+
message: "$t:validation.max_value" # receives {max} placeholder
|
|
3285
|
+
```
|
|
3286
|
+
|
|
3287
|
+
#### `format`
|
|
3288
|
+
|
|
3289
|
+
Validates against a built-in format type. This is the structured equivalent of `input_type: email` implicit validation, extended to more formats.
|
|
3290
|
+
|
|
3291
|
+
```yaml
|
|
3292
|
+
validate:
|
|
3293
|
+
format:
|
|
3294
|
+
type: email # email | url | phone | uuid
|
|
3295
|
+
message: "$t:validation.format.email"
|
|
3296
|
+
```
|
|
3297
|
+
|
|
3298
|
+
| Format | Validates |
|
|
3299
|
+
|--------|-----------|
|
|
3300
|
+
| `email` | Standard email format |
|
|
3301
|
+
| `url` | Valid URL with scheme |
|
|
3302
|
+
| `phone` | E.164 or common phone formats |
|
|
3303
|
+
| `uuid` | UUID v4 format |
|
|
3304
|
+
|
|
3305
|
+
#### `match_field`
|
|
3306
|
+
|
|
3307
|
+
Cross-field equality check. The `field` value is the `data_binding` path of the field to match.
|
|
3308
|
+
|
|
3309
|
+
```yaml
|
|
3310
|
+
# Password confirmation example
|
|
3311
|
+
- contract: input_field
|
|
3312
|
+
input_type: password
|
|
3313
|
+
props: { label: "Password" }
|
|
3314
|
+
data_binding: "form.password"
|
|
3315
|
+
|
|
3316
|
+
- contract: input_field
|
|
3317
|
+
input_type: password
|
|
3318
|
+
props: { label: "Confirm password" }
|
|
3319
|
+
validate:
|
|
3320
|
+
match_field:
|
|
3321
|
+
field: "form.password"
|
|
3322
|
+
message: "$t:validation.match_field"
|
|
3323
|
+
data_binding: "form.password_confirm"
|
|
3324
|
+
```
|
|
3325
|
+
|
|
3326
|
+
#### `custom`
|
|
3327
|
+
|
|
3328
|
+
Expression-based validation using the same condition grammar as `condition` (Section 5). The expression has access to the current field value as `$value` and the full form data via `form.*` paths.
|
|
3329
|
+
|
|
3330
|
+
```yaml
|
|
3331
|
+
validate:
|
|
3332
|
+
custom:
|
|
3333
|
+
expression: "form.end_date > form.start_date"
|
|
3334
|
+
message: "End date must be after start date"
|
|
3335
|
+
```
|
|
3336
|
+
|
|
3337
|
+
#### `async`
|
|
3338
|
+
|
|
3339
|
+
Server-side validation via an API call. The endpoint receives the field value and returns a boolean or error message.
|
|
3340
|
+
|
|
3341
|
+
```yaml
|
|
3342
|
+
validate:
|
|
3343
|
+
async:
|
|
3344
|
+
endpoint: "api.users.check_username" # same format as api_call endpoint
|
|
3345
|
+
debounce: 500 # ms, default 300
|
|
3346
|
+
message: "Username already taken"
|
|
3347
|
+
```
|
|
3348
|
+
|
|
3349
|
+
**Async behavior:**
|
|
3350
|
+
1. After the debounce period, the platform sends the field value to the endpoint
|
|
3351
|
+
2. While waiting, the field shows a loading indicator (the `validating` state)
|
|
3352
|
+
3. The endpoint returns `{ valid: true }` or `{ valid: false, message?: "..." }`
|
|
3353
|
+
4. If the endpoint returns a message, it overrides the local `message`
|
|
3354
|
+
|
|
3355
|
+
### 13.3 Validation messages
|
|
3356
|
+
|
|
3357
|
+
Every rule accepts an optional `message` property:
|
|
3358
|
+
|
|
3359
|
+
- **Plain string:** `"Must be at least 3 characters"`
|
|
3360
|
+
- **Locale reference:** `"$t:validation.min_length"` — resolved via the i18n system (Section 11)
|
|
3361
|
+
- **With placeholders:** Locale strings can use ICU MessageFormat. The validation system passes rule parameters as named values: `{min}`, `{max}`, `{value}`, `{field}`.
|
|
3362
|
+
|
|
3363
|
+
```json
|
|
3364
|
+
{
|
|
3365
|
+
"validation.min_length": "Must be at least {min} characters",
|
|
3366
|
+
"validation.max_length": "Must be no more than {max} characters",
|
|
3367
|
+
"validation.min_value": "Must be at least {min}",
|
|
3368
|
+
"validation.max_value": "Must be no more than {max}"
|
|
3369
|
+
}
|
|
3370
|
+
```
|
|
3371
|
+
|
|
3372
|
+
If no `message` is provided, the platform uses a built-in default appropriate to the rule type and locale.
|
|
3373
|
+
|
|
3374
|
+
### 13.4 Validation triggers
|
|
3375
|
+
|
|
3376
|
+
The `validate_trigger` property controls when validation runs for a field:
|
|
3377
|
+
|
|
3378
|
+
| Trigger | Behavior | Use case |
|
|
3379
|
+
|---------|----------|----------|
|
|
3380
|
+
| `on_blur` (default) | Validates when the field loses focus | Most text fields |
|
|
3381
|
+
| `on_change` | Validates on every value change | Real-time feedback (e.g., password strength) |
|
|
3382
|
+
| `on_submit` | Validates only when the form is submitted | Fields where intermediate states are valid |
|
|
3383
|
+
|
|
3384
|
+
```yaml
|
|
3385
|
+
- contract: input_field
|
|
3386
|
+
input_type: text
|
|
3387
|
+
props:
|
|
3388
|
+
label: "Username"
|
|
3389
|
+
required: true
|
|
3390
|
+
validate:
|
|
3391
|
+
pattern: { regex: "^[a-z0-9_]+$", message: "Lowercase letters and numbers only" }
|
|
3392
|
+
validate_trigger: on_change # validate on every keystroke
|
|
3393
|
+
data_binding: "form.username"
|
|
3394
|
+
```
|
|
3395
|
+
|
|
3396
|
+
If not specified, `on_blur` is the default. The `submit_form` action always runs all validation rules regardless of trigger setting.
|
|
3397
|
+
|
|
3398
|
+
### 13.5 Field dependencies
|
|
3399
|
+
|
|
3400
|
+
Field dependencies control whether a field is required or enabled based on the values of other fields.
|
|
3401
|
+
|
|
3402
|
+
#### Visibility: `condition`
|
|
3403
|
+
|
|
3404
|
+
The existing `condition` property (Section 5) already handles field visibility. No new syntax is needed:
|
|
3405
|
+
|
|
3406
|
+
```yaml
|
|
3407
|
+
# Show "Other" text field only when category is "other"
|
|
3408
|
+
- contract: input_field
|
|
3409
|
+
input_type: select
|
|
3410
|
+
props: { label: "Category", options: [...] }
|
|
3411
|
+
data_binding: "form.category"
|
|
3412
|
+
|
|
3413
|
+
- contract: input_field
|
|
3414
|
+
input_type: text
|
|
3415
|
+
props: { label: "Please specify" }
|
|
3416
|
+
condition: "form.category == 'other'"
|
|
3417
|
+
data_binding: "form.category_other"
|
|
3418
|
+
```
|
|
3419
|
+
|
|
3420
|
+
#### Conditional requirement: `required_when`
|
|
3421
|
+
|
|
3422
|
+
Makes a field required based on a condition expression:
|
|
3423
|
+
|
|
3424
|
+
```yaml
|
|
3425
|
+
- contract: input_field
|
|
3426
|
+
input_type: text
|
|
3427
|
+
props: { label: "Company name" }
|
|
3428
|
+
required_when: "form.account_type == 'business'"
|
|
3429
|
+
data_binding: "form.company_name"
|
|
3430
|
+
```
|
|
3431
|
+
|
|
3432
|
+
When the condition is true, the field behaves as if `required: true` is set. When false, the field is optional. The condition uses the same expression grammar as `condition`.
|
|
3433
|
+
|
|
3434
|
+
#### Conditional enablement: `enabled_when`
|
|
3435
|
+
|
|
3436
|
+
Controls whether a field is editable:
|
|
3437
|
+
|
|
3438
|
+
```yaml
|
|
3439
|
+
- contract: input_field
|
|
3440
|
+
input_type: date
|
|
3441
|
+
props: { label: "Custom date" }
|
|
3442
|
+
enabled_when: "form.schedule_type == 'custom'"
|
|
3443
|
+
data_binding: "form.custom_date"
|
|
3444
|
+
```
|
|
3445
|
+
|
|
3446
|
+
When the condition is false, the field is rendered in a disabled state — visible but not interactive.
|
|
3447
|
+
|
|
3448
|
+
### 13.6 Cross-field validation
|
|
3449
|
+
|
|
3450
|
+
Two mechanisms support validation that depends on multiple fields:
|
|
3451
|
+
|
|
3452
|
+
**`match_field`** — for simple equality checks (see 13.2):
|
|
3453
|
+
|
|
3454
|
+
```yaml
|
|
3455
|
+
validate:
|
|
3456
|
+
match_field:
|
|
3457
|
+
field: "form.password"
|
|
3458
|
+
message: "$t:validation.match_field"
|
|
3459
|
+
```
|
|
3460
|
+
|
|
3461
|
+
**`custom` expression** — for arbitrary cross-field logic:
|
|
3462
|
+
|
|
3463
|
+
```yaml
|
|
3464
|
+
validate:
|
|
3465
|
+
custom:
|
|
3466
|
+
expression: "form.max_budget >= form.min_budget"
|
|
3467
|
+
message: "Maximum budget must be at least the minimum"
|
|
3468
|
+
```
|
|
3469
|
+
|
|
3470
|
+
Cross-field validation rules are re-evaluated whenever any referenced field changes (not just the field they're declared on). The platform tracks dependencies by parsing the expression paths.
|
|
3471
|
+
|
|
3472
|
+
### 13.7 Async validation
|
|
3473
|
+
|
|
3474
|
+
Async validation calls a server endpoint to validate a field value. Common uses: username availability, invite code validation, address verification.
|
|
3475
|
+
|
|
3476
|
+
```yaml
|
|
3477
|
+
- contract: input_field
|
|
3478
|
+
input_type: text
|
|
3479
|
+
props: { label: "Username" }
|
|
3480
|
+
validate:
|
|
3481
|
+
min_length: { value: 3, message: "$t:validation.min_length" }
|
|
3482
|
+
async:
|
|
3483
|
+
endpoint: "api.users.check_username"
|
|
3484
|
+
debounce: 500
|
|
3485
|
+
message: "$t:validation.username_taken"
|
|
3486
|
+
validate_trigger: on_change
|
|
3487
|
+
data_binding: "form.username"
|
|
3488
|
+
```
|
|
3489
|
+
|
|
3490
|
+
**Lifecycle:**
|
|
3491
|
+
|
|
3492
|
+
1. **Debounce** — after the user stops typing for `debounce` ms (default 300), the request fires
|
|
3493
|
+
2. **Loading** — the field enters the `validating` state; platforms SHOULD show a spinner or "Checking…" text
|
|
3494
|
+
3. **Response** — the endpoint returns `{ valid: true }` or `{ valid: false, message?: "..." }`
|
|
3495
|
+
4. **Result** — if invalid, the field enters `error` state with the message; if valid, the field clears any error
|
|
3496
|
+
|
|
3497
|
+
Sync rules (pattern, min_length, etc.) run first. Async validation only fires if all sync rules pass.
|
|
3498
|
+
|
|
3499
|
+
### 13.8 `submit_form` enhancements
|
|
3500
|
+
|
|
3501
|
+
The `submit_form` action (Section 9) is extended with two optional properties:
|
|
3502
|
+
|
|
3503
|
+
#### `validate_only`
|
|
3504
|
+
|
|
3505
|
+
When `true`, runs all validation rules but does **not** trigger the form's `on_submit:` handler. Useful for multi-step forms where you validate one step before advancing:
|
|
3506
|
+
|
|
3507
|
+
```yaml
|
|
3508
|
+
action:
|
|
3509
|
+
type: submit_form
|
|
3510
|
+
form_id: "step_1_form"
|
|
3511
|
+
validate_only: true
|
|
3512
|
+
```
|
|
3513
|
+
|
|
3514
|
+
#### `on_validation_error`
|
|
3515
|
+
|
|
3516
|
+
An action to execute when validation fails. Runs after field-level error states are set:
|
|
3517
|
+
|
|
3518
|
+
```yaml
|
|
3519
|
+
action:
|
|
3520
|
+
type: submit_form
|
|
3521
|
+
form_id: "checkout_form"
|
|
3522
|
+
on_validation_error:
|
|
3523
|
+
type: feedback
|
|
3524
|
+
variant: toast
|
|
3525
|
+
message: "$t:validation.fix_errors"
|
|
3526
|
+
severity: warning
|
|
3527
|
+
```
|
|
3528
|
+
|
|
3529
|
+
**Full validation flow:**
|
|
3530
|
+
|
|
3531
|
+
1. Collect all fields in the form (matched by `form_id`)
|
|
3532
|
+
2. For each field, evaluate `required_when` → determine if required
|
|
3533
|
+
3. Check shorthand rules: `required`, `max_length`, `input_type` format
|
|
3534
|
+
4. Check `validate` block rules in declaration order
|
|
3535
|
+
5. For any failed field, set its state to `error` with `error_text`
|
|
3536
|
+
6. If any field failed and `on_validation_error` is set, execute it
|
|
3537
|
+
7. If `validate_only: true`, stop
|
|
3538
|
+
8. If all fields passed, execute the form's `on_submit:` handler
|
|
3539
|
+
|
|
3540
|
+
### 13.9 AI generation requirements
|
|
3541
|
+
|
|
3542
|
+
**MUST:**
|
|
3543
|
+
- Implement all `validate` rules declared on `input_field` instances
|
|
3544
|
+
- Evaluate `required_when` and `enabled_when` expressions reactively
|
|
3545
|
+
- Show `error_text` on fields that fail validation
|
|
3546
|
+
- Run all validation rules on `submit_form`, regardless of `validate_trigger`
|
|
3547
|
+
- Support `$t:` locale references in `message` properties
|
|
3548
|
+
- Respect validation rule order: shorthands first, then `validate` rules in declaration order
|
|
3549
|
+
|
|
3550
|
+
**SHOULD:**
|
|
3551
|
+
- Implement `validate_trigger` per field (default: `on_blur`)
|
|
3552
|
+
- Re-evaluate cross-field rules (`match_field`, `custom` with external paths) when referenced fields change
|
|
3553
|
+
- Show a loading indicator during `async` validation
|
|
3554
|
+
- Implement `validate_only` for multi-step form flows
|
|
3555
|
+
- Pass rule parameters (`{min}`, `{max}`) to locale message strings
|
|
3556
|
+
- Debounce `async` validation requests
|
|
3557
|
+
|
|
3558
|
+
**MAY:**
|
|
3559
|
+
- Animate field error state transitions
|
|
3560
|
+
- Scroll to the first invalid field on form submission
|
|
3561
|
+
- Show a summary count of validation errors
|
|
3562
|
+
- Cache async validation results for previously checked values
|
|
3563
|
+
- Implement client-side equivalents of `format` rules (email, url, phone, uuid) using platform-native validators
|
|
3564
|
+
|
|
3565
|
+
---
|
|
3566
|
+
|
|
3567
|
+
## 14. Development workflow
|
|
3568
|
+
|
|
3569
|
+
OpenUISpec is not just a code generation input — it is a **shared sync layer** that keeps platform teams aligned. The spec lives in version control alongside the code it describes, acting as a human-readable changelog of UI decisions. This section defines the two workflows that make this possible.
|
|
3570
|
+
|
|
3571
|
+
### 14.1 Two modes
|
|
3572
|
+
|
|
3573
|
+
| Mode | Direction | When to use |
|
|
3574
|
+
|------|-----------|-------------|
|
|
3575
|
+
| **Design mode** (spec-first) | Spec → Code | New features, design system changes, screen additions |
|
|
3576
|
+
| **Development mode** (platform-first) | Code → Spec → Code | Day-to-day iteration, tweaks, bug fixes, polish |
|
|
3577
|
+
|
|
3578
|
+
Both modes use the same spec format. The difference is which artifact is edited first and how changes propagate.
|
|
3579
|
+
|
|
3580
|
+
### 14.2 Design mode (spec-first)
|
|
3581
|
+
|
|
3582
|
+
In design mode, the spec is the starting point:
|
|
3583
|
+
|
|
3584
|
+
1. **Define** — Author or generate spec YAML (tokens, contracts, screens, flows)
|
|
3585
|
+
2. **Generate** — AI produces native code per platform from the spec
|
|
3586
|
+
3. **Refine** — Platform teams adjust the generated code for native feel
|
|
3587
|
+
|
|
3588
|
+
This is the natural workflow when creating new screens, onboarding flows, or applying design system changes across all platforms simultaneously.
|
|
3589
|
+
|
|
3590
|
+
Design mode works best when:
|
|
3591
|
+
- A feature does not yet exist on any platform
|
|
3592
|
+
- A designer has produced a new design that needs implementation on all platforms
|
|
3593
|
+
- A design system change (new tokens, contract variants) must roll out consistently
|
|
3594
|
+
|
|
3595
|
+
### 14.3 Development mode (platform-first)
|
|
3596
|
+
|
|
3597
|
+
In development mode, a developer works in their IDE first:
|
|
3598
|
+
|
|
3599
|
+
1. **Code** — Edit native code in Xcode, Android Studio, or a web editor with live preview
|
|
3600
|
+
2. **Sync** — AI reads the code changes and updates the spec YAML to reflect them
|
|
3601
|
+
3. **Propagate** — Other platform teams see the spec diff and update their code accordingly
|
|
3602
|
+
|
|
3603
|
+
This is the everyday workflow. A developer fixing a layout issue on iOS should not have to manually edit YAML and re-generate — they fix it in SwiftUI with instant preview, then the spec catches up.
|
|
3604
|
+
|
|
3605
|
+
The sync step can be manual (developer updates spec by hand), AI-assisted (AI reads a diff and proposes spec changes), or automated (a CI tool detects drift and opens a PR). The spec does not prescribe a specific tool — it defines the format that any such tool operates on.
|
|
3606
|
+
|
|
3607
|
+
### 14.4 Drift detection
|
|
3608
|
+
|
|
3609
|
+
**Drift** occurs when platform code and spec disagree — a screen was updated in code but the spec was not changed, or vice versa. Drift is normal and expected during development; the goal is to detect and resolve it, not prevent it.
|
|
3610
|
+
|
|
3611
|
+
A drift detector compares:
|
|
3612
|
+
- **Spec → Code**: Does the generated code match what the spec describes? (e.g., a button's action type, a screen's data sources, a flow's step order)
|
|
3613
|
+
- **Code → Spec**: Does the current platform code contain UI decisions not reflected in the spec? (e.g., a new field added to a form, a navigation path changed)
|
|
3614
|
+
|
|
3615
|
+
Drift detection is scoped to the semantic layer — it compares behavioral intent (contracts, props, state machines, data bindings), not visual details (padding values, animation curves). Platform-specific polish is expected to diverge from the spec; behavioral contracts are not.
|
|
3616
|
+
|
|
3617
|
+
Resolution strategies:
|
|
3618
|
+
- **Update spec** — The code change is intentional; update the spec to match
|
|
3619
|
+
- **Update code** — The spec change is authoritative; regenerate or manually update the code
|
|
3620
|
+
- **Platform override** — The divergence is intentional and platform-specific; document it in `platform/*.yaml`
|
|
3621
|
+
|
|
3622
|
+
### 14.5 Spec as communication layer
|
|
3623
|
+
|
|
3624
|
+
When the spec lives in version control, it becomes a communication tool between platform teams:
|
|
3625
|
+
|
|
3626
|
+
- **Spec commits are UI decisions.** A diff to `screens/home.yaml` tells every platform team what changed in the home screen — without reading Swift, Kotlin, or TypeScript.
|
|
3627
|
+
- **Review across platforms.** A PR that modifies spec files can be reviewed by any team member regardless of their platform expertise.
|
|
3628
|
+
- **Changelog by default.** The git history of spec files is a human-readable record of every UI change, who made it, and why.
|
|
3629
|
+
|
|
3630
|
+
This is the spec's primary value beyond code generation: it gives cross-platform teams a shared language for UI changes that doesn't require reading each other's native code.
|
|
3631
|
+
|
|
3632
|
+
---
|
|
3633
|
+
|
|
3634
|
+
## Appendix A: Type reference
|
|
3635
|
+
|
|
3636
|
+
| Type | Description | Example |
|
|
3637
|
+
|------|-------------|---------|
|
|
3638
|
+
| `string` | Text value | `"Submit order"` |
|
|
3639
|
+
| `int` | Integer | `48` |
|
|
3640
|
+
| `bool` | Boolean | `true` |
|
|
3641
|
+
| `enum` | One of declared values | `primary \| secondary` |
|
|
3642
|
+
| `icon_ref` | Icon identifier from icon set | `"chevron_right"` |
|
|
3643
|
+
| `media_ref` | Image/video reference | `"assets/hero.jpg"` |
|
|
3644
|
+
| `color_ref` | Token path | `"color.brand.primary"` |
|
|
3645
|
+
| `component_ref` | Inline contract instance | `{ contract: data_display, ... }` |
|
|
3646
|
+
| `contract_ref` | Contract family name | `"action_trigger"` |
|
|
3647
|
+
| `screen_ref` | Screen identifier | `"screens/order_detail"` |
|
|
3648
|
+
| `action` | Action definition (see Section 9) | `{ type: navigate, destination: "..." }` |
|
|
3649
|
+
| `data_path` | Dot-notation path to a value (see Section 10.3) | `"task.project.name"` |
|
|
3650
|
+
| `format_expr` | String with `{}` interpolation (see Section 10.5) | `"{value | format:currency}"` |
|
|
3651
|
+
| `badge_config` | `{ text?, count?, dot?, severity? }` | `{ count: 3, severity: "warning" }` |
|
|
3652
|
+
| `range_config` | `{ min, max, step?, default? }` | `{ min: 0, max: 100, step: 1 }` |
|
|
3653
|
+
| `data_source` | Data dependency declaration (see Section 10.1) | `{ source: "api.tasks.list", params: {...} }` |
|
|
3654
|
+
| `cache_level` | Data caching strategy | `none \| screen \| session \| persistent` |
|
|
3655
|
+
| `size_class` | Adaptive layout breakpoint | `compact \| regular \| expanded` |
|
|
3656
|
+
| `layout_ref` | Layout primitive definition | `{ type: stack, spacing: "spacing.md" }` |
|
|
3657
|
+
| `locale_ref` | Locale string reference (see Section 11) | `"$t:common.cancel"` |
|
|
3658
|
+
|
|
3659
|
+
## Appendix B: Format expression quick reference
|
|
3660
|
+
|
|
3661
|
+
> Full specification in Section 10.5.
|
|
3662
|
+
|
|
3663
|
+
**Syntax:** `{data_path | pipe}` or `{condition ? 'value_a' : 'value_b'}` or `$t:locale_key`
|
|
3664
|
+
|
|
3665
|
+
**Pipes:** `format:name`, `map:name`, `default:'fallback'`
|
|
3666
|
+
|
|
3667
|
+
**Locale references:** `$t:key` with optional `t_params:` sibling for ICU placeholders (see Section 11)
|
|
3668
|
+
|
|
3669
|
+
**Built-in formatters:** `currency`, `date`, `date_relative`, `date_short`, `time`, `number`, `percentage`, `status_label`, `pluralize`, `file_size`
|
|
3670
|
+
|
|
3671
|
+
**Built-in mappers:** `status_severity`, `priority_to_severity`, `bool_to_label`
|
|
3672
|
+
|
|
3673
|
+
**Custom formatters and mappers** are defined in the project's `openuispec.yaml` manifest.
|
|
3674
|
+
|
|
3675
|
+
---
|
|
3676
|
+
|
|
3677
|
+
*OpenUISpec v0.1 — Draft specification. Subject to revision.*
|