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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/cli/index.ts +49 -0
  4. package/cli/init.ts +390 -0
  5. package/drift/index.ts +398 -0
  6. package/examples/taskflow/README.md +103 -0
  7. package/examples/taskflow/contracts/README.md +18 -0
  8. package/examples/taskflow/contracts/action_trigger.yaml +7 -0
  9. package/examples/taskflow/contracts/collection.yaml +7 -0
  10. package/examples/taskflow/contracts/data_display.yaml +7 -0
  11. package/examples/taskflow/contracts/feedback.yaml +7 -0
  12. package/examples/taskflow/contracts/input_field.yaml +7 -0
  13. package/examples/taskflow/contracts/nav_container.yaml +7 -0
  14. package/examples/taskflow/contracts/surface.yaml +7 -0
  15. package/examples/taskflow/contracts/x_media_player.yaml +185 -0
  16. package/examples/taskflow/flows/create_task.yaml +171 -0
  17. package/examples/taskflow/flows/edit_task.yaml +131 -0
  18. package/examples/taskflow/locales/en.json +158 -0
  19. package/examples/taskflow/openuispec.yaml +144 -0
  20. package/examples/taskflow/platform/android.yaml +32 -0
  21. package/examples/taskflow/platform/ios.yaml +39 -0
  22. package/examples/taskflow/platform/web.yaml +35 -0
  23. package/examples/taskflow/screens/calendar.yaml +23 -0
  24. package/examples/taskflow/screens/home.yaml +220 -0
  25. package/examples/taskflow/screens/profile_edit.yaml +70 -0
  26. package/examples/taskflow/screens/project_detail.yaml +65 -0
  27. package/examples/taskflow/screens/projects.yaml +142 -0
  28. package/examples/taskflow/screens/settings.yaml +178 -0
  29. package/examples/taskflow/screens/task_detail.yaml +317 -0
  30. package/examples/taskflow/tokens/color.yaml +88 -0
  31. package/examples/taskflow/tokens/elevation.yaml +27 -0
  32. package/examples/taskflow/tokens/icons.yaml +189 -0
  33. package/examples/taskflow/tokens/layout.yaml +156 -0
  34. package/examples/taskflow/tokens/motion.yaml +41 -0
  35. package/examples/taskflow/tokens/spacing.yaml +23 -0
  36. package/examples/taskflow/tokens/themes.yaml +34 -0
  37. package/examples/taskflow/tokens/typography.yaml +61 -0
  38. package/package.json +43 -0
  39. package/schema/custom-contract.schema.json +257 -0
  40. package/schema/defs/action.schema.json +272 -0
  41. package/schema/defs/adaptive.schema.json +13 -0
  42. package/schema/defs/common.schema.json +330 -0
  43. package/schema/defs/data-binding.schema.json +119 -0
  44. package/schema/defs/validation.schema.json +121 -0
  45. package/schema/flow.schema.json +164 -0
  46. package/schema/locale.schema.json +26 -0
  47. package/schema/openuispec.schema.json +287 -0
  48. package/schema/platform.schema.json +95 -0
  49. package/schema/screen.schema.json +346 -0
  50. package/schema/tokens/color.schema.json +104 -0
  51. package/schema/tokens/elevation.schema.json +84 -0
  52. package/schema/tokens/icons.schema.json +149 -0
  53. package/schema/tokens/layout.schema.json +170 -0
  54. package/schema/tokens/motion.schema.json +83 -0
  55. package/schema/tokens/spacing.schema.json +93 -0
  56. package/schema/tokens/themes.schema.json +92 -0
  57. package/schema/tokens/typography.schema.json +106 -0
  58. package/schema/validate.ts +258 -0
  59. 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.*