openuispec 0.1.46 → 0.1.47

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,14 @@ 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 |
122
126
 
123
127
  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
128
 
@@ -138,6 +142,7 @@ See the examples for reference:
138
142
 
139
143
  - [TaskFlow](./examples/taskflow/openuispec/) — compact reference spec covering all 7 contract families
140
144
  - [Todo Orbit](./examples/todo-orbit/openuispec/) — bilingual task app with localization, custom contracts, and generated native/web targets
145
+ - [Social App](./examples/social-app/openuispec/) — trilingual social app with feeds, messaging, profiles, and generated Android/web targets
141
146
 
142
147
  ## Repository structure
143
148
 
@@ -175,6 +180,7 @@ openuispec/
175
180
  │ │ ├── generated/ # Generated iOS, Android, and web apps
176
181
  │ │ ├── README.md # Sample overview and structure
177
182
  │ │ └── AGENTS.md / CLAUDE.md # AI rules generated from the package
183
+ │ ├── social-app/ # Social app sample (trilingual, Android + Web)
178
184
  │ └── todo-orbit/ # Full showcase sample
179
185
  │ ├── openuispec/ # Source OpenUISpec project
180
186
  │ ├── generated/ # Generated iOS, Android, and web apps
@@ -184,7 +190,7 @@ openuispec/
184
190
  │ ├── index.ts # Entry point
185
191
  │ └── init.ts # Project scaffolding + AI rules
186
192
  ├── mcp-server/ # MCP server (openuispec-mcp)
187
- │ └── index.ts # Stdio transport, 8 tools
193
+ │ └── index.ts # Stdio transport, 12 tools
188
194
  ├── check/ # Composite validation command
189
195
  │ └── index.ts # Schema + semantic + readiness
190
196
  ├── drift/ # Drift detection (spec change tracking)
@@ -243,6 +249,8 @@ By default, drift stores state in `generated/<target>/<project>/`. To point targ
243
249
  ```yaml
244
250
  generation:
245
251
  targets: [ios, android, web]
252
+ extra_rules:
253
+ - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
246
254
  output_dir:
247
255
  web: "../web-ui/"
248
256
  android: "../kmp-ui/"
@@ -255,6 +263,8 @@ Paths are relative to `openuispec.yaml`. The `.openuispec-state.json` file is st
255
263
 
256
264
  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
265
 
266
+ `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.
267
+
258
268
  `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
269
 
260
270
  Use the commands like this:
@@ -345,7 +355,7 @@ to see which targets are already up to date and which ones still need to catch u
345
355
 
346
356
  ## Status
347
357
 
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.
358
+ **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
359
 
350
360
  ### Roadmap
351
361
 
@@ -364,7 +374,8 @@ to see which targets are already up to date and which ones still need to catch u
364
374
  - [x] CLI tool (`openuispec init` for project scaffolding + AI rules)
365
375
  - [x] MCP server for AI tool integration (`openuispec-mcp`)
366
376
  - [x] Multi-platform showcase app (`examples/todo-orbit/`)
367
- - [ ] More example apps (e-commerce, social, dashboard)
377
+ - [x] Social app example (`examples/social-app/`)
378
+ - [ ] More example apps (e-commerce, dashboard)
368
379
 
369
380
  ## Contributing
370
381
 
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,14 @@ 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. |
279
285
 
280
286
  ## CLI commands
281
287
 
@@ -345,6 +351,15 @@ Call these MCP tools directly. They return structured JSON with everything you n
345
351
  - Call \`openuispec_spec_schema\` with the specific type to get the full JSON schema.
346
352
  - Write the spec file following the schema exactly.
347
353
 
354
+ **Focused getters (prefer these for incremental edits over \`read_specs\`):**
355
+ - \`openuispec_get_screen(name)\` — single screen spec
356
+ - \`openuispec_get_contract(name, variant?)\` — single contract, optionally one variant
357
+ - \`openuispec_get_tokens(category)\` — single token category (color, typography, spacing, etc.)
358
+ - \`openuispec_get_locale(locale, keys?)\` — single locale file, optionally filtered keys
359
+ - \`openuispec_check(target, screens?, contracts?)\` — scoped audit for specific screens/contracts
360
+
361
+ Use \`read_specs\` for full-project generation; use focused getters when editing one screen or contract.
362
+
348
363
  **Other tools:**
349
364
  - \`openuispec_status\` — cross-target summary, good starting point
350
365
  - \`openuispec_drift\` with \`explain: true\` — property-level spec changes
@@ -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
@@ -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/"
@@ -42,6 +42,12 @@ function getPackageVersion(): string {
42
42
  }
43
43
  }
44
44
 
45
+ // ── spec directory resolver ─────────────────────────────────────────
46
+
47
+ function resolveSpecDir(projectDir: string, manifest: any, key: string): string {
48
+ return resolve(projectDir, manifest.includes?.[key] ?? `./${key}/`);
49
+ }
50
+
45
51
  // ── shared tool helpers ──────────────────────────────────────────────
46
52
 
47
53
  const targetSchema = z.enum(SUPPORTED_TARGETS).describe("Target platform");
@@ -60,7 +66,7 @@ function toolError(err: unknown): { content: [{ type: "text"; text: string }]; i
60
66
 
61
67
  // ── create server ────────────────────────────────────────────────────
62
68
 
63
- const server = new McpServer(
69
+ export const server = new McpServer(
64
70
  {
65
71
  name: "openuispec",
66
72
  version: getPackageVersion(),
@@ -97,6 +103,14 @@ When you need to create or edit spec files and are unsure of the format:
97
103
  2. Call openuispec_spec_schema with the specific type to get the full JSON schema.
98
104
  3. Write the spec file following the schema exactly.
99
105
 
106
+ FOCUSED GETTERS (prefer these for incremental edits over read_specs):
107
+ - openuispec_get_screen(name) — single screen spec
108
+ - openuispec_get_contract(name, variant?) — single contract, optionally one variant
109
+ - openuispec_get_tokens(category) — single token category (color, typography, spacing, etc.)
110
+ - openuispec_get_locale(locale, keys?) — single locale file, optionally filtered keys
111
+ - openuispec_check(target, screens?, contracts?) — scoped audit for specific screens/contracts
112
+ Use read_specs for full-project generation; use focused getters when editing one screen or contract.
113
+
100
114
  Skip these tools ONLY when the request is purely non-UI (API logic, database, infrastructure, etc.)
101
115
  or explicitly platform-specific polish that doesn't affect shared UI semantics.`,
102
116
  }
@@ -121,7 +135,7 @@ server.registerTool(
121
135
 
122
136
  // ── tool: openuispec_check ───────────────────────────────────────────
123
137
 
124
- function buildAuditChecklist(projectDir: string, target: string): string {
138
+ function buildAuditChecklist(projectDir: string, target: string, screenFilter?: string[], contractFilter?: string[]): string {
125
139
  const lines: string[] = [
126
140
  "POST-GENERATION AUDIT — verify your code against these concrete spec requirements:",
127
141
  "",
@@ -133,7 +147,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
133
147
 
134
148
  // Extract must_handle from contracts
135
149
  const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
136
- const contractsDir = resolve(projectDir, manifest.includes?.contracts ?? "./contracts/");
150
+ const contractsDir = resolveSpecDir(projectDir, manifest, "contracts");
137
151
 
138
152
  if (existsSync(contractsDir)) {
139
153
  lines.push("## Contract must_handle requirements");
@@ -141,6 +155,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
141
155
  try {
142
156
  const content = YAML.parse(fsReadFileSync(join(contractsDir, file), "utf-8"));
143
157
  const contractName = Object.keys(content)[0];
158
+ if (contractFilter && !contractFilter.includes(contractName)) continue;
144
159
  const contract = content[contractName];
145
160
  if (!contract?.variants) continue;
146
161
 
@@ -168,13 +183,14 @@ function buildAuditChecklist(projectDir: string, target: string): string {
168
183
  }
169
184
 
170
185
  // Extract screens and their sections
171
- const screensDir = resolve(projectDir, manifest.includes?.screens ?? "./screens/");
186
+ const screensDir = resolveSpecDir(projectDir, manifest, "screens");
172
187
  if (existsSync(screensDir)) {
173
188
  lines.push("## Screens — verify all sections exist in generated code");
174
189
  for (const file of readdirSync(screensDir).filter(f => f.endsWith(".yaml")).sort()) {
175
190
  try {
176
191
  const content = YAML.parse(fsReadFileSync(join(screensDir, file), "utf-8"));
177
192
  const screenName = Object.keys(content)[0];
193
+ if (screenFilter && !screenFilter.includes(screenName)) continue;
178
194
  const screen = content[screenName];
179
195
  if (screen?.status === "stub") continue;
180
196
 
@@ -212,7 +228,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
212
228
  }
213
229
 
214
230
  // Locale keys count
215
- const localesDir = resolve(projectDir, manifest.includes?.locales ?? "./locales/");
231
+ const localesDir = resolveSpecDir(projectDir, manifest, "locales");
216
232
  if (existsSync(localesDir)) {
217
233
  const localeFiles = readdirSync(localesDir).filter(f => f.endsWith(".json"));
218
234
  if (localeFiles.length > 0) {
@@ -228,7 +244,7 @@ function buildAuditChecklist(projectDir: string, target: string): string {
228
244
  }
229
245
 
230
246
  // Platform-specific checks
231
- const platformDir = resolve(projectDir, manifest.includes?.platform ?? "./platform/");
247
+ const platformDir = resolveSpecDir(projectDir, manifest, "platform");
232
248
  const platformPath = join(platformDir, `${target}.yaml`);
233
249
  if (existsSync(platformPath)) {
234
250
  try {
@@ -254,14 +270,20 @@ function buildAuditChecklist(projectDir: string, target: string): string {
254
270
  server.registerTool(
255
271
  "openuispec_check",
256
272
  {
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 },
273
+ 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).",
274
+ inputSchema: {
275
+ target: targetSchema,
276
+ screens: z.array(z.string()).optional().describe("Screen names to audit (e.g. ['home_feed', 'settings']). If omitted, audits all screens."),
277
+ contracts: z.array(z.string()).optional().describe("Contract names to audit (e.g. ['action_trigger']). If omitted, audits all contracts."),
278
+ },
259
279
  },
260
- async ({ target }) => {
280
+ async ({ target, screens, contracts }) => {
261
281
  try {
262
282
  const result = buildCheckResult(target, projectCwd);
263
283
  const projectDir = findProjectDir(projectCwd);
264
- const audit = buildAuditChecklist(projectDir, target);
284
+ const screenFilter = screens && screens.length > 0 ? screens : undefined;
285
+ const contractFilter = contracts && contracts.length > 0 ? contracts : undefined;
286
+ const audit = buildAuditChecklist(projectDir, target, screenFilter, contractFilter);
265
287
  return {
266
288
  content: [
267
289
  { type: "text" as const, text: JSON.stringify(result, null, 2) },
@@ -432,6 +454,174 @@ server.registerTool(
432
454
  }
433
455
  );
434
456
 
457
+ // ── tool: openuispec_get_screen ──────────────────────────────────────
458
+
459
+ server.registerTool(
460
+ "openuispec_get_screen",
461
+ {
462
+ description: "Get the parsed content of a single screen spec file. Faster than read_specs when you only need one screen.",
463
+ inputSchema: {
464
+ name: z.string().describe("Screen name, e.g. 'home_feed' (matches filename without .yaml)"),
465
+ },
466
+ },
467
+ async ({ name }) => {
468
+ try {
469
+ const projectDir = findProjectDir(projectCwd);
470
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
471
+ const screensDir = resolveSpecDir(projectDir, manifest, "screens");
472
+ const filePath = join(screensDir, `${name}.yaml`);
473
+ if (!existsSync(filePath)) {
474
+ return toolError(`Screen "${name}" not found. Expected file: ${filePath}`);
475
+ }
476
+ const content = fsReadFileSync(filePath, "utf-8");
477
+ return toolResult({ name, path: relative(projectDir, filePath), content });
478
+ } catch (err) {
479
+ return toolError(err);
480
+ }
481
+ }
482
+ );
483
+
484
+ // ── tool: openuispec_get_contract ───────────────────────────────────
485
+
486
+ server.registerTool(
487
+ "openuispec_get_contract",
488
+ {
489
+ description: "Get a single contract spec, optionally filtered to one variant. Faster than read_specs when you only need one contract.",
490
+ inputSchema: {
491
+ name: z.string().describe("Contract name, e.g. 'action_trigger'"),
492
+ variant: z.string().optional().describe("Optional variant name, e.g. 'fab'. If given, returns only that variant's definition."),
493
+ },
494
+ },
495
+ async ({ name, variant }) => {
496
+ try {
497
+ const projectDir = findProjectDir(projectCwd);
498
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
499
+ const contractsDir = resolveSpecDir(projectDir, manifest, "contracts");
500
+
501
+ if (!existsSync(contractsDir)) {
502
+ return toolError(`Contracts directory not found: ${contractsDir}`);
503
+ }
504
+
505
+ // Scan contract files for the matching contract key
506
+ for (const file of readdirSync(contractsDir).filter(f => f.endsWith(".yaml")).sort()) {
507
+ const filePath = join(contractsDir, file);
508
+ const raw = fsReadFileSync(filePath, "utf-8");
509
+ const content = YAML.parse(raw);
510
+ const contractName = Object.keys(content)[0];
511
+ if (contractName !== name) continue;
512
+
513
+ if (variant) {
514
+ const contract = content[contractName];
515
+ const variantDef = contract?.variants?.[variant];
516
+ if (!variantDef) {
517
+ return toolError(`Variant "${variant}" not found in contract "${name}". Available variants: ${Object.keys(contract?.variants ?? {}).join(", ")}`);
518
+ }
519
+ return toolResult({ name, variant, definition: variantDef });
520
+ }
521
+
522
+ return toolResult({ name, path: relative(projectDir, filePath), content: raw });
523
+ }
524
+
525
+ return toolError(`Contract "${name}" not found in ${contractsDir}`);
526
+ } catch (err) {
527
+ return toolError(err);
528
+ }
529
+ }
530
+ );
531
+
532
+ // ── tool: openuispec_get_tokens ─────────────────────────────────────
533
+
534
+ server.registerTool(
535
+ "openuispec_get_tokens",
536
+ {
537
+ 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.",
538
+ inputSchema: {
539
+ category: z.string().describe("Token category, e.g. 'color', 'typography', 'spacing', 'elevation', 'motion', 'layout', 'themes', 'icons'"),
540
+ },
541
+ },
542
+ async ({ category }) => {
543
+ try {
544
+ const projectDir = findProjectDir(projectCwd);
545
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
546
+ const tokensDir = resolveSpecDir(projectDir, manifest, "tokens");
547
+
548
+ if (!existsSync(tokensDir)) {
549
+ return toolError(`Tokens directory not found: ${tokensDir}`);
550
+ }
551
+
552
+ // Try exact match first, then scan for files containing the category name
553
+ const candidates = [
554
+ `${category}.yaml`,
555
+ `${category}.yml`,
556
+ ];
557
+
558
+ for (const candidate of candidates) {
559
+ const filePath = join(tokensDir, candidate);
560
+ if (existsSync(filePath)) {
561
+ const content = fsReadFileSync(filePath, "utf-8");
562
+ return toolResult({ category, path: relative(projectDir, filePath), content });
563
+ }
564
+ }
565
+
566
+ // List available token files for helpful error
567
+ const available = readdirSync(tokensDir)
568
+ .filter(f => f.endsWith(".yaml") || f.endsWith(".yml"))
569
+ .map(f => f.replace(/\.ya?ml$/, ""));
570
+ return toolError(`Token category "${category}" not found. Available: ${available.join(", ")}`);
571
+ } catch (err) {
572
+ return toolError(err);
573
+ }
574
+ }
575
+ );
576
+
577
+ // ── tool: openuispec_get_locale ─────────────────────────────────────
578
+
579
+ server.registerTool(
580
+ "openuispec_get_locale",
581
+ {
582
+ 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.",
583
+ inputSchema: {
584
+ locale: z.string().describe("Locale code, e.g. 'en', 'ru'"),
585
+ 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."),
586
+ },
587
+ },
588
+ async ({ locale, keys }) => {
589
+ try {
590
+ const projectDir = findProjectDir(projectCwd);
591
+ const manifest = YAML.parse(fsReadFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
592
+ const localesDir = resolveSpecDir(projectDir, manifest, "locales");
593
+ const filePath = join(localesDir, `${locale}.json`);
594
+
595
+ if (!existsSync(filePath)) {
596
+ if (existsSync(localesDir)) {
597
+ const available = readdirSync(localesDir)
598
+ .filter(f => f.endsWith(".json"))
599
+ .map(f => f.replace(/\.json$/, ""));
600
+ return toolError(`Locale "${locale}" not found. Available: ${available.join(", ")}`);
601
+ }
602
+ return toolError(`Locales directory not found: ${localesDir}`);
603
+ }
604
+
605
+ const raw = fsReadFileSync(filePath, "utf-8");
606
+ const content = JSON.parse(raw);
607
+
608
+ if (keys && keys.length > 0) {
609
+ const filtered: Record<string, unknown> = {};
610
+ for (const key of keys) {
611
+ if (key in content) {
612
+ filtered[key] = content[key];
613
+ }
614
+ }
615
+ return toolResult({ locale, path: relative(projectDir, filePath), content: filtered });
616
+ }
617
+
618
+ return toolResult({ locale, path: relative(projectDir, filePath), content });
619
+ } catch (err) {
620
+ return toolError(err);
621
+ }
622
+ }
623
+ );
624
+
435
625
  // ── start server ─────────────────────────────────────────────────────
436
626
 
437
627
  export async function startMcpServer() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
@@ -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.