openuispec 0.2.13 → 0.2.14

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 (63) hide show
  1. package/README.md +6 -5
  2. package/cli/index.ts +18 -12
  3. package/cli/init.ts +78 -13
  4. package/docs/cli.md +81 -27
  5. package/docs/file-formats.md +51 -1
  6. package/drift/index.ts +7 -2
  7. package/examples/social-app/openuispec/README.md +2 -1
  8. package/examples/social-app/openuispec/mock/chat_detail.yaml +25 -0
  9. package/examples/social-app/openuispec/mock/discover.yaml +17 -0
  10. package/examples/social-app/openuispec/mock/edit_profile.yaml +9 -0
  11. package/examples/social-app/openuispec/mock/home_feed.yaml +32 -0
  12. package/examples/social-app/openuispec/mock/messages_inbox.yaml +15 -0
  13. package/examples/social-app/openuispec/mock/notifications.yaml +30 -0
  14. package/examples/social-app/openuispec/mock/post_detail.yaml +26 -0
  15. package/examples/social-app/openuispec/mock/profile_self.yaml +28 -0
  16. package/examples/social-app/openuispec/mock/profile_user.yaml +32 -0
  17. package/examples/social-app/openuispec/mock/search_results.yaml +17 -0
  18. package/examples/social-app/openuispec/mock/settings.yaml +7 -0
  19. package/examples/social-app/openuispec/openuispec.yaml +3 -2
  20. package/examples/taskflow/README.md +4 -2
  21. package/examples/taskflow/openuispec/README.md +2 -1
  22. package/examples/taskflow/openuispec/components/media_player.yaml +92 -0
  23. package/examples/taskflow/openuispec/contracts/README.md +2 -2
  24. package/examples/taskflow/openuispec/locales/en.json +1 -0
  25. package/examples/taskflow/openuispec/mock/home.yaml +64 -0
  26. package/examples/taskflow/openuispec/mock/profile_edit.yaml +6 -0
  27. package/examples/taskflow/openuispec/mock/project_detail.yaml +33 -0
  28. package/examples/taskflow/openuispec/mock/settings.yaml +13 -0
  29. package/examples/taskflow/openuispec/mock/task_detail.yaml +18 -0
  30. package/examples/taskflow/openuispec/openuispec.yaml +3 -4
  31. package/examples/taskflow/openuispec/platform/ios.yaml +0 -4
  32. package/examples/taskflow/openuispec/screens/task_detail.yaml +5 -8
  33. package/examples/taskflow/openuispec/tokens/icons.yaml +16 -0
  34. package/examples/todo-orbit/README.md +3 -2
  35. package/examples/todo-orbit/openuispec/README.md +2 -1
  36. package/examples/todo-orbit/openuispec/components/task_trend_chart.yaml +85 -0
  37. package/examples/todo-orbit/openuispec/locales/en.json +3 -0
  38. package/examples/todo-orbit/openuispec/locales/ru.json +3 -0
  39. package/examples/todo-orbit/openuispec/mock/analytics.yaml +26 -0
  40. package/examples/todo-orbit/openuispec/mock/home.yaml +33 -0
  41. package/examples/todo-orbit/openuispec/mock/settings.yaml +7 -0
  42. package/examples/todo-orbit/openuispec/mock/task_detail.yaml +14 -0
  43. package/examples/todo-orbit/openuispec/openuispec.yaml +3 -3
  44. package/examples/todo-orbit/openuispec/platform/android.yaml +0 -3
  45. package/examples/todo-orbit/openuispec/platform/ios.yaml +0 -3
  46. package/examples/todo-orbit/openuispec/platform/web.yaml +0 -3
  47. package/examples/todo-orbit/openuispec/screens/analytics.yaml +1 -4
  48. package/mcp-server/index.ts +80 -3
  49. package/mcp-server/preview-render.ts +1922 -0
  50. package/mcp-server/preview.ts +292 -0
  51. package/mcp-server/screenshot-shared.ts +38 -0
  52. package/mcp-server/screenshot.ts +3 -32
  53. package/package.json +1 -1
  54. package/prepare/index.ts +1 -1
  55. package/schema/component.schema.json +278 -0
  56. package/schema/openuispec.schema.json +5 -1
  57. package/schema/screen.schema.json +12 -1
  58. package/schema/semantic-lint.ts +24 -2
  59. package/schema/validate.ts +21 -0
  60. package/scripts/regenerate-previews.ts +136 -0
  61. package/spec/{openuispec-v0.1.md → openuispec-v0.2.md} +266 -8
  62. package/examples/taskflow/openuispec/contracts/x_media_player.yaml +0 -185
  63. package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +0 -139
@@ -60,6 +60,10 @@
60
60
  "locales": {
61
61
  "type": "string",
62
62
  "description": "Localization string files — one JSON file per supported locale containing translated UI text."
63
+ },
64
+ "components": {
65
+ "type": "string",
66
+ "description": "Reusable component compositions — each YAML file defines a component as a composition of contracts with named slots, states, and variants."
63
67
  }
64
68
  },
65
69
  "required": [
@@ -184,7 +188,7 @@
184
188
  "description": "Spec categories this shared layer tracks for hash-based drift detection. Optional — when omitted, the layer relies on scope alone. Categories: manifest (project config, data models, API endpoints), tokens (visual design tokens — UI only), contracts (reusable UI component definitions — UI only, not business logic), screens (screen layouts — UI only), flows (navigation journeys — UI only), platform (per-target overrides — UI only), locales (translated UI strings).",
185
189
  "items": {
186
190
  "type": "string",
187
- "enum": ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"]
191
+ "enum": ["manifest", "tokens", "contracts", "components", "screens", "flows", "platform", "locales"]
188
192
  }
189
193
  },
190
194
  "scope": {
@@ -171,7 +171,7 @@
171
171
  "additionalProperties": false
172
172
  },
173
173
  "section_item": {
174
- "description": "A section item \u2014 either a contract instance or a section group with children",
174
+ "description": "A section item \u2014 a contract instance, component instance, or section group with children",
175
175
  "type": "object",
176
176
  "properties": {
177
177
  "id": {
@@ -180,6 +180,17 @@
180
180
  "contract": {
181
181
  "type": "string"
182
182
  },
183
+ "component": {
184
+ "type": "string",
185
+ "description": "Component name (alternative to contract). References a component defined in components/*.yaml."
186
+ },
187
+ "slots": {
188
+ "type": "object",
189
+ "description": "Slot overrides when using a component. Each key is a slot name.",
190
+ "additionalProperties": {
191
+ "$ref": "https://openuispec.rsteam.uz/schema/component.schema.json#/$defs/slot_override"
192
+ }
193
+ },
183
194
  "variant": {
184
195
  "type": "string"
185
196
  },
@@ -25,6 +25,7 @@ function collectLocaleKeys(data: unknown, prefix = ""): string[] {
25
25
  export interface Includes {
26
26
  tokens: string;
27
27
  contracts: string;
28
+ components: string;
28
29
  screens: string;
29
30
  flows: string;
30
31
  platform: string;
@@ -43,6 +44,7 @@ interface SemanticContext {
43
44
  formatterNames: Set<string>;
44
45
  mapperNames: Set<string>;
45
46
  contractNames: Set<string>;
47
+ componentNames: Set<string>;
46
48
  tokenRefs: Set<string>;
47
49
  iconRefs: Set<string>;
48
50
  iconVariantSuffixes: string[];
@@ -253,6 +255,15 @@ function buildContext(projectDir: string, includes: Includes, manifest: UnknownR
253
255
  }
254
256
  }
255
257
 
258
+ // Components are also valid references in screen sections (via "component:" key)
259
+ const componentNames = new Set<string>();
260
+ const componentsDir = resolve(projectDir, includes.components);
261
+ for (const filePath of listFiles(componentsDir, ".yaml")) {
262
+ for (const key of rootKeys(filePath)) {
263
+ componentNames.add(key);
264
+ }
265
+ }
266
+
256
267
  const tokenRefs = new Set<string>();
257
268
  const tokensDir = resolve(projectDir, includes.tokens);
258
269
  for (const filePath of listFiles(tokensDir, ".yaml")) {
@@ -300,6 +311,7 @@ function buildContext(projectDir: string, includes: Includes, manifest: UnknownR
300
311
  formatterNames,
301
312
  mapperNames,
302
313
  contractNames,
314
+ componentNames,
303
315
  tokenRefs,
304
316
  iconRefs: iconData.refs,
305
317
  iconVariantSuffixes: iconData.suffixes,
@@ -429,6 +441,10 @@ function validateStringValue(
429
441
  errors.push({ path, message: `unknown contract "${value}"` });
430
442
  }
431
443
 
444
+ if (key === "component" && !path.includes("platform_mapping") && !context.componentNames.has(value)) {
445
+ errors.push({ path, message: `unknown component "${value}"` });
446
+ }
447
+
432
448
  if (
433
449
  (key === "icon" || key === "icon_active") &&
434
450
  !isDynamicReference(value) &&
@@ -617,6 +633,7 @@ export function collectSemanticLint(projectDir: string, includes: Includes): Usa
617
633
  const manifest = readManifest(projectDir) as UnknownRecord;
618
634
  const context = buildContext(projectDir, includes, manifest);
619
635
  const contractsDir = resolve(projectDir, includes.contracts);
636
+ const componentsDir = resolve(projectDir, includes.components);
620
637
 
621
638
  const allErrors: UsageLint[] = [
622
639
  ...lintLocaleCoverage(context),
@@ -629,11 +646,13 @@ export function collectSemanticLint(projectDir: string, includes: Includes): Usa
629
646
  ...listFiles(resolve(projectDir, includes.flows), ".yaml"),
630
647
  ...listFiles(resolve(projectDir, includes.platform), ".yaml"),
631
648
  ...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
649
+ ...listFiles(componentsDir, ".yaml"),
632
650
  ];
633
651
 
634
652
  for (const filePath of files) {
653
+ const isContractOrComponent = filePath.startsWith(contractsDir) || filePath.startsWith(componentsDir);
635
654
  allErrors.push(
636
- ...lintFile(filePath, context, { validateTokens: !filePath.startsWith(contractsDir) })
655
+ ...lintFile(filePath, context, { validateTokens: !isContractOrComponent })
637
656
  );
638
657
  }
639
658
 
@@ -645,6 +664,7 @@ export function runSemanticLint(projectDir: string, includes: Includes): number
645
664
  const context = buildContext(projectDir, includes, manifest);
646
665
  let total = 0;
647
666
  const contractsDir = resolve(projectDir, includes.contracts);
667
+ const componentsDir = resolve(projectDir, includes.components);
648
668
 
649
669
  total += printSemanticErrors("locales", lintLocaleCoverage(context));
650
670
  total += printSemanticErrors("openuispec.yaml", lintManifestGenerationContext(projectDir, context.manifest));
@@ -655,12 +675,14 @@ export function runSemanticLint(projectDir: string, includes: Includes): number
655
675
  ...listFiles(resolve(projectDir, includes.flows), ".yaml"),
656
676
  ...listFiles(resolve(projectDir, includes.platform), ".yaml"),
657
677
  ...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
678
+ ...listFiles(componentsDir, ".yaml"),
658
679
  ];
659
680
 
660
681
  for (const filePath of files) {
682
+ const isContractOrComponent = filePath.startsWith(contractsDir) || filePath.startsWith(componentsDir);
661
683
  total += printSemanticErrors(
662
684
  basename(filePath),
663
- lintFile(filePath, context, { validateTokens: !filePath.startsWith(contractsDir) })
685
+ lintFile(filePath, context, { validateTokens: !isContractOrComponent })
664
686
  );
665
687
  }
666
688
 
@@ -478,6 +478,7 @@ function validateFile(
478
478
  const DEFAULT_INCLUDES: Includes = {
479
479
  tokens: "./tokens/",
480
480
  contracts: "./contracts/",
481
+ components: "./components/",
481
482
  screens: "./screens/",
482
483
  flows: "./flows/",
483
484
  platform: "./platform/",
@@ -702,6 +703,26 @@ const GROUPS: Record<string, ValidationGroup> = {
702
703
  },
703
704
  },
704
705
 
706
+ components: {
707
+ label: "Components",
708
+ run(ajv, projectDir, includes) {
709
+ let errors = 0;
710
+ const dir = resolveInclude(projectDir, includes.components);
711
+ for (const f of listFiles(dir, ".yaml")) {
712
+ errors += validateFile(ajv, f, `${BASE}component.schema.json`);
713
+ }
714
+ return errors;
715
+ },
716
+ collectJson(ajv, projectDir, includes, groupKey) {
717
+ const errors: JsonError[] = [];
718
+ const dir = resolveInclude(projectDir, includes.components);
719
+ for (const f of listFiles(dir, ".yaml")) {
720
+ errors.push(...collectValidateFile(ajv, f, `${BASE}component.schema.json`));
721
+ }
722
+ return { group: groupKey, errors };
723
+ },
724
+ },
725
+
705
726
  semantic: {
706
727
  label: "Semantic",
707
728
  run(_ajv, projectDir, includes) {
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Regenerates all preview PNGs for the 3 example projects using the
4
+ * preview renderer (preview-render.ts → preview.ts → Puppeteer).
5
+ *
6
+ * Outputs to examples/<project>/previews/<screen>_<sizeClass>[_<theme>].png
7
+ *
8
+ * Usage: npx tsx scripts/regenerate-previews.ts
9
+ */
10
+
11
+ import { mkdirSync, writeFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { renderPreview } from "../mcp-server/preview.js";
14
+ import { closeBrowser } from "../mcp-server/screenshot-shared.js";
15
+
16
+ const ROOT = resolve(import.meta.dirname!, "..");
17
+
18
+ interface Capture {
19
+ screen: string;
20
+ sizeClass: "compact" | "regular" | "expanded";
21
+ theme?: "light" | "dark";
22
+ }
23
+
24
+ interface ProjectDef {
25
+ name: string;
26
+ captures: Capture[];
27
+ }
28
+
29
+ function allSizes(screen: string): Capture[] {
30
+ return [
31
+ { screen, sizeClass: "compact" },
32
+ { screen, sizeClass: "expanded" },
33
+ ];
34
+ }
35
+
36
+ function allSizesWithThemes(screen: string): Capture[] {
37
+ return [
38
+ { screen, sizeClass: "compact" },
39
+ { screen, sizeClass: "expanded" },
40
+ { screen, sizeClass: "compact", theme: "light" },
41
+ { screen, sizeClass: "compact", theme: "dark" },
42
+ { screen, sizeClass: "expanded", theme: "light" },
43
+ { screen, sizeClass: "expanded", theme: "dark" },
44
+ ];
45
+ }
46
+
47
+ const PROJECTS: ProjectDef[] = [
48
+ {
49
+ name: "social-app",
50
+ captures: [
51
+ ...allSizes("home_feed"),
52
+ ...allSizes("discover"),
53
+ ...allSizes("notifications"),
54
+ ...allSizes("messages_inbox"),
55
+ ...allSizes("profile_self"),
56
+ ...allSizes("profile_user"),
57
+ ...allSizes("settings"),
58
+ ...allSizes("post_detail"),
59
+ ...allSizes("chat_detail"),
60
+ ...allSizes("search_results"),
61
+ ...allSizes("edit_profile"),
62
+ ],
63
+ },
64
+ {
65
+ name: "taskflow",
66
+ captures: [
67
+ ...allSizesWithThemes("home"),
68
+ ...allSizes("projects"),
69
+ ...allSizes("calendar"),
70
+ ...allSizesWithThemes("settings"),
71
+ ...allSizesWithThemes("task_detail"),
72
+ ...allSizes("project_detail"),
73
+ ...allSizes("profile_edit"),
74
+ ],
75
+ },
76
+ {
77
+ name: "todo-orbit",
78
+ captures: [
79
+ ...allSizes("home"),
80
+ ...allSizes("analytics"),
81
+ ...allSizes("settings"),
82
+ ...allSizes("task_detail"),
83
+ ],
84
+ },
85
+ ];
86
+
87
+ function log(msg: string) { console.log(`\x1b[36m▸\x1b[0m ${msg}`); }
88
+ function logOk(msg: string) { console.log(`\x1b[32m✔\x1b[0m ${msg}`); }
89
+ function logErr(msg: string) { console.error(`\x1b[31m✖\x1b[0m ${msg}`); }
90
+
91
+ async function main() {
92
+ let total = 0;
93
+ let failures = 0;
94
+
95
+ for (const project of PROJECTS) {
96
+ console.log(`\n\x1b[1m=== ${project.name} ===\x1b[0m\n`);
97
+ const projectCwd = join(ROOT, "examples", project.name);
98
+ const outDir = join(projectCwd, "previews");
99
+ mkdirSync(outDir, { recursive: true });
100
+
101
+ for (const cap of project.captures) {
102
+ const theme = cap.theme ?? "light";
103
+ const suffix = cap.theme ? `_${cap.theme}` : "";
104
+ const filename = `${cap.screen}_${cap.sizeClass}${suffix}.png`;
105
+ log(`${filename}...`);
106
+ total++;
107
+
108
+ try {
109
+ const result = await renderPreview(projectCwd, {
110
+ screen: cap.screen,
111
+ size_class: cap.sizeClass,
112
+ theme,
113
+ });
114
+
115
+ for (const item of result.content) {
116
+ if (item.type === "image" && "data" in item) {
117
+ writeFileSync(join(outDir, filename), Buffer.from(item.data, "base64"));
118
+ logOk(filename);
119
+ }
120
+ }
121
+ } catch (err: any) {
122
+ logErr(`${filename}: ${err.message}`);
123
+ failures++;
124
+ }
125
+ }
126
+ }
127
+
128
+ await closeBrowser();
129
+ console.log(`\n\x1b[${failures ? "31" : "32"}m${total - failures}/${total} previews generated, ${failures} failures\x1b[0m\n`);
130
+ process.exit(failures > 0 ? 1 : 0);
131
+ }
132
+
133
+ main().catch((err) => {
134
+ console.error(err);
135
+ process.exit(1);
136
+ });
@@ -1,11 +1,11 @@
1
- # OpenUISpec v0.1
1
+ # OpenUISpec v0.2
2
2
 
3
3
  > A single source of truth design language for AI-native, platform-native app development.
4
4
 
5
- **Status:** Draft
6
- **Version:** 0.1
7
- **Authors:** Rustam Samandarov
8
- **Last updated:** 2026-03-13
5
+ **Status:** Draft
6
+ **Version:** 0.2
7
+ **Authors:** Rustam Samandarov
8
+ **Last updated:** 2026-03-19
9
9
 
10
10
  ---
11
11
 
@@ -49,6 +49,8 @@ project/
49
49
  │ ├── surface.yaml
50
50
  │ ├── collection.yaml
51
51
  │ └── x_media_player.yaml # Custom contract (Section 12)
52
+ ├── components/
53
+ │ └── media_player.yaml # Component composition (Section 15)
52
54
  ├── screens/
53
55
  │ ├── home.yaml
54
56
  │ ├── order_detail.yaml
@@ -68,7 +70,7 @@ project/
68
70
 
69
71
  ```yaml
70
72
  # openuispec.yaml
71
- spec_version: "0.1"
73
+ spec_version: "0.2"
72
74
  project:
73
75
  name: "MyApp"
74
76
  description: "A sample application defined in OpenUISpec"
@@ -3167,6 +3169,8 @@ Use a custom contract when the component:
3167
3169
 
3168
3170
  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`.
3169
3171
 
3172
+ > **Prefer components over custom contracts** when the UI block is a composition of multiple contracts (e.g., a media player with play button, scrubber, and time label). Components (Section 15) provide named slots, states, variants, and screen-level overrides. Reserve `x_` custom contracts for truly atomic, domain-specific widgets that don't decompose into smaller contracts.
3173
+
3170
3174
  ### 12.2 Naming
3171
3175
 
3172
3176
  Custom contract names **MUST**:
@@ -3271,7 +3275,7 @@ Custom contracts are registered in the root manifest via the `custom_contracts`
3271
3275
 
3272
3276
  ```yaml
3273
3277
  # openuispec.yaml
3274
- spec_version: "0.1"
3278
+ spec_version: "0.2"
3275
3279
  project:
3276
3280
  name: "MyApp"
3277
3281
 
@@ -3841,6 +3845,259 @@ This is the spec's primary value beyond code generation: it gives cross-platform
3841
3845
 
3842
3846
  ---
3843
3847
 
3848
+ ## 15. Component composition
3849
+
3850
+ Components fill the gap between atomic contracts and full-page screens. A component is a **reusable composition of contracts with named slots** — think of a media player composed of a play button, scrubber, time label, and volume control, each backed by a base contract.
3851
+
3852
+ ```
3853
+ Tokens → Contracts → Components → Screens → Flows
3854
+ (atomic) (composed) (full page)
3855
+ ```
3856
+
3857
+ Components live in the `components/` directory referenced by `includes.components` in the manifest.
3858
+
3859
+ ### 15.1 Component definition format
3860
+
3861
+ Each YAML file contains a single root key — the component name — mapping to a `component_def`:
3862
+
3863
+ ```yaml
3864
+ # components/media_player.yaml
3865
+ media_player:
3866
+ semantic: "Plays audio and video media with transport controls"
3867
+
3868
+ props:
3869
+ source: { type: string, required: true }
3870
+ media_type: { type: enum, values: [audio, video], required: true }
3871
+ title: { type: string }
3872
+
3873
+ slots:
3874
+ play_button:
3875
+ contract: action_trigger
3876
+ variant: icon
3877
+ props: { label: "$t:media_player.play", icon: play }
3878
+ hideable: true
3879
+ scrubber:
3880
+ contract: input_field
3881
+ input_type: slider
3882
+ props: { label: "$t:media_player.progress" }
3883
+ hideable: true
3884
+ time_label:
3885
+ contract: data_display
3886
+ variant: caption
3887
+ hideable: true
3888
+ volume_control:
3889
+ contract: input_field
3890
+ input_type: slider
3891
+ hideable: true
3892
+
3893
+ layout:
3894
+ type: stack
3895
+ spacing: "spacing.sm"
3896
+ sections:
3897
+ - slot: play_button
3898
+ - slot: scrubber
3899
+ - layout:
3900
+ type: row
3901
+ sections:
3902
+ - slot: time_label
3903
+ - slot: volume_control
3904
+
3905
+ states:
3906
+ idle: { semantic: "No media loaded" }
3907
+ loading:
3908
+ semantic: "Buffering"
3909
+ hide_slots: [scrubber, volume_control]
3910
+ playing:
3911
+ semantic: "Actively playing"
3912
+ slot_overrides:
3913
+ play_button: { props: { icon: pause } }
3914
+ paused:
3915
+ semantic: "Paused at position"
3916
+ slot_overrides:
3917
+ play_button: { props: { icon: play } }
3918
+
3919
+ variants:
3920
+ mini:
3921
+ semantic: "Compact player for persistent bottom bar"
3922
+ hide_slots: [volume_control]
3923
+ layout:
3924
+ type: row
3925
+ sections:
3926
+ - slot: play_button
3927
+ - slot: scrubber
3928
+ - slot: time_label
3929
+ fullscreen:
3930
+ semantic: "Full-screen immersive player"
3931
+ tokens: { background: "#000000" }
3932
+
3933
+ tokens:
3934
+ background: "color.surface.secondary"
3935
+ radius: "spacing.md"
3936
+ padding: "spacing.md"
3937
+
3938
+ a11y:
3939
+ role: "group"
3940
+ label: "props.title"
3941
+
3942
+ platform_mapping:
3943
+ ios: { component: "VideoPlayer", framework: "AVKit" }
3944
+ android: { component: "PlayerView", library: "androidx.media3" }
3945
+ web: { element: "div", role: "region" }
3946
+
3947
+ generation:
3948
+ must_handle: ["All slots must render with correct contract types"]
3949
+ ```
3950
+
3951
+ ### 15.2 Anatomy
3952
+
3953
+ | Section | Purpose | Required |
3954
+ |---------|---------|----------|
3955
+ | `semantic` | Human-readable description of what this component does | Yes |
3956
+ | `slots` | Named contract instances that make up the component | Yes |
3957
+ | `props` | Typed inputs the component accepts (passed via data binding) | No |
3958
+ | `layout` | Spatial arrangement of slots using layout primitives | No |
3959
+ | `states` | Composite states that control slot visibility and props | No |
3960
+ | `variants` | Named presets that hide slots, change layout, or override tokens | No |
3961
+ | `tokens` | Visual token bindings for the component container | No |
3962
+ | `a11y` | Accessibility role and label pattern | No |
3963
+ | `platform_mapping` | Per-platform native component hints | No |
3964
+ | `dependencies` | Platform-specific library requirements | No |
3965
+ | `generation` | AI generation hints (must_handle, should_handle, may_handle) | No |
3966
+ | `test_cases` | Behavioral verification scenarios | No |
3967
+
3968
+ ### 15.3 Slots
3969
+
3970
+ A **slot** is a named position within a component that renders a base contract. Each slot specifies:
3971
+
3972
+ | Field | Type | Required | Description |
3973
+ |-------|------|----------|-------------|
3974
+ | `contract` | `contract_ref` | Yes | The base contract family (e.g. `action_trigger`, `data_display`) |
3975
+ | `variant` | `string` | No | Default variant for the contract |
3976
+ | `input_type` | `string` | No | Input type (for `input_field` contracts) |
3977
+ | `props` | `object` | No | Default props passed to the contract |
3978
+ | `hideable` | `bool` | No | Whether this slot can be hidden from screens or by states |
3979
+ | `tokens_override` | `object` | No | Token overrides for this slot |
3980
+
3981
+ Slots reference **base contracts only** — components cannot nest other components (v1 keeps it flat).
3982
+
3983
+ ### 15.4 States
3984
+
3985
+ Component states are **composite states** that control slot visibility and override slot props. They differ from contract states (Section 4): contract states describe UI interaction states of a single widget (pressed, disabled, loading); component states describe the state of the entire composition (playing, paused, buffering).
3986
+
3987
+ Each state can specify:
3988
+
3989
+ | Field | Type | Description |
3990
+ |-------|------|-------------|
3991
+ | `semantic` | `string` | What this state means |
3992
+ | `hide_slots` | `string[]` | Slots to hide when this state is active |
3993
+ | `slot_overrides` | `object` | Per-slot prop/variant overrides |
3994
+ | `transitions_to` | `string[]` | Valid next states |
3995
+
3996
+ ### 15.5 Variants
3997
+
3998
+ Variants are named presets that change the component's appearance or slot arrangement:
3999
+
4000
+ ```yaml
4001
+ variants:
4002
+ mini:
4003
+ semantic: "Compact player for persistent bottom bar"
4004
+ hide_slots: [volume_control]
4005
+ layout:
4006
+ type: row
4007
+ sections:
4008
+ - slot: play_button
4009
+ - slot: scrubber
4010
+ - slot: time_label
4011
+ ```
4012
+
4013
+ A variant can specify:
4014
+ - `semantic` — What this variant is for
4015
+ - `hide_slots` — Slots to remove in this variant
4016
+ - `layout` — Alternative slot arrangement
4017
+ - `tokens` — Token overrides for this variant
4018
+ - `slot_overrides` — Per-slot prop/variant overrides
4019
+
4020
+ ### 15.6 Slot resolution order
4021
+
4022
+ When a component is rendered, slot properties are resolved by layering overrides from most general to most specific:
4023
+
4024
+ ```
4025
+ slot default → variant override → state override → screen-level override
4026
+ ```
4027
+
4028
+ Most specific wins. Screen-level overrides always have final say. If a slot is hidden at any level (variant `hide_slots`, state `hide_slots`, or screen-level `hidden: true`), it is not rendered.
4029
+
4030
+ ### 15.7 Usage in screens
4031
+
4032
+ Components are used in screens via the `component` key (instead of `contract`):
4033
+
4034
+ ```yaml
4035
+ # screens/task_detail.yaml
4036
+ - component: media_player
4037
+ variant: mini
4038
+ props:
4039
+ source: "{task.attachment.url}"
4040
+ media_type: "{task.attachment.media_type}"
4041
+ slots:
4042
+ volume_control: { hidden: true }
4043
+ play_button:
4044
+ variant: branded
4045
+ tokens_override: { background: "color.brand.primary" }
4046
+ ```
4047
+
4048
+ **Screen-level slot overrides** can:
4049
+ - Change the slot's `variant`
4050
+ - Override `props`
4051
+ - Apply `tokens_override`
4052
+ - Set `hidden: true` to suppress the slot (only if the slot is declared `hideable: true`)
4053
+
4054
+ ### 15.8 Components vs. custom contracts
4055
+
4056
+ | Aspect | Component | Custom contract (`x_`) |
4057
+ |--------|-----------|----------------------|
4058
+ | Structure | Composition of base contracts via slots | Single atomic widget |
4059
+ | Customization | Slots can be restyled, repositioned, swapped, or hidden | Props and tokens only |
4060
+ | States | Composite states controlling slot visibility | UI interaction states (pressed, loading, error) |
4061
+ | Layout | Defines spatial arrangement of slots | Opaque — platform decides layout |
4062
+ | Use when | The UI block decomposes into smaller contracts | The widget is truly atomic and domain-specific |
4063
+ | Examples | Media player, wizard, conversation timeline | Status badge, SLA indicator, sparkline chart |
4064
+
4065
+ ### 15.9 Registration
4066
+
4067
+ Components are registered in the manifest's `includes` section:
4068
+
4069
+ ```yaml
4070
+ # openuispec.yaml
4071
+ includes:
4072
+ tokens: "./tokens/"
4073
+ contracts: "./contracts/"
4074
+ components: "./components/"
4075
+ screens: "./screens/"
4076
+ # ...
4077
+ ```
4078
+
4079
+ Each `.yaml` file in the components directory defines one component. The file must contain exactly one root key matching the component name (no `x_` prefix — that is reserved for custom contracts).
4080
+
4081
+ ### 15.10 AI generation requirements
4082
+
4083
+ **MUST:**
4084
+ - Read all component definitions before generating code
4085
+ - Render every non-hidden slot with the correct base contract type
4086
+ - Apply the slot resolution order (Section 15.6) correctly
4087
+ - Support variant selection from screens
4088
+ - Include `dependencies` in generated project configuration
4089
+
4090
+ **SHOULD:**
4091
+ - Implement component states with correct slot visibility transitions
4092
+ - Apply component-level and slot-level token overrides
4093
+ - Generate accessibility support matching the `a11y` definition
4094
+
4095
+ **MAY:**
4096
+ - Generate test code based on `test_cases`
4097
+ - Add platform-specific enhancements via `platform_mapping`
4098
+
4099
+ ---
4100
+
3844
4101
  ## Appendix A: Type reference
3845
4102
 
3846
4103
  | Type | Description | Example |
@@ -3853,6 +4110,7 @@ This is the spec's primary value beyond code generation: it gives cross-platform
3853
4110
  | `media_ref` | Image/video reference | `"assets/hero.jpg"` |
3854
4111
  | `color_ref` | Token path | `"color.brand.primary"` |
3855
4112
  | `component_ref` | Inline contract instance | `{ contract: data_display, ... }` |
4113
+ | `composed_component_ref` | Component name from `components/` | `"media_player"` |
3856
4114
  | `contract_ref` | Contract family name | `"action_trigger"` |
3857
4115
  | `screen_ref` | Screen identifier | `"screens/order_detail"` |
3858
4116
  | `action` | Action definition (see Section 9) | `{ type: navigate, destination: "..." }` |
@@ -3884,4 +4142,4 @@ This is the spec's primary value beyond code generation: it gives cross-platform
3884
4142
 
3885
4143
  ---
3886
4144
 
3887
- *OpenUISpec v0.1 — Draft specification. Subject to revision.*
4145
+ *OpenUISpec v0.2 — Draft specification. Subject to revision.*