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.
- package/README.md +6 -5
- package/cli/index.ts +18 -12
- package/cli/init.ts +78 -13
- package/docs/cli.md +81 -27
- package/docs/file-formats.md +51 -1
- package/drift/index.ts +7 -2
- package/examples/social-app/openuispec/README.md +2 -1
- package/examples/social-app/openuispec/mock/chat_detail.yaml +25 -0
- package/examples/social-app/openuispec/mock/discover.yaml +17 -0
- package/examples/social-app/openuispec/mock/edit_profile.yaml +9 -0
- package/examples/social-app/openuispec/mock/home_feed.yaml +32 -0
- package/examples/social-app/openuispec/mock/messages_inbox.yaml +15 -0
- package/examples/social-app/openuispec/mock/notifications.yaml +30 -0
- package/examples/social-app/openuispec/mock/post_detail.yaml +26 -0
- package/examples/social-app/openuispec/mock/profile_self.yaml +28 -0
- package/examples/social-app/openuispec/mock/profile_user.yaml +32 -0
- package/examples/social-app/openuispec/mock/search_results.yaml +17 -0
- package/examples/social-app/openuispec/mock/settings.yaml +7 -0
- package/examples/social-app/openuispec/openuispec.yaml +3 -2
- package/examples/taskflow/README.md +4 -2
- package/examples/taskflow/openuispec/README.md +2 -1
- package/examples/taskflow/openuispec/components/media_player.yaml +92 -0
- package/examples/taskflow/openuispec/contracts/README.md +2 -2
- package/examples/taskflow/openuispec/locales/en.json +1 -0
- package/examples/taskflow/openuispec/mock/home.yaml +64 -0
- package/examples/taskflow/openuispec/mock/profile_edit.yaml +6 -0
- package/examples/taskflow/openuispec/mock/project_detail.yaml +33 -0
- package/examples/taskflow/openuispec/mock/settings.yaml +13 -0
- package/examples/taskflow/openuispec/mock/task_detail.yaml +18 -0
- package/examples/taskflow/openuispec/openuispec.yaml +3 -4
- package/examples/taskflow/openuispec/platform/ios.yaml +0 -4
- package/examples/taskflow/openuispec/screens/task_detail.yaml +5 -8
- package/examples/taskflow/openuispec/tokens/icons.yaml +16 -0
- package/examples/todo-orbit/README.md +3 -2
- package/examples/todo-orbit/openuispec/README.md +2 -1
- package/examples/todo-orbit/openuispec/components/task_trend_chart.yaml +85 -0
- package/examples/todo-orbit/openuispec/locales/en.json +3 -0
- package/examples/todo-orbit/openuispec/locales/ru.json +3 -0
- package/examples/todo-orbit/openuispec/mock/analytics.yaml +26 -0
- package/examples/todo-orbit/openuispec/mock/home.yaml +33 -0
- package/examples/todo-orbit/openuispec/mock/settings.yaml +7 -0
- package/examples/todo-orbit/openuispec/mock/task_detail.yaml +14 -0
- package/examples/todo-orbit/openuispec/openuispec.yaml +3 -3
- package/examples/todo-orbit/openuispec/platform/android.yaml +0 -3
- package/examples/todo-orbit/openuispec/platform/ios.yaml +0 -3
- package/examples/todo-orbit/openuispec/platform/web.yaml +0 -3
- package/examples/todo-orbit/openuispec/screens/analytics.yaml +1 -4
- package/mcp-server/index.ts +80 -3
- package/mcp-server/preview-render.ts +1922 -0
- package/mcp-server/preview.ts +292 -0
- package/mcp-server/screenshot-shared.ts +38 -0
- package/mcp-server/screenshot.ts +3 -32
- package/package.json +1 -1
- package/prepare/index.ts +1 -1
- package/schema/component.schema.json +278 -0
- package/schema/openuispec.schema.json +5 -1
- package/schema/screen.schema.json +12 -1
- package/schema/semantic-lint.ts +24 -2
- package/schema/validate.ts +21 -0
- package/scripts/regenerate-previews.ts +136 -0
- package/spec/{openuispec-v0.1.md → openuispec-v0.2.md} +266 -8
- package/examples/taskflow/openuispec/contracts/x_media_player.yaml +0 -185
- 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
|
|
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
|
},
|
package/schema/semantic-lint.ts
CHANGED
|
@@ -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: !
|
|
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: !
|
|
685
|
+
lintFile(filePath, context, { validateTokens: !isContractOrComponent })
|
|
664
686
|
);
|
|
665
687
|
}
|
|
666
688
|
|
package/schema/validate.ts
CHANGED
|
@@ -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
|
+
# 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.
|
|
7
|
-
**Authors:** Rustam Samandarov
|
|
8
|
-
**Last updated:** 2026-03-
|
|
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.
|
|
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.
|
|
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.
|
|
4145
|
+
*OpenUISpec v0.2 — Draft specification. Subject to revision.*
|