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 +15 -4
- package/cli/init.ts +16 -1
- package/examples/social-app/AGENTS.md +9 -0
- package/examples/social-app/CLAUDE.md +9 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/SettingsScreen.kt +15 -13
- package/examples/social-app/generated/web/social-app/src/components/Shell.tsx +17 -5
- package/examples/social-app/generated/web/social-app/src/components/ui.tsx +15 -3
- package/examples/social-app/generated/web/social-app/src/screens/HomeFeedScreen.tsx +11 -21
- package/examples/social-app/generated/web/social-app/src/screens/SettingsScreen.tsx +25 -23
- package/examples/social-app/openuispec/contracts/action_trigger.yaml +39 -8
- package/examples/social-app/openuispec/screens/home_feed.yaml +19 -4
- package/examples/social-app/openuispec/screens/settings.yaml +5 -0
- package/examples/taskflow/openuispec/openuispec.yaml +2 -0
- package/examples/todo-orbit/openuispec/openuispec.yaml +2 -0
- package/mcp-server/index.ts +200 -10
- package/package.json +1 -1
- package/schema/openuispec.schema.json +7 -0
- package/spec/openuispec-v0.1.md +13 -0
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,
|
|
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,
|
|
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
|
-
- [
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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-
|
|
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 {
|
|
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
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
<
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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:
|
|
@@ -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/"
|
package/mcp-server/index.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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: {
|
|
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
|
|
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
|
@@ -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.",
|
package/spec/openuispec-v0.1.md
CHANGED
|
@@ -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.
|