openuispec 0.1.46 → 0.2.0

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 CHANGED
@@ -115,10 +115,15 @@ Or run directly: `openuispec mcp`
115
115
  | `openuispec_spec_schema` | Before creating/editing spec files | Returns the full JSON schema for a specific spec type — exact structure, required fields, allowed values |
116
116
  | `openuispec_prepare` | Before UI code generation | Returns spec context, platform config, generation constraints |
117
117
  | `openuispec_read_specs` | Before and after generation | Loads spec file contents — the authoritative source for tokens, screens, contracts |
118
- | `openuispec_check` | After generation | Schema validation + concrete audit checklist from your spec |
118
+ | `openuispec_check` | After generation | Schema validation + concrete audit checklist from your spec. Optional `screens`/`contracts` params scope the audit |
119
119
  | `openuispec_validate` | After spec edits | Schema-only validation, optionally filtered by group |
120
120
  | `openuispec_drift` | Before updates | Detect spec drift since last snapshot, with semantic explanation |
121
121
  | `openuispec_status` | Anytime | Cross-target summary: baselines, drift, next steps |
122
+ | `openuispec_get_screen` | Incremental edits | Get a single screen spec by name — faster than `read_specs` for targeted work |
123
+ | `openuispec_get_contract` | Incremental edits | Get a single contract spec, optionally filtered to one variant |
124
+ | `openuispec_get_tokens` | Incremental edits | Get tokens for a specific category (color, typography, spacing, etc.) |
125
+ | `openuispec_get_locale` | Incremental edits | Get a single locale file, optionally filtered to specific keys |
126
+ | `openuispec_screenshot` | Visual verification | Take a screenshot of the generated web app at a specific route (requires `puppeteer`) |
122
127
 
123
128
  The server includes **protocol-level instructions** that trigger on UI-related requests independently of CLAUDE.md rules — so even if CLAUDE.md is buried under other project rules, the MCP enforcement still works.
124
129
 
@@ -138,6 +143,7 @@ See the examples for reference:
138
143
 
139
144
  - [TaskFlow](./examples/taskflow/openuispec/) — compact reference spec covering all 7 contract families
140
145
  - [Todo Orbit](./examples/todo-orbit/openuispec/) — bilingual task app with localization, custom contracts, and generated native/web targets
146
+ - [Social App](./examples/social-app/openuispec/) — trilingual social app with feeds, messaging, profiles, and generated Android/web targets
141
147
 
142
148
  ## Repository structure
143
149
 
@@ -175,6 +181,7 @@ openuispec/
175
181
  │ │ ├── generated/ # Generated iOS, Android, and web apps
176
182
  │ │ ├── README.md # Sample overview and structure
177
183
  │ │ └── AGENTS.md / CLAUDE.md # AI rules generated from the package
184
+ │ ├── social-app/ # Social app sample (trilingual, Android + Web)
178
185
  │ └── todo-orbit/ # Full showcase sample
179
186
  │ ├── openuispec/ # Source OpenUISpec project
180
187
  │ ├── generated/ # Generated iOS, Android, and web apps
@@ -184,7 +191,8 @@ openuispec/
184
191
  │ ├── index.ts # Entry point
185
192
  │ └── init.ts # Project scaffolding + AI rules
186
193
  ├── mcp-server/ # MCP server (openuispec-mcp)
187
- └── index.ts # Stdio transport, 8 tools
194
+ ├── index.ts # Stdio transport, 13 tools
195
+ │ └── screenshot.ts # Dev server + headless browser screenshot
188
196
  ├── check/ # Composite validation command
189
197
  │ └── index.ts # Schema + semantic + readiness
190
198
  ├── drift/ # Drift detection (spec change tracking)
@@ -243,6 +251,8 @@ By default, drift stores state in `generated/<target>/<project>/`. To point targ
243
251
  ```yaml
244
252
  generation:
245
253
  targets: [ios, android, web]
254
+ extra_rules:
255
+ - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
246
256
  output_dir:
247
257
  web: "../web-ui/"
248
258
  android: "../kmp-ui/"
@@ -255,6 +265,8 @@ Paths are relative to `openuispec.yaml`. The `.openuispec-state.json` file is st
255
265
 
256
266
  If `api.endpoints` are declared, `generation.code_roots.backend` is required. It should point at the backend folder the AI must inspect when generating API clients or wiring request/response behavior.
257
267
 
268
+ `generation.extra_rules` can hold project-wide generation conventions for AI and humans. For example, projects may declare that generation hint strings use prefixes such as `[common]`, `[ios]`, `[android]`, and `[web]` to indicate scope.
269
+
258
270
  `openuispec drift --snapshot --target <target>` requires that target output directory to already exist. If it does not, generate the target code first, then snapshot the accepted baseline.
259
271
 
260
272
  Use the commands like this:
@@ -345,7 +357,7 @@ to see which targets are already up to date and which ones still need to catch u
345
357
 
346
358
  ## Status
347
359
 
348
- **v0.1 — Draft**. The spec covers all foundational layers. TaskFlow provides a compact reference app, and Todo Orbit extends coverage with localization, recurring-rule flows, custom contracts, and generated native/web targets.
360
+ **v0.1 — Draft**. The spec covers all foundational layers. TaskFlow provides a compact reference app, Todo Orbit extends coverage with localization, recurring-rule flows, custom contracts, and generated native/web targets, and Social App demonstrates a trilingual social feed app with generated Android and web targets.
349
361
 
350
362
  ### Roadmap
351
363
 
@@ -364,7 +376,8 @@ to see which targets are already up to date and which ones still need to catch u
364
376
  - [x] CLI tool (`openuispec init` for project scaffolding + AI rules)
365
377
  - [x] MCP server for AI tool integration (`openuispec-mcp`)
366
378
  - [x] Multi-platform showcase app (`examples/todo-orbit/`)
367
- - [ ] More example apps (e-commerce, social, dashboard)
379
+ - [x] Social app example (`examples/social-app/`)
380
+ - [ ] More example apps (e-commerce, dashboard)
368
381
 
369
382
  ## Contributing
370
383
 
package/cli/init.ts CHANGED
@@ -208,6 +208,8 @@ i18n:
208
208
 
209
209
  generation:
210
210
  targets: [${targetList}]
211
+ # extra_rules: # Optional: project-wide AI authoring conventions
212
+ # - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
211
213
  # output_dir: # Optional: map targets to code directories
212
214
  # ios: "../ios-app/" # relative to this file
213
215
  # android: "../android-app/"
@@ -272,10 +274,15 @@ When the openuispec MCP server is configured, AI assistants should use these too
272
274
  | \`openuispec_spec_schema\` | Get the full JSON schema for a specific spec type — exact structure, required fields, allowed values. |
273
275
  | \`openuispec_prepare\` | **Before any UI code generation.** Returns spec context, platform config, and constraints. |
274
276
  | \`openuispec_read_specs\` | Load spec file contents — the authoritative source for tokens, screens, contracts. |
275
- | \`openuispec_check\` | After editing spec files. Validates schema + semantics + readiness. |
277
+ | \`openuispec_check\` | After editing spec files. Validates schema + semantics + readiness. Optional \`screens\`/\`contracts\` params scope the audit. |
276
278
  | \`openuispec_validate\` | Schema-only validation, optionally by group. |
277
279
  | \`openuispec_drift\` | Detect spec changes since last snapshot. |
278
280
  | \`openuispec_status\` | To understand cross-target state (baselines, drift, next steps). |
281
+ | \`openuispec_get_screen\` | Get a single screen spec by name — faster than \`read_specs\` for targeted edits. |
282
+ | \`openuispec_get_contract\` | Get a single contract spec, optionally filtered to one variant. |
283
+ | \`openuispec_get_tokens\` | Get tokens for a specific category (color, typography, spacing, etc.). |
284
+ | \`openuispec_get_locale\` | Get a single locale file, optionally filtered to specific keys. |
285
+ | \`openuispec_screenshot\` | Take a screenshot of the generated web app at a route (requires \`puppeteer\`). |
279
286
 
280
287
  ## CLI commands
281
288
 
@@ -345,6 +352,15 @@ Call these MCP tools directly. They return structured JSON with everything you n
345
352
  - Call \`openuispec_spec_schema\` with the specific type to get the full JSON schema.
346
353
  - Write the spec file following the schema exactly.
347
354
 
355
+ **Focused getters (prefer these for incremental edits over \`read_specs\`):**
356
+ - \`openuispec_get_screen(name)\` — single screen spec
357
+ - \`openuispec_get_contract(name, variant?)\` — single contract, optionally one variant
358
+ - \`openuispec_get_tokens(category)\` — single token category (color, typography, spacing, etc.)
359
+ - \`openuispec_get_locale(locale, keys?)\` — single locale file, optionally filtered keys
360
+ - \`openuispec_check(target, screens?, contracts?)\` — scoped audit for specific screens/contracts
361
+
362
+ Use \`read_specs\` for full-project generation; use focused getters when editing one screen or contract.
363
+
348
364
  **Other tools:**
349
365
  - \`openuispec_status\` — cross-target summary, good starting point
350
366
  - \`openuispec_drift\` with \`explain: true\` — property-level spec changes
@@ -1,5 +1,5 @@
1
1
  <!-- openuispec-rules-start -->
2
- <!-- openuispec-rules-version: 0.1.45 -->
2
+ <!-- openuispec-rules-version: 0.1.47 -->
3
3
  # OpenUISpec — AI Assistant Rules
4
4
  # ================================
5
5
  # This project uses OpenUISpec to define UI as a semantic spec.
@@ -38,6 +38,15 @@ Call these MCP tools directly. They return structured JSON with everything you n
38
38
  - Call `openuispec_spec_schema` with the specific type to get the full JSON schema.
39
39
  - Write the spec file following the schema exactly.
40
40
 
41
+ **Focused getters (prefer these for incremental edits over `read_specs`):**
42
+ - `openuispec_get_screen(name)` — single screen spec
43
+ - `openuispec_get_contract(name, variant?)` — single contract, optionally one variant
44
+ - `openuispec_get_tokens(category)` — single token category (color, typography, spacing, etc.)
45
+ - `openuispec_get_locale(locale, keys?)` — single locale file, optionally filtered keys
46
+ - `openuispec_check(target, screens?, contracts?)` — scoped audit for specific screens/contracts
47
+
48
+ Use `read_specs` for full-project generation; use focused getters when editing one screen or contract.
49
+
41
50
  **Other tools:**
42
51
  - `openuispec_status` — cross-target summary, good starting point
43
52
  - `openuispec_drift` with `explain: true` — property-level spec changes
@@ -1,5 +1,5 @@
1
1
  <!-- openuispec-rules-start -->
2
- <!-- openuispec-rules-version: 0.1.45 -->
2
+ <!-- openuispec-rules-version: 0.1.47 -->
3
3
  # OpenUISpec — AI Assistant Rules
4
4
  # ================================
5
5
  # This project uses OpenUISpec to define UI as a semantic spec.
@@ -38,6 +38,15 @@ Call these MCP tools directly. They return structured JSON with everything you n
38
38
  - Call `openuispec_spec_schema` with the specific type to get the full JSON schema.
39
39
  - Write the spec file following the schema exactly.
40
40
 
41
+ **Focused getters (prefer these for incremental edits over `read_specs`):**
42
+ - `openuispec_get_screen(name)` — single screen spec
43
+ - `openuispec_get_contract(name, variant?)` — single contract, optionally one variant
44
+ - `openuispec_get_tokens(category)` — single token category (color, typography, spacing, etc.)
45
+ - `openuispec_get_locale(locale, keys?)` — single locale file, optionally filtered keys
46
+ - `openuispec_check(target, screens?, contracts?)` — scoped audit for specific screens/contracts
47
+
48
+ Use `read_specs` for full-project generation; use focused getters when editing one screen or contract.
49
+
41
50
  **Other tools:**
42
51
  - `openuispec_status` — cross-target summary, good starting point
43
52
  - `openuispec_drift` with `explain: true` — property-level spec changes
@@ -133,19 +133,21 @@ fun SettingsScreen(
133
133
  )
134
134
 
135
135
  ContractSectionHeader(stringResource(R.string.settings_account))
136
- ActionTriggerButton(
137
- text = stringResource(R.string.settings_edit_profile),
138
- onClick = onEditProfileClick,
139
- variant = ActionTriggerVariant.Secondary,
140
- fullWidth = true,
141
- icon = { Icon(Icons.Default.Edit, contentDescription = null) },
142
- )
143
- ActionTriggerButton(
144
- text = stringResource(R.string.settings_logout),
145
- onClick = { showLogoutDialog = true },
146
- variant = ActionTriggerVariant.Destructive,
147
- fullWidth = true,
148
- )
136
+ Column(verticalArrangement = Arrangement.spacedBy(Spacing.SM)) {
137
+ ActionTriggerButton(
138
+ text = stringResource(R.string.settings_edit_profile),
139
+ onClick = onEditProfileClick,
140
+ variant = ActionTriggerVariant.Secondary,
141
+ fullWidth = false,
142
+ icon = { Icon(Icons.Default.Edit, contentDescription = null) },
143
+ )
144
+ ActionTriggerButton(
145
+ text = stringResource(R.string.settings_logout),
146
+ onClick = { showLogoutDialog = true },
147
+ variant = ActionTriggerVariant.Destructive,
148
+ fullWidth = false,
149
+ )
150
+ }
149
151
  }
150
152
  }
151
153
 
@@ -5,12 +5,11 @@ import { Icon } from "../lib/icons";
5
5
  import type { SizeClass } from "../lib/tokens";
6
6
  import { cn, useSizeClass } from "../lib/utils";
7
7
  import { selectUnreadNotifications, useAppStore } from "../state/store";
8
- import { ActionButton } from "./ui";
8
+ import { ActionButton, ActionGroup } from "./ui";
9
9
 
10
10
  const primaryRoutes = [
11
11
  { to: "/home", icon: "home" as const, labelKey: "nav.home" },
12
12
  { to: "/discover", icon: "discover" as const, labelKey: "nav.discover" },
13
- { to: "/create", icon: "create_post" as const, labelKey: "nav.create" },
14
13
  { to: "/notifications", icon: "notifications" as const, labelKey: "nav.notifications" },
15
14
  { to: "/profile", icon: "profile" as const, labelKey: "nav.profile" },
16
15
  ];
@@ -91,6 +90,19 @@ export function AppShell() {
91
90
  </main>
92
91
 
93
92
  {sizeClass !== "expanded" ? <BottomTabBar unreadCount={unreadCount} /> : null}
93
+
94
+ {location.pathname.startsWith("/home") || location.pathname === "/" ? (
95
+ <Link
96
+ to="/create"
97
+ className={cn(
98
+ "interactive-press fixed z-30 flex h-14 w-14 items-center justify-center rounded-cap-primary bg-[var(--color-brand-primary)] text-white shadow-md",
99
+ sizeClass === "expanded" ? "bottom-8 right-8" : "bottom-20 right-4",
100
+ )}
101
+ aria-label={t("nav.create")}
102
+ >
103
+ <Icon name="create_post" className="h-6 w-6" />
104
+ </Link>
105
+ ) : null}
94
106
  </div>
95
107
 
96
108
  {toast ? (
@@ -106,7 +118,7 @@ export function AppShell() {
106
118
  <div className="w-full max-w-md rounded-surface border border-[var(--color-border-default)] bg-[var(--color-surface-primary)] p-6 shadow-lg">
107
119
  <h2 className="text-xl font-semibold">{dialog.title}</h2>
108
120
  <p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{dialog.message}</p>
109
- <div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-end">
121
+ <ActionGroup className="mt-6 sm:flex-row sm:items-center sm:justify-end">
110
122
  {dialog.actions.map((action) => (
111
123
  <ActionButton
112
124
  key={action.label}
@@ -119,7 +131,7 @@ export function AppShell() {
119
131
  {action.label}
120
132
  </ActionButton>
121
133
  ))}
122
- </div>
134
+ </ActionGroup>
123
135
  </div>
124
136
  </div>
125
137
  ) : null}
@@ -131,7 +143,7 @@ function BottomTabBar({ unreadCount }: { unreadCount: number }) {
131
143
  const { t } = useI18n();
132
144
  return (
133
145
  <nav className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--color-border-default)] bg-[color:rgba(250,248,245,0.94)] px-2 py-2 backdrop-blur-xl">
134
- <div className="mx-auto grid max-w-xl grid-cols-5 gap-1">
146
+ <div className="mx-auto grid max-w-xl grid-cols-4 gap-1">
135
147
  {primaryRoutes.map((route) => (
136
148
  <NavLink
137
149
  key={route.to}
@@ -40,8 +40,17 @@ export function Surface({
40
40
  return <div className={cn("rounded-surface border border-[var(--color-border-default)] bg-[var(--color-surface-secondary)] shadow-sm", className)}>{children}</div>;
41
41
  }
42
42
 
43
+ export function ActionGroup({
44
+ children,
45
+ className,
46
+ }: PropsWithChildren<{
47
+ className?: string;
48
+ }>) {
49
+ return <div className={cn("flex flex-col items-start gap-3", className)}>{children}</div>;
50
+ }
51
+
43
52
  type ActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
44
- variant?: "primary" | "secondary" | "chip" | "destructive";
53
+ variant?: "primary" | "secondary" | "chip" | "destructive" | "fab";
45
54
  icon?: Parameters<typeof Icon>[0]["name"];
46
55
  fullWidth?: boolean;
47
56
  selected?: boolean;
@@ -65,6 +74,8 @@ export function ActionButton({
65
74
  ? "rounded-cap-alternate border-[var(--color-border-strong)] bg-transparent text-[var(--color-text-primary)]"
66
75
  : variant === "destructive"
67
76
  ? "rounded-cap-primary border-transparent bg-[var(--color-semantic-danger)] text-white"
77
+ : variant === "fab"
78
+ ? "rounded-cap-primary border-transparent bg-[var(--color-brand-primary)] text-[var(--color-brand-primary-on)] shadow-md"
68
79
  : selected
69
80
  ? "rounded-cap-primary border-transparent bg-[var(--color-brand-accent)] text-[var(--color-brand-accent-on)]"
70
81
  : "rounded-cap-primary border-[var(--color-border-default)] bg-transparent text-[var(--color-text-primary)]";
@@ -72,7 +83,8 @@ export function ActionButton({
72
83
  return (
73
84
  <button
74
85
  className={cn(
75
- "interactive-press inline-flex min-h-11 items-center justify-center gap-2 border px-4 py-3 text-sm font-semibold transition",
86
+ "interactive-press inline-flex min-h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap align-middle border px-4 py-3 text-sm font-semibold transition",
87
+ variant === "fab" && "h-14 w-14 gap-0 px-0 py-0",
76
88
  fullWidth && "w-full",
77
89
  variantClass,
78
90
  className,
@@ -80,7 +92,7 @@ export function ActionButton({
80
92
  {...props}
81
93
  >
82
94
  {icon ? <Icon name={icon} className="h-5 w-5" /> : null}
83
- <span>{children}</span>
95
+ {variant === "fab" ? (children ? <span className="sr-only">{children}</span> : null) : <span>{children}</span>}
84
96
  {trailing}
85
97
  </button>
86
98
  );
@@ -2,7 +2,7 @@ import { useMemo, useState, startTransition } from "react";
2
2
  import { useNavigate } from "react-router";
3
3
  import { useI18n } from "../i18n";
4
4
  import { selectFeed, selectStories, selectUserById, useAppStore } from "../state/store";
5
- import { CreatorCard, PostCard, StoryCard } from "../components/cards";
5
+ import { PostCard, StoryCard } from "../components/cards";
6
6
  import { ActionButton, EmptyState, ErrorState, ScreenScaffold, SectionTitle, SkeletonList } from "../components/ui";
7
7
  import { useSimulatedLoading, useUiScenario } from "../lib/utils";
8
8
 
@@ -56,15 +56,7 @@ export function HomeFeedScreen() {
56
56
  </section>
57
57
 
58
58
  <section className="space-y-4">
59
- <SectionTitle
60
- action={
61
- <ActionButton variant="primary" icon="create_post" onClick={() => navigate("/create")}>
62
- {t("nav.create")}
63
- </ActionButton>
64
- }
65
- >
66
- Feed
67
- </SectionTitle>
59
+ <SectionTitle>Feed</SectionTitle>
68
60
 
69
61
  {scenario === "error" ? (
70
62
  <ErrorState title="Feed unavailable" description="The mock feed request failed. Remove `?ui=error` to restore the normal state." />
@@ -97,17 +89,15 @@ export function HomeFeedScreen() {
97
89
  )}
98
90
  </section>
99
91
 
100
- <section className="space-y-4 xl:hidden">
101
- <SectionTitle>Suggested Creators</SectionTitle>
102
- <div className="no-scrollbar flex snap-x gap-3 overflow-x-auto pb-1">
103
- {state.users
104
- .filter((user) => user.id !== state.currentUserId)
105
- .slice(0, 3)
106
- .map((user) => (
107
- <CreatorCard key={user.id} user={user} to={`/u/${user.id}`} />
108
- ))}
109
- </div>
110
- </section>
92
+ <ActionButton
93
+ variant="fab"
94
+ icon="create_post"
95
+ aria-label={t("nav.create")}
96
+ className="fixed bottom-20 right-4 z-30 lg:bottom-8 lg:right-8"
97
+ onClick={() => navigate("/create")}
98
+ >
99
+ {t("nav.create")}
100
+ </ActionButton>
111
101
  </ScreenScaffold>
112
102
  );
113
103
  }
@@ -1,6 +1,6 @@
1
1
  import { useNavigate } from "react-router";
2
2
  import { useI18n } from "../i18n";
3
- import { ActionButton, ScreenScaffold, SectionTitle, SelectField, ToggleField } from "../components/ui";
3
+ import { ActionButton, ActionGroup, ScreenScaffold, SectionTitle, SelectField, ToggleField } from "../components/ui";
4
4
  import { useAppStore } from "../state/store";
5
5
 
6
6
  export function SettingsScreen() {
@@ -49,28 +49,30 @@ export function SettingsScreen() {
49
49
 
50
50
  <section className="space-y-4">
51
51
  <SectionTitle>{t("settings.account")}</SectionTitle>
52
- <ActionButton variant="secondary" icon="edit" onClick={() => navigate("/profile/edit")}>
53
- {t("settings.edit_profile")}
54
- </ActionButton>
55
- <ActionButton
56
- variant="destructive"
57
- onClick={() =>
58
- state.openDialog({
59
- title: t("settings.logout"),
60
- message: t("settings.logout_confirm"),
61
- actions: [
62
- { label: t("common.cancel"), variant: "secondary", onPress: () => undefined },
63
- {
64
- label: t("settings.logout"),
65
- variant: "destructive",
66
- onPress: () => state.logout(),
67
- },
68
- ],
69
- })
70
- }
71
- >
72
- {t("settings.logout")}
73
- </ActionButton>
52
+ <ActionGroup className="gap-2">
53
+ <ActionButton variant="secondary" icon="edit" onClick={() => navigate("/profile/edit")}>
54
+ {t("settings.edit_profile")}
55
+ </ActionButton>
56
+ <ActionButton
57
+ variant="destructive"
58
+ onClick={() =>
59
+ state.openDialog({
60
+ title: t("settings.logout"),
61
+ message: t("settings.logout_confirm"),
62
+ actions: [
63
+ { label: t("common.cancel"), variant: "secondary", onPress: () => undefined },
64
+ {
65
+ label: t("settings.logout"),
66
+ variant: "destructive",
67
+ onPress: () => state.logout(),
68
+ },
69
+ ],
70
+ })
71
+ }
72
+ >
73
+ {t("settings.logout")}
74
+ </ActionButton>
75
+ </ActionGroup>
74
76
  </section>
75
77
  </ScreenScaffold>
76
78
  );
@@ -21,11 +21,13 @@ action_trigger:
21
21
  web: { style: "border-radius: 2px 24px 2px 24px" }
22
22
  generation:
23
23
  must_handle:
24
- - "Rounded cap primary: TR and BL corners ~24px, TL and BR corners 2px"
25
- - "Dark fill background with high-contrast white text"
26
- - "Press state: scale down slightly with spring animation"
24
+ - "[common] Rounded cap primary: TR and BL corners ~24px, TL and BR corners 2px"
25
+ - "[common] Dark fill background with high-contrast white text"
26
+ - "[common] Press state: scale down slightly with spring animation"
27
+ - "[web] When action triggers are adjacent siblings, place them in an explicit group/container layout instead of raw inline text flow."
28
+ - "[web] Mixed icon-bearing and text-only actions keep consistent cross-axis alignment."
27
29
  should_handle:
28
- - "Haptic feedback on press (iOS)"
30
+ - "[ios] Haptic feedback on press"
29
31
 
30
32
  secondary:
31
33
  semantic: "Secondary action — bordered, rounded cap alternate diagonal (mirrors primary)"
@@ -40,8 +42,10 @@ action_trigger:
40
42
  web: { style: "border-radius: 24px 2px 24px 2px" }
41
43
  generation:
42
44
  must_handle:
43
- - "Rounded cap alternate: TL and BR corners ~24px, TR and BL corners 2px"
44
- - "Visible border, no fill. Mirrors primary for visual rhythm."
45
+ - "[common] Rounded cap alternate: TL and BR corners ~24px, TR and BL corners 2px"
46
+ - "[common] Visible border, no fill. Mirrors primary for visual rhythm."
47
+ - "[web] When action triggers are adjacent siblings, place them in an explicit group/container layout instead of raw inline text flow."
48
+ - "[web] Mixed icon-bearing and text-only actions keep consistent cross-axis alignment."
45
49
 
46
50
  chip:
47
51
  semantic: "Chip / tag — rounded cap primary diagonal, accent fill when selected"
@@ -58,8 +62,8 @@ action_trigger:
58
62
  web: { style: "border-radius: 2px 24px 2px 24px" }
59
63
  generation:
60
64
  must_handle:
61
- - "Same diagonal as primary, radius scales to chip height (~16-20px for 32-40px tall chips)"
62
- - "Toggle between default (bordered) and selected (accent fill) states"
65
+ - "[common] Same diagonal as primary, radius scales to chip height (~16-20px for 32-40px tall chips)"
66
+ - "[common] Toggle between default (bordered) and selected (accent fill) states"
63
67
 
64
68
  destructive:
65
69
  semantic: "Destructive action — danger color, rounded cap primary diagonal"
@@ -71,3 +75,30 @@ action_trigger:
71
75
  ios: { shape: "RoundedCapShape(diagonal: .primary)", role: "destructive" }
72
76
  android: { shape: "RoundedCapShape(diagonal = Primary)" }
73
77
  web: { style: "border-radius: 2px 24px 2px 24px" }
78
+ generation:
79
+ must_handle:
80
+ - "[common] Danger color background with high-contrast on-color text."
81
+ - "[web] When action triggers are adjacent siblings, place them in an explicit group/container layout instead of raw inline text flow."
82
+ - "[web] Mixed icon-bearing and text-only actions keep consistent cross-axis alignment."
83
+
84
+ fab:
85
+ semantic: "Floating action button — elevated icon-only primary trigger"
86
+ tokens:
87
+ background: "color.brand.primary"
88
+ icon_color: "color.brand.primary.on_color"
89
+ shape: "2px 24px 2px 24px"
90
+ size: 56
91
+ icon_size: 24
92
+ elevation: "elevation.md"
93
+ platform_mapping:
94
+ ios: { shape: "RoundedCapShape(diagonal: .primary)", size: 56 }
95
+ android: { shape: "RoundedCapShape(diagonal = Primary)", size: "56.dp" }
96
+ web: { style: "border-radius: 2px 24px 2px 24px; width: 56px; height: 56px" }
97
+ generation:
98
+ must_handle:
99
+ - "[common] Rounded cap primary shape, icon-only centered layout"
100
+ - "[common] Brand primary background with white icon"
101
+ - "[common] Elevation shadow (md level)"
102
+ - "[common] Press state: scale down slightly with spring animation"
103
+ should_handle:
104
+ - "[ios] Haptic feedback on press"
@@ -35,10 +35,6 @@ home_feed:
35
35
  label: "$t:nav.discover"
36
36
  icon: "discover"
37
37
  destination: "screens/discover"
38
- - id: "create"
39
- label: "$t:nav.create"
40
- icon: "create_post"
41
- destination: "flows/create_post"
42
38
  - id: "notifications"
43
39
  label: "$t:nav.notifications"
44
40
  icon: "notifications"
@@ -121,3 +117,22 @@ home_feed:
121
117
  pagination:
122
118
  type: infinite_scroll
123
119
  cursor_field: "cursor"
120
+
121
+ - id: create_fab
122
+ contract: action_trigger
123
+ variant: fab
124
+ position: "floating"
125
+ props:
126
+ icon: "create_post"
127
+ label: "$t:nav.create"
128
+ aria_label: "$t:nav.create"
129
+ action:
130
+ type: navigate
131
+ destination: "flows/create_post"
132
+ adaptive:
133
+ compact:
134
+ position: "bottom-right"
135
+ inset: { bottom: 80, right: 16 }
136
+ expanded:
137
+ position: "bottom-right"
138
+ inset: { bottom: 32, right: 32 }
@@ -64,9 +64,13 @@ settings:
64
64
 
65
65
  - id: account
66
66
  header: "$t:settings.account"
67
+ layout:
68
+ type: stack
69
+ spacing: "sm"
67
70
  children:
68
71
  - contract: action_trigger
69
72
  variant: secondary
73
+ full_width: false
70
74
  props:
71
75
  label: "$t:settings.edit_profile"
72
76
  icon: "edit"
@@ -76,6 +80,7 @@ settings:
76
80
 
77
81
  - contract: action_trigger
78
82
  variant: destructive
83
+ full_width: false
79
84
  props:
80
85
  label: "$t:settings.logout"
81
86
  action:
@@ -31,6 +31,8 @@ i18n:
31
31
 
32
32
  generation:
33
33
  targets: [ios, android, web]
34
+ extra_rules:
35
+ - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
34
36
  code_roots:
35
37
  backend: "../backend/"
36
38
  output_format:
@@ -25,6 +25,8 @@ i18n:
25
25
 
26
26
  generation:
27
27
  targets: [ios, android, web]
28
+ extra_rules:
29
+ - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
28
30
  # output_dir: # Optional: map targets to code directories
29
31
  # ios: "../ios-app/" # relative to this file
30
32
  # android: "../android-app/"
@@ -25,6 +25,7 @@ import { loadTargetDrift } from "../drift/index.js";
25
25
  import { readFileSync as fsReadFileSync, existsSync, readdirSync } from "node:fs";
26
26
  import { relative, resolve } from "node:path";
27
27
  import YAML from "yaml";
28
+ import { takeScreenshot } from "./screenshot.js";
28
29
 
29
30
  // ── resolve project cwd ──────────────────────────────────────────────
30
31
 
@@ -42,6 +43,12 @@ function getPackageVersion(): string {
42
43
  }
43
44
  }
44
45
 
46
+ // ── spec directory resolver ─────────────────────────────────────────
47
+
48
+ function resolveSpecDir(projectDir: string, manifest: any, key: string): string {
49
+ return resolve(projectDir, manifest.includes?.[key] ?? `./${key}/`);
50
+ }
51
+
45
52
  // ── shared tool helpers ──────────────────────────────────────────────
46
53
 
47
54
  const targetSchema = z.enum(SUPPORTED_TARGETS).describe("Target platform");
@@ -60,7 +67,7 @@ function toolError(err: unknown): { content: [{ type: "text"; text: string }]; i
60
67
 
61
68
  // ── create server ────────────────────────────────────────────────────
62
69
 
63
- const server = new McpServer(
70
+ export const server = new McpServer(
64
71
  {
65
72
  name: "openuispec",
66
73
  version: getPackageVersion(),
@@ -97,6 +104,19 @@ When you need to create or edit spec files and are unsure of the format:
97
104
  2. Call openuispec_spec_schema with the specific type to get the full JSON schema.
98
105
  3. Write the spec file following the schema exactly.
99
106
 
107
+ FOCUSED GETTERS (prefer these for incremental edits over read_specs):
108
+ - openuispec_get_screen(name) — single screen spec
109
+ - openuispec_get_contract(name, variant?) — single contract, optionally one variant
110
+ - openuispec_get_tokens(category) — single token category (color, typography, spacing, etc.)
111
+ - openuispec_get_locale(locale, keys?) — single locale file, optionally filtered keys
112
+ - openuispec_check(target, screens?, contracts?) — scoped audit for specific screens/contracts
113
+ Use read_specs for full-project generation; use focused getters when editing one screen or contract.
114
+
115
+ VISUAL VERIFICATION:
116
+ - openuispec_screenshot(route, viewport?, theme?) — screenshot the generated web app at a route.
117
+ Starts the dev server automatically. Use after generation to visually verify UI matches the spec.
118
+ Requires puppeteer (npm install -g puppeteer).
119
+
100
120
  Skip these tools ONLY when the request is purely non-UI (API logic, database, infrastructure, etc.)
101
121
  or explicitly platform-specific polish that doesn't affect shared UI semantics.`,
102
122
  }
@@ -121,7 +141,7 @@ server.registerTool(
121
141
 
122
142
  // ── tool: openuispec_check ───────────────────────────────────────────
123
143
 
124
- function buildAuditChecklist(projectDir: string, target: string): string {
144
+ function buildAuditChecklist(projectDir: string, target: string, screenFilter?: string[], contractFilter?: string[]): string {
125
145
  const lines: string[] = [
126
146
  "POST-GENERATION AUDIT — verify your code against these concrete spec requirements:",
127
147
  "",
@@ -133,7 +153,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
133
153
 
134
154
  // Extract must_handle from contracts
135
155
  const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
136
- const contractsDir = resolve(projectDir, manifest.includes?.contracts ?? "./contracts/");
156
+ const contractsDir = resolveSpecDir(projectDir, manifest, "contracts");
137
157
 
138
158
  if (existsSync(contractsDir)) {
139
159
  lines.push("## Contract must_handle requirements");
@@ -141,6 +161,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
141
161
  try {
142
162
  const content = YAML.parse(fsReadFileSync(join(contractsDir, file), "utf-8"));
143
163
  const contractName = Object.keys(content)[0];
164
+ if (contractFilter && !contractFilter.includes(contractName)) continue;
144
165
  const contract = content[contractName];
145
166
  if (!contract?.variants) continue;
146
167
 
@@ -168,13 +189,14 @@ function buildAuditChecklist(projectDir: string, target: string): string {
168
189
  }
169
190
 
170
191
  // Extract screens and their sections
171
- const screensDir = resolve(projectDir, manifest.includes?.screens ?? "./screens/");
192
+ const screensDir = resolveSpecDir(projectDir, manifest, "screens");
172
193
  if (existsSync(screensDir)) {
173
194
  lines.push("## Screens — verify all sections exist in generated code");
174
195
  for (const file of readdirSync(screensDir).filter(f => f.endsWith(".yaml")).sort()) {
175
196
  try {
176
197
  const content = YAML.parse(fsReadFileSync(join(screensDir, file), "utf-8"));
177
198
  const screenName = Object.keys(content)[0];
199
+ if (screenFilter && !screenFilter.includes(screenName)) continue;
178
200
  const screen = content[screenName];
179
201
  if (screen?.status === "stub") continue;
180
202
 
@@ -212,7 +234,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
212
234
  }
213
235
 
214
236
  // Locale keys count
215
- const localesDir = resolve(projectDir, manifest.includes?.locales ?? "./locales/");
237
+ const localesDir = resolveSpecDir(projectDir, manifest, "locales");
216
238
  if (existsSync(localesDir)) {
217
239
  const localeFiles = readdirSync(localesDir).filter(f => f.endsWith(".json"));
218
240
  if (localeFiles.length > 0) {
@@ -228,7 +250,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
228
250
  }
229
251
 
230
252
  // Platform-specific checks
231
- const platformDir = resolve(projectDir, manifest.includes?.platform ?? "./platform/");
253
+ const platformDir = resolveSpecDir(projectDir, manifest, "platform");
232
254
  const platformPath = join(platformDir, `${target}.yaml`);
233
255
  if (existsSync(platformPath)) {
234
256
  try {
@@ -254,14 +276,20 @@ function buildAuditChecklist(projectDir: string, target: string): string {
254
276
  server.registerTool(
255
277
  "openuispec_check",
256
278
  {
257
- description: "Run composite validation + post-generation audit. Returns schema validation results AND a concrete audit checklist derived from your spec files — listing every contract must_handle item, every screen section, and every locale file that must exist in your generated code. Verify each item.",
258
- inputSchema: { target: targetSchema },
279
+ description: "Run composite validation + post-generation audit. Returns schema validation results AND a concrete audit checklist derived from your spec files — listing every contract must_handle item, every screen section, and every locale file that must exist in your generated code. Verify each item. Use optional screens/contracts params to scope the audit to specific items (validation still runs on all files).",
280
+ inputSchema: {
281
+ target: targetSchema,
282
+ screens: z.array(z.string()).optional().describe("Screen names to audit (e.g. ['home_feed', 'settings']). If omitted, audits all screens."),
283
+ contracts: z.array(z.string()).optional().describe("Contract names to audit (e.g. ['action_trigger']). If omitted, audits all contracts."),
284
+ },
259
285
  },
260
- async ({ target }) => {
286
+ async ({ target, screens, contracts }) => {
261
287
  try {
262
288
  const result = buildCheckResult(target, projectCwd);
263
289
  const projectDir = findProjectDir(projectCwd);
264
- const audit = buildAuditChecklist(projectDir, target);
290
+ const screenFilter = screens && screens.length > 0 ? screens : undefined;
291
+ const contractFilter = contracts && contracts.length > 0 ? contracts : undefined;
292
+ const audit = buildAuditChecklist(projectDir, target, screenFilter, contractFilter);
265
293
  return {
266
294
  content: [
267
295
  { type: "text" as const, text: JSON.stringify(result, null, 2) },
@@ -432,6 +460,208 @@ server.registerTool(
432
460
  }
433
461
  );
434
462
 
463
+ // ── tool: openuispec_get_screen ──────────────────────────────────────
464
+
465
+ server.registerTool(
466
+ "openuispec_get_screen",
467
+ {
468
+ description: "Get the parsed content of a single screen spec file. Faster than read_specs when you only need one screen.",
469
+ inputSchema: {
470
+ name: z.string().describe("Screen name, e.g. 'home_feed' (matches filename without .yaml)"),
471
+ },
472
+ },
473
+ async ({ name }) => {
474
+ try {
475
+ const projectDir = findProjectDir(projectCwd);
476
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
477
+ const screensDir = resolveSpecDir(projectDir, manifest, "screens");
478
+ const filePath = join(screensDir, `${name}.yaml`);
479
+ if (!existsSync(filePath)) {
480
+ return toolError(`Screen "${name}" not found. Expected file: ${filePath}`);
481
+ }
482
+ const content = fsReadFileSync(filePath, "utf-8");
483
+ return toolResult({ name, path: relative(projectDir, filePath), content });
484
+ } catch (err) {
485
+ return toolError(err);
486
+ }
487
+ }
488
+ );
489
+
490
+ // ── tool: openuispec_get_contract ───────────────────────────────────
491
+
492
+ server.registerTool(
493
+ "openuispec_get_contract",
494
+ {
495
+ description: "Get a single contract spec, optionally filtered to one variant. Faster than read_specs when you only need one contract.",
496
+ inputSchema: {
497
+ name: z.string().describe("Contract name, e.g. 'action_trigger'"),
498
+ variant: z.string().optional().describe("Optional variant name, e.g. 'fab'. If given, returns only that variant's definition."),
499
+ },
500
+ },
501
+ async ({ name, variant }) => {
502
+ try {
503
+ const projectDir = findProjectDir(projectCwd);
504
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
505
+ const contractsDir = resolveSpecDir(projectDir, manifest, "contracts");
506
+
507
+ if (!existsSync(contractsDir)) {
508
+ return toolError(`Contracts directory not found: ${contractsDir}`);
509
+ }
510
+
511
+ // Scan contract files for the matching contract key
512
+ for (const file of readdirSync(contractsDir).filter(f => f.endsWith(".yaml")).sort()) {
513
+ const filePath = join(contractsDir, file);
514
+ const raw = fsReadFileSync(filePath, "utf-8");
515
+ const content = YAML.parse(raw);
516
+ const contractName = Object.keys(content)[0];
517
+ if (contractName !== name) continue;
518
+
519
+ if (variant) {
520
+ const contract = content[contractName];
521
+ const variantDef = contract?.variants?.[variant];
522
+ if (!variantDef) {
523
+ return toolError(`Variant "${variant}" not found in contract "${name}". Available variants: ${Object.keys(contract?.variants ?? {}).join(", ")}`);
524
+ }
525
+ return toolResult({ name, variant, definition: variantDef });
526
+ }
527
+
528
+ return toolResult({ name, path: relative(projectDir, filePath), content: raw });
529
+ }
530
+
531
+ return toolError(`Contract "${name}" not found in ${contractsDir}`);
532
+ } catch (err) {
533
+ return toolError(err);
534
+ }
535
+ }
536
+ );
537
+
538
+ // ── tool: openuispec_get_tokens ─────────────────────────────────────
539
+
540
+ server.registerTool(
541
+ "openuispec_get_tokens",
542
+ {
543
+ description: "Get tokens for a specific category (color, typography, spacing, elevation, motion, layout, themes, icons). Faster than read_specs when you only need one token file.",
544
+ inputSchema: {
545
+ category: z.string().describe("Token category, e.g. 'color', 'typography', 'spacing', 'elevation', 'motion', 'layout', 'themes', 'icons'"),
546
+ },
547
+ },
548
+ async ({ category }) => {
549
+ try {
550
+ const projectDir = findProjectDir(projectCwd);
551
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
552
+ const tokensDir = resolveSpecDir(projectDir, manifest, "tokens");
553
+
554
+ if (!existsSync(tokensDir)) {
555
+ return toolError(`Tokens directory not found: ${tokensDir}`);
556
+ }
557
+
558
+ // Try exact match first, then scan for files containing the category name
559
+ const candidates = [
560
+ `${category}.yaml`,
561
+ `${category}.yml`,
562
+ ];
563
+
564
+ for (const candidate of candidates) {
565
+ const filePath = join(tokensDir, candidate);
566
+ if (existsSync(filePath)) {
567
+ const content = fsReadFileSync(filePath, "utf-8");
568
+ return toolResult({ category, path: relative(projectDir, filePath), content });
569
+ }
570
+ }
571
+
572
+ // List available token files for helpful error
573
+ const available = readdirSync(tokensDir)
574
+ .filter(f => f.endsWith(".yaml") || f.endsWith(".yml"))
575
+ .map(f => f.replace(/\.ya?ml$/, ""));
576
+ return toolError(`Token category "${category}" not found. Available: ${available.join(", ")}`);
577
+ } catch (err) {
578
+ return toolError(err);
579
+ }
580
+ }
581
+ );
582
+
583
+ // ── tool: openuispec_get_locale ─────────────────────────────────────
584
+
585
+ server.registerTool(
586
+ "openuispec_get_locale",
587
+ {
588
+ description: "Get a single locale file, optionally filtered to specific keys. Faster than read_specs when you only need one locale or specific translation keys.",
589
+ inputSchema: {
590
+ locale: z.string().describe("Locale code, e.g. 'en', 'ru'"),
591
+ keys: z.array(z.string()).optional().describe("Optional list of keys to filter to, e.g. ['nav.home', 'nav.create']. If omitted, returns the full locale file."),
592
+ },
593
+ },
594
+ async ({ locale, keys }) => {
595
+ try {
596
+ const projectDir = findProjectDir(projectCwd);
597
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
598
+ const localesDir = resolveSpecDir(projectDir, manifest, "locales");
599
+ const filePath = join(localesDir, `${locale}.json`);
600
+
601
+ if (!existsSync(filePath)) {
602
+ if (existsSync(localesDir)) {
603
+ const available = readdirSync(localesDir)
604
+ .filter(f => f.endsWith(".json"))
605
+ .map(f => f.replace(/\.json$/, ""));
606
+ return toolError(`Locale "${locale}" not found. Available: ${available.join(", ")}`);
607
+ }
608
+ return toolError(`Locales directory not found: ${localesDir}`);
609
+ }
610
+
611
+ const raw = fsReadFileSync(filePath, "utf-8");
612
+ const content = JSON.parse(raw);
613
+
614
+ if (keys && keys.length > 0) {
615
+ const filtered: Record<string, unknown> = {};
616
+ for (const key of keys) {
617
+ if (key in content) {
618
+ filtered[key] = content[key];
619
+ }
620
+ }
621
+ return toolResult({ locale, path: relative(projectDir, filePath), content: filtered });
622
+ }
623
+
624
+ return toolResult({ locale, path: relative(projectDir, filePath), content });
625
+ } catch (err) {
626
+ return toolError(err);
627
+ }
628
+ }
629
+ );
630
+
631
+ // ── tool: openuispec_screenshot ──────────────────────────────────────
632
+
633
+ server.registerTool(
634
+ "openuispec_screenshot",
635
+ {
636
+ description: "Take a screenshot of the generated web app at a specific route. Starts the Vite dev server automatically if needed (first call may take longer). Returns a PNG image for visual verification of generated UI. Requires puppeteer to be installed (npm install -g puppeteer).",
637
+ inputSchema: {
638
+ route: z.string().default("/").describe("Route path to navigate to, e.g. '/home', '/settings', '/posts/123'"),
639
+ viewport: z.object({
640
+ width: z.number().default(1280),
641
+ height: z.number().default(800),
642
+ }).optional().describe("Viewport dimensions. Defaults to 1280x800. Use {width: 375, height: 812} for mobile."),
643
+ theme: z.enum(["light", "dark"]).optional().describe("Force a color scheme via prefers-color-scheme emulation"),
644
+ wait_for: z.number().optional().default(1000).describe("Milliseconds to wait after page load before screenshotting (default 1000)"),
645
+ full_page: z.boolean().optional().default(false).describe("Capture the full scrollable page instead of just the viewport"),
646
+ selector: z.string().optional().describe("CSS selector to screenshot a specific element instead of the full page"),
647
+ },
648
+ },
649
+ async ({ route, viewport, theme, wait_for, full_page, selector }) => {
650
+ try {
651
+ return await takeScreenshot(projectCwd, {
652
+ route,
653
+ viewport,
654
+ theme,
655
+ wait_for,
656
+ full_page,
657
+ selector,
658
+ });
659
+ } catch (err) {
660
+ return toolError(err);
661
+ }
662
+ }
663
+ );
664
+
435
665
  // ── start server ─────────────────────────────────────────────────────
436
666
 
437
667
  export async function startMcpServer() {
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Screenshot tool — launches dev server + headless browser, captures pages.
3
+ *
4
+ * Both the Vite dev server and the Puppeteer browser are kept alive between
5
+ * calls and torn down when the MCP server process exits.
6
+ */
7
+
8
+ import { spawn, type ChildProcess, execSync } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { createServer, type AddressInfo } from "node:net";
12
+ import YAML from "yaml";
13
+ import { findProjectDir } from "../drift/index.js";
14
+
15
+ // ── types ───────────────────────────────────────────────────────────
16
+
17
+ export interface ScreenshotOptions {
18
+ route: string;
19
+ viewport?: { width: number; height: number };
20
+ theme?: "light" | "dark";
21
+ wait_for?: number;
22
+ full_page?: boolean;
23
+ selector?: string;
24
+ }
25
+
26
+ export interface ScreenshotResult {
27
+ content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
28
+ isError?: true;
29
+ }
30
+
31
+ // ── free port finder ────────────────────────────────────────────────
32
+
33
+ function findFreePort(): Promise<number> {
34
+ return new Promise((resolve, reject) => {
35
+ const srv = createServer();
36
+ srv.listen(0, () => {
37
+ const port = (srv.address() as AddressInfo).port;
38
+ srv.close(() => resolve(port));
39
+ });
40
+ srv.on("error", reject);
41
+ });
42
+ }
43
+
44
+ // ── web app directory discovery ─────────────────────────────────────
45
+
46
+ export function findWebAppDir(projectCwd: string): string {
47
+ const projectDir = findProjectDir(projectCwd);
48
+ const manifestPath = join(projectDir, "openuispec.yaml");
49
+ const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
50
+ const projectName = manifest.project?.name ?? "app";
51
+
52
+ // Check custom output_dir first
53
+ const customDir = manifest.generation?.output_dir?.web;
54
+ if (customDir) {
55
+ const resolved = resolve(projectDir, customDir);
56
+ if (existsSync(join(resolved, "package.json"))) return resolved;
57
+ }
58
+
59
+ // Default: generated/web/<project-name>/
60
+ // Try from the project root (parent of openuispec/)
61
+ const projectRoot = resolve(projectDir, "..");
62
+ const defaultDir = join(projectRoot, "generated", "web", projectName);
63
+ if (existsSync(join(defaultDir, "package.json"))) return defaultDir;
64
+
65
+ throw new Error(
66
+ `Web app not found. Checked:\n` +
67
+ (customDir ? ` - ${resolve(projectDir, customDir)}\n` : "") +
68
+ ` - ${defaultDir}\n` +
69
+ `Generate the web target first, then try again.`,
70
+ );
71
+ }
72
+
73
+ // ── dev server manager ──────────────────────────────────────────────
74
+
75
+ interface ServerInstance {
76
+ process: ChildProcess;
77
+ port: number;
78
+ url: string;
79
+ }
80
+
81
+ const servers = new Map<string, ServerInstance>();
82
+
83
+ function ensureDepsInstalled(webDir: string): void {
84
+ if (existsSync(join(webDir, "node_modules"))) return;
85
+ try {
86
+ execSync("npm install", { cwd: webDir, stdio: "pipe", timeout: 90_000 });
87
+ } catch (err) {
88
+ throw new Error(`Failed to install web app dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
89
+ }
90
+ }
91
+
92
+ async function startDevServer(webDir: string): Promise<ServerInstance> {
93
+ const existing = servers.get(webDir);
94
+ if (existing) {
95
+ // Verify still running
96
+ if (existing.process.exitCode === null) return existing;
97
+ servers.delete(webDir);
98
+ }
99
+
100
+ ensureDepsInstalled(webDir);
101
+ const port = await findFreePort();
102
+
103
+ const child = spawn("npx", ["vite", "--port", String(port), "--strictPort"], {
104
+ cwd: webDir,
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
107
+ });
108
+
109
+ // Wait for "Local:" line from Vite
110
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
111
+
112
+ const url = await new Promise<string>((resolveUrl, reject) => {
113
+ const timeout = setTimeout(() => {
114
+ child.kill();
115
+ reject(new Error("Vite dev server failed to start within 30s"));
116
+ }, 30_000);
117
+
118
+ let output = "";
119
+ const onData = (chunk: Buffer) => {
120
+ output += chunk.toString();
121
+ const clean = stripAnsi(output);
122
+ const match = clean.match(/Local:\s+(https?:\/\/[^\s]+)/);
123
+ if (match) {
124
+ clearTimeout(timeout);
125
+ child.stdout?.off("data", onData);
126
+ child.stderr?.off("data", onData);
127
+ resolveUrl(match[1]);
128
+ }
129
+ };
130
+ child.stdout?.on("data", onData);
131
+ child.stderr?.on("data", onData);
132
+ child.on("error", (err) => { clearTimeout(timeout); reject(err); });
133
+ child.on("exit", (code) => {
134
+ clearTimeout(timeout);
135
+ if (!output.includes("Local:")) {
136
+ reject(new Error(`Vite exited with code ${code} before ready. Output:\n${output.slice(-500)}`));
137
+ }
138
+ });
139
+ });
140
+
141
+ const instance: ServerInstance = { process: child, port, url };
142
+ servers.set(webDir, instance);
143
+ return instance;
144
+ }
145
+
146
+ // ── browser manager ─────────────────────────────────────────────────
147
+
148
+ let browserInstance: any = null;
149
+
150
+ async function getBrowser(): Promise<any> {
151
+ if (browserInstance?.connected) return browserInstance;
152
+
153
+ let puppeteer: any;
154
+ try {
155
+ puppeteer = await import("puppeteer");
156
+ } catch {
157
+ throw new Error(
158
+ "puppeteer is not installed. Run:\n npm install -g puppeteer\n" +
159
+ "or add it to your project's devDependencies.",
160
+ );
161
+ }
162
+
163
+ browserInstance = await puppeteer.launch({
164
+ headless: true,
165
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
166
+ });
167
+ return browserInstance;
168
+ }
169
+
170
+ // ── screenshot capture ──────────────────────────────────────────────
171
+
172
+ export async function takeScreenshot(
173
+ projectCwd: string,
174
+ options: ScreenshotOptions,
175
+ ): Promise<ScreenshotResult> {
176
+ const {
177
+ route = "/",
178
+ viewport = { width: 1280, height: 800 },
179
+ theme,
180
+ wait_for = 1000,
181
+ full_page = false,
182
+ selector,
183
+ } = options;
184
+
185
+ // 1. Find and start
186
+ const webDir = findWebAppDir(projectCwd);
187
+ const server = await startDevServer(webDir);
188
+ const browser = await getBrowser();
189
+
190
+ // 2. Navigate
191
+ const page = await browser.newPage();
192
+ try {
193
+ await page.setViewport({ width: viewport.width, height: viewport.height });
194
+
195
+ if (theme) {
196
+ await page.emulateMediaFeatures([
197
+ { name: "prefers-color-scheme", value: theme },
198
+ ]);
199
+ }
200
+
201
+ const base = server.url.replace(/\/+$/, "");
202
+ const targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
203
+ await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
204
+
205
+ if (wait_for > 0) {
206
+ await new Promise((r) => setTimeout(r, wait_for));
207
+ }
208
+
209
+ // 3. Screenshot
210
+ let buffer: Buffer;
211
+ if (selector) {
212
+ const element = await page.$(selector);
213
+ if (!element) {
214
+ return {
215
+ content: [{ type: "text", text: `Error: Element not found for selector: ${selector}` }],
216
+ isError: true,
217
+ };
218
+ }
219
+ buffer = await element.screenshot({ type: "png" });
220
+ } else {
221
+ buffer = await page.screenshot({ type: "png", fullPage: full_page });
222
+ }
223
+
224
+ const base64 = buffer.toString("base64");
225
+
226
+ return {
227
+ content: [
228
+ { type: "image" as const, data: base64, mimeType: "image/png" },
229
+ {
230
+ type: "text" as const,
231
+ text: JSON.stringify({
232
+ route,
233
+ url: targetUrl,
234
+ viewport,
235
+ theme: theme ?? "default",
236
+ full_page,
237
+ selector: selector ?? null,
238
+ }, null, 2),
239
+ },
240
+ ],
241
+ };
242
+ } finally {
243
+ await page.close();
244
+ }
245
+ }
246
+
247
+ // ── cleanup ─────────────────────────────────────────────────────────
248
+
249
+ export function shutdownAll() {
250
+ for (const [, instance] of servers) {
251
+ try { instance.process.kill(); } catch { /* already dead */ }
252
+ }
253
+ servers.clear();
254
+ if (browserInstance) {
255
+ try { browserInstance.close(); } catch { /* ignore */ }
256
+ browserInstance = null;
257
+ }
258
+ }
259
+
260
+ process.on("exit", shutdownAll);
261
+ process.on("SIGINT", () => { shutdownAll(); process.exit(0); });
262
+ process.on("SIGTERM", () => { shutdownAll(); process.exit(0); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.1.46",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
@@ -49,6 +49,9 @@
49
49
  "tsx": "^4.19.4",
50
50
  "yaml": "^2.7.1"
51
51
  },
52
+ "optionalDependencies": {
53
+ "puppeteer": "^24.39.1"
54
+ },
52
55
  "devDependencies": {
53
56
  "@types/node": "^25.5.0",
54
57
  "typescript": "^5.8.3"
@@ -104,6 +104,13 @@
104
104
  "type": "string"
105
105
  }
106
106
  },
107
+ "extra_rules": {
108
+ "type": "array",
109
+ "description": "Project-wide authoring and generation conventions for AI, including optional scoped hint labels such as [common], [ios], [android], and [web].",
110
+ "items": {
111
+ "type": "string"
112
+ }
113
+ },
107
114
  "output_dir": {
108
115
  "type": "object",
109
116
  "description": "Per-target output directory (relative to openuispec.yaml). Defaults to generated/<target>/<project_name> if not set.",
@@ -83,6 +83,8 @@ includes:
83
83
 
84
84
  generation:
85
85
  targets: [ios, android, web]
86
+ extra_rules:
87
+ - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
86
88
  ai_model: "any" # no model lock-in
87
89
  output_format:
88
90
  ios: { language: swift, framework: swiftui }
@@ -3248,6 +3250,17 @@ ios:
3248
3250
  - Generate test code based on `test_cases`
3249
3251
  - Add platform-specific enhancements beyond what the contract specifies
3250
3252
 
3253
+ ### Scoped generation hint labels
3254
+
3255
+ Projects may declare authoring conventions in `generation.extra_rules` inside `openuispec.yaml`. One supported convention is prefixing generation hint strings with scope labels such as `[common]`, `[ios]`, `[android]`, and `[web]`. These labels are advisory authoring metadata for humans and AI; they do not change schema semantics unless downstream tooling chooses to interpret them.
3256
+
3257
+ ```yaml
3258
+ generation:
3259
+ targets: [ios, android, web]
3260
+ extra_rules:
3261
+ - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
3262
+ ```
3263
+
3251
3264
  ### 12.8 Extending standard contracts
3252
3265
 
3253
3266
  The 7 built-in contract families (Section 4) can be extended per-project using `contracts/<name>.yaml` files. Extensions add project-specific **variants**, **token overrides**, **platform mapping**, and **generation hints** without redefining the base contract. The base definition (props, states, a11y) remains authoritative from the spec.