openuispec 0.1.17 → 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.
@@ -23,6 +23,8 @@ profile_edit:
23
23
  - contract: data_display
24
24
  variant: inline
25
25
  props:
26
+ title: "user.name"
27
+ subtitle: "user.email"
26
28
  leading:
27
29
  media: "user.avatar"
28
30
  fallback: { initials: "user.name", background: "color.brand.primary" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
@@ -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
- errors += validateFile(ajv, f, `${BASE}screen.schema.json`);
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
- errors += validateFile(ajv, f, `${BASE}flow.schema.json`);
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
  },
@@ -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