openuispec 0.1.16 → 0.1.18
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 -3
- package/cli/init.ts +6 -4
- package/examples/taskflow/contracts/action_trigger.yaml +3 -6
- package/examples/taskflow/contracts/collection.yaml +3 -6
- package/examples/taskflow/contracts/data_display.yaml +3 -6
- package/examples/taskflow/contracts/feedback.yaml +3 -6
- package/examples/taskflow/contracts/input_field.yaml +22 -6
- package/examples/taskflow/contracts/nav_container.yaml +3 -6
- package/examples/taskflow/contracts/surface.yaml +3 -6
- package/examples/taskflow/screens/profile_edit.yaml +2 -0
- package/package.json +1 -1
- package/schema/contract.schema.json +129 -0
- package/schema/validate.ts +278 -10
- package/spec/openuispec-v0.1.md +5 -2
package/README.md
CHANGED
|
@@ -70,7 +70,8 @@ openuispec/
|
|
|
70
70
|
│ ├── flow.schema.json # Navigation flow schema
|
|
71
71
|
│ ├── platform.schema.json # Platform adaptation schema
|
|
72
72
|
│ ├── locale.schema.json # Locale file schema
|
|
73
|
-
│ ├──
|
|
73
|
+
│ ├── contract.schema.json # Standard contract extension schema
|
|
74
|
+
│ ├── custom-contract.schema.json # Custom contract extension schema (x_ prefixed)
|
|
74
75
|
│ ├── tokens/
|
|
75
76
|
│ │ ├── color.schema.json # Color token schema
|
|
76
77
|
│ │ ├── typography.schema.json # Typography token schema
|
|
@@ -99,7 +100,8 @@ openuispec/
|
|
|
99
100
|
│ │ ├── layout.yaml # Size classes, primitives, reflow rules
|
|
100
101
|
│ │ ├── themes.yaml # Light, dark, warm variants
|
|
101
102
|
│ │ └── icons.yaml # Icon registry with platform mappings
|
|
102
|
-
│ ├── contracts/ #
|
|
103
|
+
│ ├── contracts/ # Standard contract extensions + custom contracts
|
|
104
|
+
│ │ ├── input_field.yaml # Standard contract with cut_corner variant
|
|
103
105
|
│ │ └── x_media_player.yaml # Custom media player contract (Section 12)
|
|
104
106
|
│ ├── screens/
|
|
105
107
|
│ │ ├── home.yaml # Task list with search, filters, FAB, adaptive nav
|
|
@@ -138,7 +140,8 @@ Every file type has a corresponding JSON Schema in `schema/`. **Read the schema
|
|
|
138
140
|
| `flows/*.yaml` | `flow.schema.json` | `<flow_id>` | [create_task.yaml](./examples/taskflow/flows/create_task.yaml) |
|
|
139
141
|
| `platform/*.yaml` | `platform.schema.json` | `platform` | [ios.yaml](./examples/taskflow/platform/ios.yaml) |
|
|
140
142
|
| `locales/*.json` | `locale.schema.json` | (object) | [en.json](./examples/taskflow/locales/en.json) |
|
|
141
|
-
| `contracts
|
|
143
|
+
| `contracts/<name>.yaml` | `contract.schema.json` | `<contract_name>` | [input_field.yaml](./examples/taskflow/contracts/input_field.yaml) |
|
|
144
|
+
| `contracts/x_*.yaml` | `custom-contract.schema.json` | `<x_name>` | [x_media_player.yaml](./examples/taskflow/contracts/x_media_player.yaml) |
|
|
142
145
|
| `tokens/color.yaml` | `tokens/color.schema.json` | `color` | [color.yaml](./examples/taskflow/tokens/color.yaml) |
|
|
143
146
|
| `tokens/typography.yaml` | `tokens/typography.schema.json` | `typography` | [typography.yaml](./examples/taskflow/tokens/typography.yaml) |
|
|
144
147
|
| `tokens/spacing.yaml` | `tokens/spacing.schema.json` | `spacing` | [spacing.yaml](./examples/taskflow/tokens/spacing.yaml) |
|
package/cli/init.ts
CHANGED
|
@@ -137,7 +137,7 @@ OpenUISpec is a YAML-based format that describes your app's UI semantically —
|
|
|
137
137
|
| \`tokens/\` | Design tokens — colors, typography, spacing, elevation, motion, icons, themes |
|
|
138
138
|
| \`screens/\` | Screen definitions — one YAML file per screen |
|
|
139
139
|
| \`flows/\` | Navigation flows — multi-step user journeys |
|
|
140
|
-
| \`contracts/\` | Component contracts —
|
|
140
|
+
| \`contracts/\` | Component contracts — standard extensions (variants, tokens) and custom (\`x_\` prefixed) |
|
|
141
141
|
| \`platform/\` | Platform overrides — per-target (iOS, Android, Web) behaviors |
|
|
142
142
|
| \`locales/\` | Localization — i18n strings (JSON, ICU MessageFormat) |
|
|
143
143
|
|
|
@@ -222,7 +222,8 @@ Root keys: \`color\`, \`typography\`, \`spacing\`, \`elevation\`, \`motion\`, \`
|
|
|
222
222
|
| \`flows/*.yaml\` | \`flow.schema.json\` | \`<flow_id>\` |
|
|
223
223
|
| \`platform/*.yaml\` | \`platform.schema.json\` | \`platform\` |
|
|
224
224
|
| \`locales/*.json\` | \`locale.schema.json\` | (object) |
|
|
225
|
-
| \`contracts
|
|
225
|
+
| \`contracts/<name>.yaml\` | \`contract.schema.json\` | \`<contract_name>\` |
|
|
226
|
+
| \`contracts/x_*.yaml\` | \`custom-contract.schema.json\` | \`<x_name>\` |
|
|
226
227
|
| \`tokens/color.yaml\` | \`tokens/color.schema.json\` | \`color\` |
|
|
227
228
|
| \`tokens/typography.yaml\` | \`tokens/typography.schema.json\` | \`typography\` |
|
|
228
229
|
| \`tokens/spacing.yaml\` | \`tokens/spacing.schema.json\` | \`spacing\` |
|
|
@@ -300,7 +301,7 @@ OpenUISpec is a YAML-based spec format that describes an app's UI semantically
|
|
|
300
301
|
- Tokens: \`${specDir}/tokens/\` — colors, typography, spacing, motion, icons, themes
|
|
301
302
|
- Screens: \`${specDir}/screens/\` — one YAML file per screen
|
|
302
303
|
- Flows: \`${specDir}/flows/\` — multi-step navigation journeys
|
|
303
|
-
- Contracts: \`${specDir}/contracts/\` —
|
|
304
|
+
- Contracts: \`${specDir}/contracts/\` — standard extensions (variants, tokens) and custom (\`x_\` prefixed)
|
|
304
305
|
- Platform: \`${specDir}/platform/\` — per-target overrides (iOS, Android, Web)
|
|
305
306
|
- Locales: \`${specDir}/locales/\` — i18n strings (JSON, ICU MessageFormat)
|
|
306
307
|
|
|
@@ -373,7 +374,8 @@ Before creating or editing any spec file, read the corresponding JSON Schema. Do
|
|
|
373
374
|
| \`flows/*.yaml\` | \`flow.schema.json\` | \`<flow_id>\` |
|
|
374
375
|
| \`platform/*.yaml\` | \`platform.schema.json\` | \`platform\` |
|
|
375
376
|
| \`locales/*.json\` | \`locale.schema.json\` | (object) |
|
|
376
|
-
| \`contracts
|
|
377
|
+
| \`contracts/<name>.yaml\` | \`contract.schema.json\` | \`<contract_name>\` |
|
|
378
|
+
| \`contracts/x_*.yaml\` | \`custom-contract.schema.json\` | \`<x_name>\` |
|
|
377
379
|
| \`tokens/color.yaml\` | \`tokens/color.schema.json\` | \`color\` |
|
|
378
380
|
| \`tokens/typography.yaml\` | \`tokens/typography.schema.json\` | \`typography\` |
|
|
379
381
|
| \`tokens/spacing.yaml\` | \`tokens/spacing.schema.json\` | \`spacing\` |
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
# action_trigger contract
|
|
2
|
-
#
|
|
3
|
-
# The canonical definition lives in spec/openuispec-v0.1.md.
|
|
1
|
+
# action_trigger contract extension
|
|
2
|
+
# Base definition: spec Section 4.1
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
spec_section: "4"
|
|
7
|
-
source: "spec/openuispec-v0.1.md"
|
|
4
|
+
action_trigger: {}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
# collection contract
|
|
2
|
-
#
|
|
3
|
-
# The canonical definition lives in spec/openuispec-v0.1.md.
|
|
1
|
+
# collection contract extension
|
|
2
|
+
# Base definition: spec Section 4.7
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
spec_section: "4"
|
|
7
|
-
source: "spec/openuispec-v0.1.md"
|
|
4
|
+
collection: {}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
# data_display contract
|
|
2
|
-
#
|
|
3
|
-
# The canonical definition lives in spec/openuispec-v0.1.md.
|
|
1
|
+
# data_display contract extension
|
|
2
|
+
# Base definition: spec Section 4.2
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
spec_section: "4"
|
|
7
|
-
source: "spec/openuispec-v0.1.md"
|
|
4
|
+
data_display: {}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
# feedback contract
|
|
2
|
-
#
|
|
3
|
-
# The canonical definition lives in spec/openuispec-v0.1.md.
|
|
1
|
+
# feedback contract extension
|
|
2
|
+
# Base definition: spec Section 4.5
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
spec_section: "4"
|
|
7
|
-
source: "spec/openuispec-v0.1.md"
|
|
4
|
+
feedback: {}
|
|
@@ -1,7 +1,23 @@
|
|
|
1
|
-
# input_field contract
|
|
2
|
-
#
|
|
3
|
-
#
|
|
1
|
+
# input_field contract extension
|
|
2
|
+
# Base definition: spec Section 4.3
|
|
3
|
+
# Add project-specific variants, token overrides, and generation hints.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
input_field:
|
|
6
|
+
variants:
|
|
7
|
+
cut_corner:
|
|
8
|
+
semantic: "Angled corner input for branded forms"
|
|
9
|
+
tokens:
|
|
10
|
+
cut_size: "spacing.sm"
|
|
11
|
+
border: { color: "color.semantic.border", width: 1 }
|
|
12
|
+
background: "color.semantic.surface"
|
|
13
|
+
platform_mapping:
|
|
14
|
+
ios: { shape: "CutCornerShape", clip: true }
|
|
15
|
+
android: { shape: "CutCornerShape" }
|
|
16
|
+
web: { style: "clip-path" }
|
|
17
|
+
generation:
|
|
18
|
+
must_handle:
|
|
19
|
+
- "Cut top-right and bottom-left corners by cut_size"
|
|
20
|
+
- "Maintain focus ring that follows the cut shape"
|
|
21
|
+
- "Placeholder text must remain readable against background"
|
|
22
|
+
should_handle:
|
|
23
|
+
- "Animate corner cut on focus"
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
# nav_container contract
|
|
2
|
-
#
|
|
3
|
-
# The canonical definition lives in spec/openuispec-v0.1.md.
|
|
1
|
+
# nav_container contract extension
|
|
2
|
+
# Base definition: spec Section 4.4
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
spec_section: "4"
|
|
7
|
-
source: "spec/openuispec-v0.1.md"
|
|
4
|
+
nav_container: {}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
# surface contract
|
|
2
|
-
#
|
|
3
|
-
# The canonical definition lives in spec/openuispec-v0.1.md.
|
|
1
|
+
# surface contract extension
|
|
2
|
+
# Base definition: spec Section 4.6
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
spec_section: "4"
|
|
7
|
-
source: "spec/openuispec-v0.1.md"
|
|
4
|
+
surface: {}
|
package/package.json
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openuispec.org/schema/contract.schema.json",
|
|
4
|
+
"title": "OpenUISpec Standard Contract Extension",
|
|
5
|
+
"description": "Extension file for a standard contract — add variants, override tokens, platform mapping, and generation hints",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"minProperties": 1,
|
|
8
|
+
"maxProperties": 1,
|
|
9
|
+
"propertyNames": {
|
|
10
|
+
"enum": [
|
|
11
|
+
"action_trigger",
|
|
12
|
+
"data_display",
|
|
13
|
+
"input_field",
|
|
14
|
+
"nav_container",
|
|
15
|
+
"feedback",
|
|
16
|
+
"surface",
|
|
17
|
+
"collection"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"additionalProperties": {
|
|
21
|
+
"$ref": "#/$defs/contract_extension"
|
|
22
|
+
},
|
|
23
|
+
"$defs": {
|
|
24
|
+
"contract_extension": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"description": "Extension definition — all fields are optional, they add to or override the spec-defined contract",
|
|
27
|
+
"properties": {
|
|
28
|
+
"variants": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"description": "Named style/behavior variants (e.g. cut_corner, branded, minimal)",
|
|
31
|
+
"additionalProperties": {
|
|
32
|
+
"$ref": "#/$defs/variant_def"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"additional_props": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"description": "Additional props beyond the spec definition",
|
|
38
|
+
"additionalProperties": {
|
|
39
|
+
"$ref": "https://openuispec.org/schema/custom-contract.schema.json#/$defs/prop_def"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"tokens": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"description": "Token overrides at the contract level",
|
|
45
|
+
"additionalProperties": true
|
|
46
|
+
},
|
|
47
|
+
"platform_mapping": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"description": "Platform mapping overrides",
|
|
50
|
+
"properties": {
|
|
51
|
+
"ios": { "type": "object", "additionalProperties": true },
|
|
52
|
+
"android": { "type": "object", "additionalProperties": true },
|
|
53
|
+
"web": { "type": "object", "additionalProperties": true }
|
|
54
|
+
},
|
|
55
|
+
"additionalProperties": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"generation": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"description": "AI generation compliance hints (merged with spec defaults)",
|
|
63
|
+
"properties": {
|
|
64
|
+
"must_handle": {
|
|
65
|
+
"type": "array",
|
|
66
|
+
"items": { "type": "string" }
|
|
67
|
+
},
|
|
68
|
+
"should_handle": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": { "type": "string" }
|
|
71
|
+
},
|
|
72
|
+
"may_handle": {
|
|
73
|
+
"type": "array",
|
|
74
|
+
"items": { "type": "string" }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"additionalProperties": false
|
|
78
|
+
},
|
|
79
|
+
"test_cases": {
|
|
80
|
+
"type": "array",
|
|
81
|
+
"description": "Additional behavioral test cases",
|
|
82
|
+
"items": {
|
|
83
|
+
"$ref": "https://openuispec.org/schema/custom-contract.schema.json#/$defs/test_case"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"additionalProperties": false
|
|
88
|
+
},
|
|
89
|
+
"variant_def": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"description": "A named variant with semantic description, tokens, platform mapping, and generation hints",
|
|
92
|
+
"properties": {
|
|
93
|
+
"semantic": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Human-readable description of this variant"
|
|
96
|
+
},
|
|
97
|
+
"tokens": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"description": "Visual token bindings for this variant",
|
|
100
|
+
"additionalProperties": true
|
|
101
|
+
},
|
|
102
|
+
"platform_mapping": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"description": "Per-platform implementation hints for this variant",
|
|
105
|
+
"properties": {
|
|
106
|
+
"ios": { "type": "object", "additionalProperties": true },
|
|
107
|
+
"android": { "type": "object", "additionalProperties": true },
|
|
108
|
+
"web": { "type": "object", "additionalProperties": true }
|
|
109
|
+
},
|
|
110
|
+
"additionalProperties": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"additionalProperties": true
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"generation": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"description": "Generation hints specific to this variant",
|
|
118
|
+
"properties": {
|
|
119
|
+
"must_handle": { "type": "array", "items": { "type": "string" } },
|
|
120
|
+
"should_handle": { "type": "array", "items": { "type": "string" } },
|
|
121
|
+
"may_handle": { "type": "array", "items": { "type": "string" } }
|
|
122
|
+
},
|
|
123
|
+
"additionalProperties": false
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"additionalProperties": false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
package/schema/validate.ts
CHANGED
|
@@ -23,6 +23,18 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
|
23
23
|
const SCHEMA_DIR = resolve(__dirname);
|
|
24
24
|
|
|
25
25
|
type AjvInstance = InstanceType<typeof Ajv2020>;
|
|
26
|
+
type UnknownRecord = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
interface UsageLint {
|
|
29
|
+
path: string;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StandardContractRule {
|
|
34
|
+
requiredProps?: string[];
|
|
35
|
+
nonEmptyStringProps?: string[];
|
|
36
|
+
validate?: (node: UnknownRecord, props: UnknownRecord, path: string) => UsageLint[];
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
28
40
|
|
|
@@ -49,6 +61,255 @@ function listFiles(dir: string, ext: string): string[] {
|
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
69
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getSingleRootValue(data: unknown): unknown {
|
|
73
|
+
if (!isRecord(data)) return undefined;
|
|
74
|
+
const values = Object.values(data);
|
|
75
|
+
return values.length === 1 ? values[0] : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const STANDARD_CONTRACT_RULES: Record<string, StandardContractRule> = {
|
|
79
|
+
action_trigger: {
|
|
80
|
+
requiredProps: ["label"],
|
|
81
|
+
nonEmptyStringProps: ["label"],
|
|
82
|
+
},
|
|
83
|
+
data_display: {
|
|
84
|
+
requiredProps: ["title"],
|
|
85
|
+
nonEmptyStringProps: ["title"],
|
|
86
|
+
},
|
|
87
|
+
input_field: {
|
|
88
|
+
requiredProps: ["label"],
|
|
89
|
+
nonEmptyStringProps: ["label"],
|
|
90
|
+
validate(node, props, path) {
|
|
91
|
+
const inputType = node.input_type;
|
|
92
|
+
if (inputType === "select" || inputType === "radio") {
|
|
93
|
+
return hasOwnProp(props, "options")
|
|
94
|
+
? []
|
|
95
|
+
: [{
|
|
96
|
+
path,
|
|
97
|
+
message: `contract "input_field" with input_type="${String(inputType)}" requires props.options`,
|
|
98
|
+
}];
|
|
99
|
+
}
|
|
100
|
+
if (inputType === "slider") {
|
|
101
|
+
return hasOwnProp(props, "range")
|
|
102
|
+
? []
|
|
103
|
+
: [{
|
|
104
|
+
path,
|
|
105
|
+
message: 'contract "input_field" with input_type="slider" requires props.range',
|
|
106
|
+
}];
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
nav_container: {
|
|
112
|
+
requiredProps: ["items"],
|
|
113
|
+
validate(_node, props, path) {
|
|
114
|
+
const items = props.items;
|
|
115
|
+
if (!Array.isArray(items)) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
const errors: UsageLint[] = [];
|
|
119
|
+
for (const [index, item] of items.entries()) {
|
|
120
|
+
const itemPath = `${path}/props/items/${index}`;
|
|
121
|
+
if (!isRecord(item)) {
|
|
122
|
+
errors.push({
|
|
123
|
+
path: itemPath,
|
|
124
|
+
message: "nav_container items must be objects",
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
for (const key of ["id", "label", "icon", "destination"]) {
|
|
129
|
+
if (!hasOwnProp(item, key)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
path: itemPath,
|
|
132
|
+
message: `nav_container item requires "${key}"`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (hasOwnProp(item, "label") && !isNonEmptyString(item.label)) {
|
|
137
|
+
errors.push({
|
|
138
|
+
path: `${itemPath}/label`,
|
|
139
|
+
message: 'nav_container item "label" must be a non-empty string',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return errors;
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
feedback: {
|
|
147
|
+
requiredProps: ["message"],
|
|
148
|
+
nonEmptyStringProps: ["message"],
|
|
149
|
+
},
|
|
150
|
+
surface: {
|
|
151
|
+
requiredProps: ["content"],
|
|
152
|
+
},
|
|
153
|
+
collection: {
|
|
154
|
+
requiredProps: ["data", "item_contract", "item_props_map"],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function hasOwnProp(obj: UnknownRecord, key: string): boolean {
|
|
159
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateStandardContractUsage(
|
|
163
|
+
node: UnknownRecord,
|
|
164
|
+
path: string,
|
|
165
|
+
): UsageLint[] {
|
|
166
|
+
const contract = node.contract;
|
|
167
|
+
if (typeof contract !== "string") return [];
|
|
168
|
+
|
|
169
|
+
const rule = STANDARD_CONTRACT_RULES[contract];
|
|
170
|
+
if (!rule) return [];
|
|
171
|
+
|
|
172
|
+
const props = isRecord(node.props) ? node.props : {};
|
|
173
|
+
const errors: UsageLint[] = [];
|
|
174
|
+
|
|
175
|
+
for (const prop of rule.requiredProps ?? []) {
|
|
176
|
+
if (!hasOwnProp(props, prop)) {
|
|
177
|
+
errors.push({
|
|
178
|
+
path,
|
|
179
|
+
message: `contract "${contract}" requires props.${prop}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const prop of rule.nonEmptyStringProps ?? []) {
|
|
185
|
+
if (hasOwnProp(props, prop) && !isNonEmptyString(props[prop])) {
|
|
186
|
+
errors.push({
|
|
187
|
+
path: `${path}/props/${prop}`,
|
|
188
|
+
message: `props.${prop} for contract "${contract}" must be a non-empty string`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
errors.push(...(rule.validate?.(node, props, path) ?? []));
|
|
194
|
+
return errors;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function lintSectionItems(items: unknown, path: string): UsageLint[] {
|
|
198
|
+
if (!Array.isArray(items)) return [];
|
|
199
|
+
const errors: UsageLint[] = [];
|
|
200
|
+
|
|
201
|
+
for (const [index, item] of items.entries()) {
|
|
202
|
+
const itemPath = `${path}/${index}`;
|
|
203
|
+
if (!isRecord(item)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
errors.push(...validateStandardContractUsage(item, itemPath));
|
|
208
|
+
|
|
209
|
+
if (Array.isArray(item.children)) {
|
|
210
|
+
errors.push(...lintSectionItems(item.children, `${itemPath}/children`));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return errors;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function lintScreenLikeDefinition(screenDef: unknown, path: string): UsageLint[] {
|
|
218
|
+
if (!isRecord(screenDef)) return [];
|
|
219
|
+
const errors: UsageLint[] = [];
|
|
220
|
+
|
|
221
|
+
if (isRecord(screenDef.layout)) {
|
|
222
|
+
errors.push(
|
|
223
|
+
...lintSectionItems(screenDef.layout.sections, `${path}/layout/sections`),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (isRecord(screenDef.navigation)) {
|
|
228
|
+
errors.push(
|
|
229
|
+
...validateStandardContractUsage(
|
|
230
|
+
screenDef.navigation,
|
|
231
|
+
`${path}/navigation`,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isRecord(screenDef.surfaces)) {
|
|
237
|
+
for (const [surfaceId, surfaceDef] of Object.entries(screenDef.surfaces)) {
|
|
238
|
+
const surfacePath = `${path}/surfaces/${surfaceId}`;
|
|
239
|
+
if (!isRecord(surfaceDef)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
errors.push(...validateStandardContractUsage(surfaceDef, surfacePath));
|
|
244
|
+
|
|
245
|
+
const props = isRecord(surfaceDef.props) ? surfaceDef.props : {};
|
|
246
|
+
if (Array.isArray(props.content)) {
|
|
247
|
+
errors.push(
|
|
248
|
+
...lintSectionItems(props.content, `${surfacePath}/props/content`),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return errors;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function lintScreenFile(dataPath: string): number {
|
|
258
|
+
const root = getSingleRootValue(loadData(dataPath));
|
|
259
|
+
const errors = lintScreenLikeDefinition(root, basename(dataPath));
|
|
260
|
+
if (errors.length === 0) {
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(` FAIL ${basename(dataPath)} (${errors.length} contract usage error(s))`);
|
|
265
|
+
for (const error of errors.slice(0, 5)) {
|
|
266
|
+
console.log(` [${error.path}] ${error.message}`);
|
|
267
|
+
}
|
|
268
|
+
if (errors.length > 5) {
|
|
269
|
+
console.log(` ... and ${errors.length - 5} more`);
|
|
270
|
+
}
|
|
271
|
+
console.log(
|
|
272
|
+
" Hint: built-in contract instances inherit required props from the spec even when contracts/<name>.yaml does not restate them.",
|
|
273
|
+
);
|
|
274
|
+
return errors.length;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function lintFlowFile(dataPath: string): number {
|
|
278
|
+
const root = getSingleRootValue(loadData(dataPath));
|
|
279
|
+
if (!isRecord(root) || !isRecord(root.screens)) {
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const errors: UsageLint[] = [];
|
|
284
|
+
for (const [screenId, screenEntry] of Object.entries(root.screens)) {
|
|
285
|
+
if (!isRecord(screenEntry) || !isRecord(screenEntry.screen_inline)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
errors.push(
|
|
289
|
+
...lintScreenLikeDefinition(
|
|
290
|
+
screenEntry.screen_inline,
|
|
291
|
+
`${basename(dataPath)}/screens/${screenId}/screen_inline`,
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (errors.length === 0) {
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
console.log(` FAIL ${basename(dataPath)} (${errors.length} contract usage error(s))`);
|
|
301
|
+
for (const error of errors.slice(0, 5)) {
|
|
302
|
+
console.log(` [${error.path}] ${error.message}`);
|
|
303
|
+
}
|
|
304
|
+
if (errors.length > 5) {
|
|
305
|
+
console.log(` ... and ${errors.length - 5} more`);
|
|
306
|
+
}
|
|
307
|
+
console.log(
|
|
308
|
+
" Hint: flow screen_inline sections follow the same built-in contract requirements as screens/*.yaml.",
|
|
309
|
+
);
|
|
310
|
+
return errors.length;
|
|
311
|
+
}
|
|
312
|
+
|
|
52
313
|
// ── build Ajv instance with all schemas ──────────────────────────────
|
|
53
314
|
|
|
54
315
|
function buildAjv(): AjvInstance {
|
|
@@ -233,7 +494,11 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
233
494
|
let errors = 0;
|
|
234
495
|
const dir = resolveInclude(projectDir, includes.screens);
|
|
235
496
|
for (const f of listFiles(dir, ".yaml")) {
|
|
236
|
-
|
|
497
|
+
const schemaErrors = validateFile(ajv, f, `${BASE}screen.schema.json`);
|
|
498
|
+
errors += schemaErrors;
|
|
499
|
+
if (schemaErrors === 0) {
|
|
500
|
+
errors += lintScreenFile(f);
|
|
501
|
+
}
|
|
237
502
|
}
|
|
238
503
|
return errors;
|
|
239
504
|
},
|
|
@@ -245,7 +510,11 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
245
510
|
let errors = 0;
|
|
246
511
|
const dir = resolveInclude(projectDir, includes.flows);
|
|
247
512
|
for (const f of listFiles(dir, ".yaml")) {
|
|
248
|
-
|
|
513
|
+
const schemaErrors = validateFile(ajv, f, `${BASE}flow.schema.json`);
|
|
514
|
+
errors += schemaErrors;
|
|
515
|
+
if (schemaErrors === 0) {
|
|
516
|
+
errors += lintFlowFile(f);
|
|
517
|
+
}
|
|
249
518
|
}
|
|
250
519
|
return errors;
|
|
251
520
|
},
|
|
@@ -275,18 +544,17 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
275
544
|
},
|
|
276
545
|
},
|
|
277
546
|
|
|
278
|
-
|
|
279
|
-
label: "
|
|
547
|
+
contracts: {
|
|
548
|
+
label: "Contracts",
|
|
280
549
|
run(ajv, projectDir, includes) {
|
|
281
550
|
let errors = 0;
|
|
282
551
|
const dir = resolveInclude(projectDir, includes.contracts);
|
|
283
552
|
for (const f of listFiles(dir, ".yaml")) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
);
|
|
553
|
+
const name = basename(f);
|
|
554
|
+
if (name.startsWith("x_")) {
|
|
555
|
+
errors += validateFile(ajv, f, `${BASE}custom-contract.schema.json`);
|
|
556
|
+
} else {
|
|
557
|
+
errors += validateFile(ajv, f, `${BASE}contract.schema.json`);
|
|
290
558
|
}
|
|
291
559
|
}
|
|
292
560
|
return errors;
|
package/spec/openuispec-v0.1.md
CHANGED
|
@@ -1392,6 +1392,7 @@ collection:
|
|
|
1392
1392
|
grid: "grid"
|
|
1393
1393
|
table: "table"
|
|
1394
1394
|
carousel: "region"
|
|
1395
|
+
label: "Derived from visible header text or surrounding section heading; bind with aria-labelledby or platform equivalent rather than a dedicated prop when possible"
|
|
1395
1396
|
item_role:
|
|
1396
1397
|
list: "listitem"
|
|
1397
1398
|
grid: "gridcell"
|
|
@@ -1469,6 +1470,8 @@ collection:
|
|
|
1469
1470
|
- "Item recycling / virtualization for large datasets"
|
|
1470
1471
|
```
|
|
1471
1472
|
|
|
1473
|
+
Collection containers still need an accessible name, but unlike controls such as buttons or fields, that name usually comes from visible context rather than a dedicated `label` prop. Generators SHOULD connect the collection to a visible heading or header component via `aria-labelledby` or the platform equivalent, and only fall back to an explicit synthesized label when no visible label source exists.
|
|
1474
|
+
|
|
1472
1475
|
---
|
|
1473
1476
|
|
|
1474
1477
|
## 5. Screen composition
|
|
@@ -2022,7 +2025,7 @@ Every AI generator, regardless of platform target, MUST:
|
|
|
2022
2025
|
2. Map every `contract` reference to the correct native widget per `platform_mapping`.
|
|
2023
2026
|
3. Apply all `tokens` values within their declared `range` constraints.
|
|
2024
2027
|
4. Implement every `state` declared in each used contract, including transitions.
|
|
2025
|
-
5. Set correct `a11y.role` and `a11y.label` for every component instance.
|
|
2028
|
+
5. Set correct `a11y.role` and `a11y.label` for every component instance. For contextual containers such as `collection`, derive the label from the visible heading/header via `aria-labelledby` or the platform equivalent when possible instead of requiring a dedicated prop.
|
|
2026
2029
|
6. Respect `themes` by generating light/dark mode support.
|
|
2027
2030
|
7. Handle `empty`, `loading`, and `error` states for `collection` contracts.
|
|
2028
2031
|
8. Wire all `action.navigate` declarations to the platform's navigation system.
|
|
@@ -2039,7 +2042,7 @@ A valid OpenUISpec document:
|
|
|
2039
2042
|
- Contains a root `openuispec.yaml` manifest with `spec_version`
|
|
2040
2043
|
- References only defined tokens, contracts, screens, and flows
|
|
2041
2044
|
- Has no circular `flow` transitions
|
|
2042
|
-
- Has every `required: true` prop satisfied in screen compositions
|
|
2045
|
+
- Has every `required: true` prop satisfied in screen compositions, including required props inherited from built-in standard contract families even when `contracts/<name>.yaml` does not restate them
|
|
2043
2046
|
- Has every `screen.params` satisfied by its callers
|
|
2044
2047
|
|
|
2045
2048
|
### 8.4 Drift detection
|