openuispec 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +6 -5
  2. package/cli/index.ts +18 -12
  3. package/cli/init.ts +79 -13
  4. package/docs/cli.md +134 -27
  5. package/docs/file-formats.md +51 -1
  6. package/drift/index.ts +7 -2
  7. package/examples/social-app/openuispec/README.md +2 -1
  8. package/examples/social-app/openuispec/mock/chat_detail.yaml +25 -0
  9. package/examples/social-app/openuispec/mock/discover.yaml +17 -0
  10. package/examples/social-app/openuispec/mock/edit_profile.yaml +9 -0
  11. package/examples/social-app/openuispec/mock/home_feed.yaml +32 -0
  12. package/examples/social-app/openuispec/mock/messages_inbox.yaml +15 -0
  13. package/examples/social-app/openuispec/mock/notifications.yaml +30 -0
  14. package/examples/social-app/openuispec/mock/post_detail.yaml +26 -0
  15. package/examples/social-app/openuispec/mock/profile_self.yaml +28 -0
  16. package/examples/social-app/openuispec/mock/profile_user.yaml +32 -0
  17. package/examples/social-app/openuispec/mock/search_results.yaml +17 -0
  18. package/examples/social-app/openuispec/mock/settings.yaml +7 -0
  19. package/examples/social-app/openuispec/openuispec.yaml +3 -2
  20. package/examples/taskflow/README.md +4 -2
  21. package/examples/taskflow/openuispec/README.md +2 -1
  22. package/examples/taskflow/openuispec/components/media_player.yaml +92 -0
  23. package/examples/taskflow/openuispec/contracts/README.md +2 -2
  24. package/examples/taskflow/openuispec/locales/en.json +1 -0
  25. package/examples/taskflow/openuispec/mock/home.yaml +64 -0
  26. package/examples/taskflow/openuispec/mock/profile_edit.yaml +6 -0
  27. package/examples/taskflow/openuispec/mock/project_detail.yaml +33 -0
  28. package/examples/taskflow/openuispec/mock/settings.yaml +13 -0
  29. package/examples/taskflow/openuispec/mock/task_detail.yaml +18 -0
  30. package/examples/taskflow/openuispec/openuispec.yaml +3 -4
  31. package/examples/taskflow/openuispec/platform/ios.yaml +0 -4
  32. package/examples/taskflow/openuispec/screens/task_detail.yaml +5 -8
  33. package/examples/taskflow/openuispec/tokens/icons.yaml +16 -0
  34. package/examples/todo-orbit/README.md +3 -2
  35. package/examples/todo-orbit/openuispec/README.md +2 -1
  36. package/examples/todo-orbit/openuispec/components/task_trend_chart.yaml +85 -0
  37. package/examples/todo-orbit/openuispec/locales/en.json +3 -0
  38. package/examples/todo-orbit/openuispec/locales/ru.json +3 -0
  39. package/examples/todo-orbit/openuispec/mock/analytics.yaml +26 -0
  40. package/examples/todo-orbit/openuispec/mock/home.yaml +33 -0
  41. package/examples/todo-orbit/openuispec/mock/settings.yaml +7 -0
  42. package/examples/todo-orbit/openuispec/mock/task_detail.yaml +14 -0
  43. package/examples/todo-orbit/openuispec/openuispec.yaml +3 -3
  44. package/examples/todo-orbit/openuispec/platform/android.yaml +0 -3
  45. package/examples/todo-orbit/openuispec/platform/ios.yaml +0 -3
  46. package/examples/todo-orbit/openuispec/platform/web.yaml +0 -3
  47. package/examples/todo-orbit/openuispec/screens/analytics.yaml +1 -4
  48. package/mcp-server/index.ts +87 -6
  49. package/mcp-server/preview-render.ts +1922 -0
  50. package/mcp-server/preview.ts +292 -0
  51. package/mcp-server/screenshot-shared.ts +41 -4
  52. package/mcp-server/screenshot.ts +283 -97
  53. package/package.json +1 -1
  54. package/prepare/index.ts +1 -1
  55. package/schema/component.schema.json +278 -0
  56. package/schema/openuispec.schema.json +5 -1
  57. package/schema/screen.schema.json +12 -1
  58. package/schema/semantic-lint.ts +29 -5
  59. package/schema/validate.ts +21 -0
  60. package/scripts/regenerate-previews.ts +136 -0
  61. package/spec/{openuispec-v0.1.md → openuispec-v0.2.md} +266 -8
  62. package/examples/taskflow/openuispec/contracts/x_media_player.yaml +0 -185
  63. package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +0 -139
@@ -0,0 +1,278 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openuispec.rsteam.uz/schema/component.schema.json",
4
+ "title": "OpenUISpec Component",
5
+ "description": "Reusable composition of contracts with named slots — root key is the component name",
6
+ "type": "object",
7
+ "minProperties": 1,
8
+ "maxProperties": 1,
9
+ "propertyNames": {
10
+ "pattern": "^[a-z][a-z0-9_]*$"
11
+ },
12
+ "additionalProperties": {
13
+ "$ref": "#/$defs/component_def"
14
+ },
15
+ "$defs": {
16
+ "component_def": {
17
+ "type": "object",
18
+ "description": "A component definition — composition of contracts with named slots, states, variants, and layout",
19
+ "properties": {
20
+ "semantic": {
21
+ "type": "string",
22
+ "description": "Human-readable description of this component's purpose"
23
+ },
24
+ "props": {
25
+ "type": "object",
26
+ "description": "Typed property definitions for this component",
27
+ "additionalProperties": {
28
+ "$ref": "https://openuispec.rsteam.uz/schema/custom-contract.schema.json#/$defs/prop_def"
29
+ }
30
+ },
31
+ "slots": {
32
+ "type": "object",
33
+ "description": "Named slots — each slot wraps a contract instance",
34
+ "additionalProperties": {
35
+ "$ref": "#/$defs/slot_def"
36
+ }
37
+ },
38
+ "layout": {
39
+ "$ref": "#/$defs/component_layout"
40
+ },
41
+ "states": {
42
+ "type": "object",
43
+ "description": "Composite states that control slot visibility and props",
44
+ "additionalProperties": {
45
+ "$ref": "#/$defs/component_state_def"
46
+ }
47
+ },
48
+ "variants": {
49
+ "type": "object",
50
+ "description": "Named component variants with layout and slot overrides",
51
+ "additionalProperties": {
52
+ "$ref": "#/$defs/component_variant_def"
53
+ }
54
+ },
55
+ "tokens": {
56
+ "type": "object",
57
+ "description": "Token bindings for the component container",
58
+ "additionalProperties": true
59
+ },
60
+ "a11y": {
61
+ "type": "object",
62
+ "description": "Accessibility requirements",
63
+ "properties": {
64
+ "role": {
65
+ "type": "string",
66
+ "description": "ARIA/accessibility role"
67
+ },
68
+ "label": {
69
+ "type": "string",
70
+ "description": "Accessibility label source"
71
+ }
72
+ },
73
+ "additionalProperties": true
74
+ },
75
+ "platform_mapping": {
76
+ "type": "object",
77
+ "description": "Per-platform native component mapping",
78
+ "properties": {
79
+ "ios": { "type": "object", "additionalProperties": true },
80
+ "android": { "type": "object", "additionalProperties": true },
81
+ "web": { "type": "object", "additionalProperties": true }
82
+ },
83
+ "additionalProperties": {
84
+ "type": "object",
85
+ "additionalProperties": true
86
+ }
87
+ },
88
+ "dependencies": {
89
+ "type": "object",
90
+ "description": "Per-platform library/framework requirements",
91
+ "additionalProperties": {
92
+ "$ref": "https://openuispec.rsteam.uz/schema/custom-contract.schema.json#/$defs/dependency_def"
93
+ }
94
+ },
95
+ "generation": {
96
+ "type": "object",
97
+ "description": "AI generation compliance hints",
98
+ "properties": {
99
+ "must_handle": {
100
+ "type": "array",
101
+ "items": { "type": "string" }
102
+ },
103
+ "should_handle": {
104
+ "type": "array",
105
+ "items": { "type": "string" }
106
+ },
107
+ "may_handle": {
108
+ "type": "array",
109
+ "items": { "type": "string" }
110
+ }
111
+ },
112
+ "additionalProperties": false
113
+ },
114
+ "test_cases": {
115
+ "type": "array",
116
+ "description": "Behavioral verification scenarios",
117
+ "items": {
118
+ "$ref": "https://openuispec.rsteam.uz/schema/custom-contract.schema.json#/$defs/test_case"
119
+ }
120
+ }
121
+ },
122
+ "required": ["semantic", "slots"],
123
+ "additionalProperties": false
124
+ },
125
+ "slot_def": {
126
+ "type": "object",
127
+ "description": "A named slot wrapping a contract instance",
128
+ "properties": {
129
+ "contract": {
130
+ "type": "string",
131
+ "description": "Base contract family (e.g. action_trigger, input_field, data_display)"
132
+ },
133
+ "variant": {
134
+ "type": "string",
135
+ "description": "Contract variant"
136
+ },
137
+ "input_type": {
138
+ "type": "string",
139
+ "description": "Input type for input_field contracts (e.g. slider, text)"
140
+ },
141
+ "props": {
142
+ "type": "object",
143
+ "description": "Default props passed to the contract",
144
+ "additionalProperties": true
145
+ },
146
+ "hideable": {
147
+ "type": "boolean",
148
+ "description": "Whether this slot can be hidden by states or screen overrides"
149
+ },
150
+ "tokens_override": {
151
+ "type": "object",
152
+ "description": "Token overrides for this slot",
153
+ "additionalProperties": true
154
+ }
155
+ },
156
+ "required": ["contract"],
157
+ "additionalProperties": false
158
+ },
159
+ "component_layout": {
160
+ "type": "object",
161
+ "description": "Layout definition for the component",
162
+ "properties": {
163
+ "type": {
164
+ "type": "string",
165
+ "description": "Layout type (e.g. stack, row, grid)"
166
+ },
167
+ "spacing": {
168
+ "type": "string"
169
+ },
170
+ "align": {
171
+ "type": "string"
172
+ },
173
+ "justify": {
174
+ "type": "string"
175
+ },
176
+ "sections": {
177
+ "type": "array",
178
+ "items": {
179
+ "$ref": "#/$defs/component_layout_item"
180
+ }
181
+ }
182
+ },
183
+ "additionalProperties": true
184
+ },
185
+ "component_layout_item": {
186
+ "description": "A layout item — either a slot reference or a nested layout",
187
+ "type": "object",
188
+ "properties": {
189
+ "slot": {
190
+ "type": "string",
191
+ "description": "Reference to a named slot"
192
+ },
193
+ "layout": {
194
+ "$ref": "#/$defs/component_layout"
195
+ }
196
+ },
197
+ "additionalProperties": true
198
+ },
199
+ "component_state_def": {
200
+ "type": "object",
201
+ "description": "A composite state affecting slot visibility and props",
202
+ "properties": {
203
+ "semantic": {
204
+ "type": "string"
205
+ },
206
+ "hide_slots": {
207
+ "type": "array",
208
+ "items": { "type": "string" },
209
+ "description": "Slots to hide in this state"
210
+ },
211
+ "slot_overrides": {
212
+ "type": "object",
213
+ "description": "Per-slot prop/variant overrides in this state",
214
+ "additionalProperties": {
215
+ "$ref": "#/$defs/slot_override"
216
+ }
217
+ },
218
+ "transitions_to": {
219
+ "type": "array",
220
+ "items": { "type": "string" },
221
+ "description": "States this state can transition to"
222
+ }
223
+ },
224
+ "additionalProperties": false
225
+ },
226
+ "component_variant_def": {
227
+ "type": "object",
228
+ "description": "A named component variant with layout and slot overrides",
229
+ "properties": {
230
+ "semantic": {
231
+ "type": "string"
232
+ },
233
+ "hide_slots": {
234
+ "type": "array",
235
+ "items": { "type": "string" },
236
+ "description": "Slots to hide in this variant"
237
+ },
238
+ "layout": {
239
+ "$ref": "#/$defs/component_layout"
240
+ },
241
+ "tokens": {
242
+ "type": "object",
243
+ "description": "Token overrides for this variant",
244
+ "additionalProperties": true
245
+ },
246
+ "slot_overrides": {
247
+ "type": "object",
248
+ "description": "Per-slot prop/variant overrides in this variant",
249
+ "additionalProperties": {
250
+ "$ref": "#/$defs/slot_override"
251
+ }
252
+ }
253
+ },
254
+ "additionalProperties": false
255
+ },
256
+ "slot_override": {
257
+ "type": "object",
258
+ "description": "Override for a specific slot",
259
+ "properties": {
260
+ "variant": {
261
+ "type": "string"
262
+ },
263
+ "props": {
264
+ "type": "object",
265
+ "additionalProperties": true
266
+ },
267
+ "tokens_override": {
268
+ "type": "object",
269
+ "additionalProperties": true
270
+ },
271
+ "hidden": {
272
+ "type": "boolean"
273
+ }
274
+ },
275
+ "additionalProperties": false
276
+ }
277
+ }
278
+ }
@@ -60,6 +60,10 @@
60
60
  "locales": {
61
61
  "type": "string",
62
62
  "description": "Localization string files — one JSON file per supported locale containing translated UI text."
63
+ },
64
+ "components": {
65
+ "type": "string",
66
+ "description": "Reusable component compositions — each YAML file defines a component as a composition of contracts with named slots, states, and variants."
63
67
  }
64
68
  },
65
69
  "required": [
@@ -184,7 +188,7 @@
184
188
  "description": "Spec categories this shared layer tracks for hash-based drift detection. Optional — when omitted, the layer relies on scope alone. Categories: manifest (project config, data models, API endpoints), tokens (visual design tokens — UI only), contracts (reusable UI component definitions — UI only, not business logic), screens (screen layouts — UI only), flows (navigation journeys — UI only), platform (per-target overrides — UI only), locales (translated UI strings).",
185
189
  "items": {
186
190
  "type": "string",
187
- "enum": ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"]
191
+ "enum": ["manifest", "tokens", "contracts", "components", "screens", "flows", "platform", "locales"]
188
192
  }
189
193
  },
190
194
  "scope": {
@@ -171,7 +171,7 @@
171
171
  "additionalProperties": false
172
172
  },
173
173
  "section_item": {
174
- "description": "A section item \u2014 either a contract instance or a section group with children",
174
+ "description": "A section item \u2014 a contract instance, component instance, or section group with children",
175
175
  "type": "object",
176
176
  "properties": {
177
177
  "id": {
@@ -180,6 +180,17 @@
180
180
  "contract": {
181
181
  "type": "string"
182
182
  },
183
+ "component": {
184
+ "type": "string",
185
+ "description": "Component name (alternative to contract). References a component defined in components/*.yaml."
186
+ },
187
+ "slots": {
188
+ "type": "object",
189
+ "description": "Slot overrides when using a component. Each key is a slot name.",
190
+ "additionalProperties": {
191
+ "$ref": "https://openuispec.rsteam.uz/schema/component.schema.json#/$defs/slot_override"
192
+ }
193
+ },
183
194
  "variant": {
184
195
  "type": "string"
185
196
  },
@@ -25,6 +25,7 @@ function collectLocaleKeys(data: unknown, prefix = ""): string[] {
25
25
  export interface Includes {
26
26
  tokens: string;
27
27
  contracts: string;
28
+ components: string;
28
29
  screens: string;
29
30
  flows: string;
30
31
  platform: string;
@@ -43,6 +44,7 @@ interface SemanticContext {
43
44
  formatterNames: Set<string>;
44
45
  mapperNames: Set<string>;
45
46
  contractNames: Set<string>;
47
+ componentNames: Set<string>;
46
48
  tokenRefs: Set<string>;
47
49
  iconRefs: Set<string>;
48
50
  iconVariantSuffixes: string[];
@@ -182,7 +184,8 @@ function collectIconRefs(filePath: string): { refs: Set<string>; suffixes: strin
182
184
  if (!isRecord(data) || !isRecord(data.icons)) return { refs, suffixes };
183
185
 
184
186
  const icons = data.icons as UnknownRecord;
185
- const variantSuffixes = isRecord(icons.variants?.suffixes) ? icons.variants.suffixes : {};
187
+ const variants = isRecord(icons.variants) ? icons.variants : {};
188
+ const variantSuffixes = isRecord(variants.suffixes) ? variants.suffixes : {};
186
189
  for (const suffix of Object.keys(variantSuffixes)) {
187
190
  if (suffix.trim()) suffixes.push(suffix);
188
191
  }
@@ -215,8 +218,9 @@ function collectIconRefs(filePath: string): { refs: Set<string>; suffixes: strin
215
218
  }
216
219
  }
217
220
 
218
- if (typeof icons.fallback?.missing_icon === "string") {
219
- refs.add(icons.fallback.missing_icon);
221
+ const fallback = isRecord(icons.fallback) ? icons.fallback : {};
222
+ if (typeof fallback.missing_icon === "string") {
223
+ refs.add(fallback.missing_icon);
220
224
  }
221
225
 
222
226
  return { refs, suffixes };
@@ -253,6 +257,15 @@ function buildContext(projectDir: string, includes: Includes, manifest: UnknownR
253
257
  }
254
258
  }
255
259
 
260
+ // Components are also valid references in screen sections (via "component:" key)
261
+ const componentNames = new Set<string>();
262
+ const componentsDir = resolve(projectDir, includes.components);
263
+ for (const filePath of listFiles(componentsDir, ".yaml")) {
264
+ for (const key of rootKeys(filePath)) {
265
+ componentNames.add(key);
266
+ }
267
+ }
268
+
256
269
  const tokenRefs = new Set<string>();
257
270
  const tokensDir = resolve(projectDir, includes.tokens);
258
271
  for (const filePath of listFiles(tokensDir, ".yaml")) {
@@ -300,6 +313,7 @@ function buildContext(projectDir: string, includes: Includes, manifest: UnknownR
300
313
  formatterNames,
301
314
  mapperNames,
302
315
  contractNames,
316
+ componentNames,
303
317
  tokenRefs,
304
318
  iconRefs: iconData.refs,
305
319
  iconVariantSuffixes: iconData.suffixes,
@@ -429,6 +443,10 @@ function validateStringValue(
429
443
  errors.push({ path, message: `unknown contract "${value}"` });
430
444
  }
431
445
 
446
+ if (key === "component" && !path.includes("platform_mapping") && !context.componentNames.has(value)) {
447
+ errors.push({ path, message: `unknown component "${value}"` });
448
+ }
449
+
432
450
  if (
433
451
  (key === "icon" || key === "icon_active") &&
434
452
  !isDynamicReference(value) &&
@@ -617,6 +635,7 @@ export function collectSemanticLint(projectDir: string, includes: Includes): Usa
617
635
  const manifest = readManifest(projectDir) as UnknownRecord;
618
636
  const context = buildContext(projectDir, includes, manifest);
619
637
  const contractsDir = resolve(projectDir, includes.contracts);
638
+ const componentsDir = resolve(projectDir, includes.components);
620
639
 
621
640
  const allErrors: UsageLint[] = [
622
641
  ...lintLocaleCoverage(context),
@@ -629,11 +648,13 @@ export function collectSemanticLint(projectDir: string, includes: Includes): Usa
629
648
  ...listFiles(resolve(projectDir, includes.flows), ".yaml"),
630
649
  ...listFiles(resolve(projectDir, includes.platform), ".yaml"),
631
650
  ...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
651
+ ...listFiles(componentsDir, ".yaml"),
632
652
  ];
633
653
 
634
654
  for (const filePath of files) {
655
+ const isContractOrComponent = filePath.startsWith(contractsDir) || filePath.startsWith(componentsDir);
635
656
  allErrors.push(
636
- ...lintFile(filePath, context, { validateTokens: !filePath.startsWith(contractsDir) })
657
+ ...lintFile(filePath, context, { validateTokens: !isContractOrComponent })
637
658
  );
638
659
  }
639
660
 
@@ -645,6 +666,7 @@ export function runSemanticLint(projectDir: string, includes: Includes): number
645
666
  const context = buildContext(projectDir, includes, manifest);
646
667
  let total = 0;
647
668
  const contractsDir = resolve(projectDir, includes.contracts);
669
+ const componentsDir = resolve(projectDir, includes.components);
648
670
 
649
671
  total += printSemanticErrors("locales", lintLocaleCoverage(context));
650
672
  total += printSemanticErrors("openuispec.yaml", lintManifestGenerationContext(projectDir, context.manifest));
@@ -655,12 +677,14 @@ export function runSemanticLint(projectDir: string, includes: Includes): number
655
677
  ...listFiles(resolve(projectDir, includes.flows), ".yaml"),
656
678
  ...listFiles(resolve(projectDir, includes.platform), ".yaml"),
657
679
  ...listFiles(resolve(projectDir, includes.contracts), ".yaml"),
680
+ ...listFiles(componentsDir, ".yaml"),
658
681
  ];
659
682
 
660
683
  for (const filePath of files) {
684
+ const isContractOrComponent = filePath.startsWith(contractsDir) || filePath.startsWith(componentsDir);
661
685
  total += printSemanticErrors(
662
686
  basename(filePath),
663
- lintFile(filePath, context, { validateTokens: !filePath.startsWith(contractsDir) })
687
+ lintFile(filePath, context, { validateTokens: !isContractOrComponent })
664
688
  );
665
689
  }
666
690
 
@@ -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
+ });