openuispec 0.2.12 → 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 (64) hide show
  1. package/README.md +8 -7
  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 +52 -2
  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 +5 -3
  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/custom-contract.schema.json +2 -2
  57. package/schema/openuispec.schema.json +18 -8
  58. package/schema/screen.schema.json +12 -1
  59. package/schema/semantic-lint.ts +24 -2
  60. package/schema/validate.ts +21 -0
  61. package/scripts/regenerate-previews.ts +136 -0
  62. package/spec/{openuispec-v0.1.md → openuispec-v0.2.md} +275 -17
  63. package/examples/taskflow/openuispec/contracts/x_media_player.yaml +0 -185
  64. package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +0 -139
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "states": {
32
32
  "type": "object",
33
- "description": "State machine definition — each key is a state name",
33
+ "description": "UI interaction states (e.g. idle, active, disabled, loading, error) — each key is a state name. These are visual/interaction states, not business logic states.",
34
34
  "additionalProperties": {
35
35
  "$ref": "https://openuispec.rsteam.uz/schema/custom-contract.schema.json#/$defs/state_def"
36
36
  }
@@ -165,7 +165,7 @@
165
165
  },
166
166
  "state_def": {
167
167
  "type": "object",
168
- "description": "A single state in the state machine",
168
+ "description": "A single UI interaction state",
169
169
  "properties": {
170
170
  "semantic": {
171
171
  "type": "string"
@@ -38,22 +38,32 @@
38
38
  "description": "Paths to spec component directories",
39
39
  "properties": {
40
40
  "tokens": {
41
- "type": "string"
41
+ "type": "string",
42
+ "description": "Visual design tokens — colors, typography, spacing, elevation, motion. Affects UI rendering only, not business logic."
42
43
  },
43
44
  "contracts": {
44
- "type": "string"
45
+ "type": "string",
46
+ "description": "Reusable UI component definitions — buttons, inputs, lists, surfaces. Each contract specifies visual props, UI interaction states (pressed, disabled, loading), and accessibility. These are UI rendering specifications, not business logic or domain state machines."
45
47
  },
46
48
  "screens": {
47
- "type": "string"
49
+ "type": "string",
50
+ "description": "Screen layouts composed from contracts — defines what UI components appear on each screen, their data bindings, and visual arrangement."
48
51
  },
49
52
  "flows": {
50
- "type": "string"
53
+ "type": "string",
54
+ "description": "Multi-screen navigation journeys — defines screen sequences, transitions, and navigation triggers. UI navigation structure, not business workflow logic."
51
55
  },
52
56
  "platform": {
53
- "type": "string"
57
+ "type": "string",
58
+ "description": "Per-target platform overrides — native UI behavior, framework-specific generation config. One file per target (ios.yaml, android.yaml, web.yaml)."
54
59
  },
55
60
  "locales": {
56
- "type": "string"
61
+ "type": "string",
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."
57
67
  }
58
68
  },
59
69
  "required": [
@@ -175,10 +185,10 @@
175
185
  },
176
186
  "tracks": {
177
187
  "type": "array",
178
- "description": "Spec categories this shared layer tracks for drift. Must be explicitchoose which categories actually affect the shared code.",
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).",
179
189
  "items": {
180
190
  "type": "string",
181
- "enum": ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"]
191
+ "enum": ["manifest", "tokens", "contracts", "components", "screens", "flows", "platform", "locales"]
182
192
  }
183
193
  },
184
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
+ });