openuispec 0.2.11 → 0.2.13
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 +2 -2
- package/check/index.ts +17 -0
- package/cli/index.ts +17 -1
- package/cli/init.ts +201 -2
- package/docs/file-formats.md +36 -0
- package/docs/implementation-notes.md +7 -0
- package/drift/index.ts +241 -26
- package/examples/taskflow/README.md +1 -1
- package/mcp-server/index.ts +25 -4
- package/package.json +1 -1
- package/prepare/index.ts +139 -18
- package/schema/custom-contract.schema.json +2 -2
- package/schema/openuispec.schema.json +71 -6
- package/schema/semantic-lint.ts +25 -1
- package/spec/openuispec-v0.1.md +22 -9
- package/status/index.ts +70 -0
|
@@ -38,22 +38,28 @@
|
|
|
38
38
|
"description": "Paths to spec component directories",
|
|
39
39
|
"properties": {
|
|
40
40
|
"tokens": {
|
|
41
|
-
"type": "string"
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "Visual design tokens — colors, typography, spacing, elevation, motion. Affects UI rendering only, not business logic."
|
|
42
43
|
},
|
|
43
44
|
"contracts": {
|
|
44
|
-
"type": "string"
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Reusable UI component definitions — buttons, inputs, lists, surfaces. Each contract specifies visual props, UI interaction states (pressed, disabled, loading), and accessibility. These are UI rendering specifications, not business logic or domain state machines."
|
|
45
47
|
},
|
|
46
48
|
"screens": {
|
|
47
|
-
"type": "string"
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Screen layouts composed from contracts — defines what UI components appear on each screen, their data bindings, and visual arrangement."
|
|
48
51
|
},
|
|
49
52
|
"flows": {
|
|
50
|
-
"type": "string"
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "Multi-screen navigation journeys — defines screen sequences, transitions, and navigation triggers. UI navigation structure, not business workflow logic."
|
|
51
55
|
},
|
|
52
56
|
"platform": {
|
|
53
|
-
"type": "string"
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "Per-target platform overrides — native UI behavior, framework-specific generation config. One file per target (ios.yaml, android.yaml, web.yaml)."
|
|
54
59
|
},
|
|
55
60
|
"locales": {
|
|
56
|
-
"type": "string"
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Localization string files — one JSON file per supported locale containing translated UI text."
|
|
57
63
|
}
|
|
58
64
|
},
|
|
59
65
|
"required": [
|
|
@@ -152,6 +158,65 @@
|
|
|
152
158
|
},
|
|
153
159
|
"additionalProperties": true
|
|
154
160
|
}
|
|
161
|
+
},
|
|
162
|
+
"shared": {
|
|
163
|
+
"type": "object",
|
|
164
|
+
"description": "Named shared code layers that span multiple targets (e.g. KMP common modules)",
|
|
165
|
+
"additionalProperties": {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"platforms": {
|
|
169
|
+
"type": "array",
|
|
170
|
+
"items": { "type": "string" }
|
|
171
|
+
},
|
|
172
|
+
"language": {
|
|
173
|
+
"type": "string"
|
|
174
|
+
},
|
|
175
|
+
"root": {
|
|
176
|
+
"type": "string"
|
|
177
|
+
},
|
|
178
|
+
"paths": {
|
|
179
|
+
"type": "object",
|
|
180
|
+
"additionalProperties": { "type": "string" }
|
|
181
|
+
},
|
|
182
|
+
"tracks": {
|
|
183
|
+
"type": "array",
|
|
184
|
+
"description": "Spec categories this shared layer tracks for hash-based drift detection. Optional — when omitted, the layer relies on scope alone. Categories: manifest (project config, data models, API endpoints), tokens (visual design tokens — UI only), contracts (reusable UI component definitions — UI only, not business logic), screens (screen layouts — UI only), flows (navigation journeys — UI only), platform (per-target overrides — UI only), locales (translated UI strings).",
|
|
185
|
+
"items": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"enum": ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"]
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"scope": {
|
|
191
|
+
"type": "string",
|
|
192
|
+
"description": "What code belongs in this shared layer (e.g. 'Business logic, data models, repositories, API clients, view models. No UI rendering.')"
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
"required": ["platforms", "language", "root", "scope"],
|
|
196
|
+
"additionalProperties": false
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"structure": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"description": "Per-target output directory structure (overrides heuristic code root discovery)",
|
|
202
|
+
"additionalProperties": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"properties": {
|
|
205
|
+
"root": {
|
|
206
|
+
"type": "string"
|
|
207
|
+
},
|
|
208
|
+
"paths": {
|
|
209
|
+
"type": "object",
|
|
210
|
+
"additionalProperties": { "type": "string" }
|
|
211
|
+
},
|
|
212
|
+
"scope": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"description": "What code belongs in this target directory (e.g. 'Pure SwiftUI views and navigation. All business logic comes from the shared layer.')"
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
"required": ["root"],
|
|
218
|
+
"additionalProperties": false
|
|
219
|
+
}
|
|
155
220
|
}
|
|
156
221
|
},
|
|
157
222
|
"required": [
|
package/schema/semantic-lint.ts
CHANGED
|
@@ -5,6 +5,23 @@ import { listFiles, readManifest } from "../drift/index.js";
|
|
|
5
5
|
|
|
6
6
|
type UnknownRecord = Record<string, unknown>;
|
|
7
7
|
|
|
8
|
+
/** Collect all locale keys from a JSON object, supporting both flat dotted keys and nested objects. */
|
|
9
|
+
function collectLocaleKeys(data: unknown, prefix = ""): string[] {
|
|
10
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
|
11
|
+
const keys: string[] = [];
|
|
12
|
+
for (const [key, value] of Object.entries(data as UnknownRecord)) {
|
|
13
|
+
if (key.startsWith("$")) continue; // skip $locale, $direction
|
|
14
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
15
|
+
if (typeof value === "string") {
|
|
16
|
+
keys.push(fullKey);
|
|
17
|
+
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
18
|
+
// Nested object — recurse to flatten
|
|
19
|
+
keys.push(...collectLocaleKeys(value, fullKey));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return keys;
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
export interface Includes {
|
|
9
26
|
tokens: string;
|
|
10
27
|
contracts: string;
|
|
@@ -212,7 +229,14 @@ function buildContext(projectDir: string, includes: Includes, manifest: UnknownR
|
|
|
212
229
|
for (const filePath of listFiles(localeDir, ".json")) {
|
|
213
230
|
const localeName = basename(filePath, ".json");
|
|
214
231
|
const data = loadJson(filePath);
|
|
215
|
-
|
|
232
|
+
// Support both flat dotted keys ("nav.home": "Home") and nested objects ({ nav: { home: "Home" } })
|
|
233
|
+
const flatKeys = Object.keys(data as object).filter(k => !k.startsWith("$"));
|
|
234
|
+
const hasNestedObjects = flatKeys.some(k => {
|
|
235
|
+
const v = (data as UnknownRecord)[k];
|
|
236
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
237
|
+
});
|
|
238
|
+
const allKeys = hasNestedObjects ? collectLocaleKeys(data) : flatKeys;
|
|
239
|
+
localeFiles.set(localeName, new Set(allKeys));
|
|
216
240
|
}
|
|
217
241
|
|
|
218
242
|
const formatterNames = new Set<string>([
|
package/spec/openuispec-v0.1.md
CHANGED
|
@@ -19,7 +19,7 @@ OpenUISpec is a shared UI sync language for native products, optimized for solo
|
|
|
19
19
|
|
|
20
20
|
1. **Semantic over visual.** The spec defines behavioral intent, not pixel layouts. A "primary action trigger" maps to `Button` in SwiftUI, `Button` in Compose, and `<button>` in HTML — the spec never says "button."
|
|
21
21
|
2. **Constrained freedom.** Tokens use ranges, not exact values. Close enough to be recognizably the same brand; loose enough for each platform to feel native.
|
|
22
|
-
3. **Contract-driven.** Every component is a
|
|
22
|
+
3. **Contract-driven.** Every UI component is a reusable contract with typed props, UI interaction states (pressed, disabled, loading, error — not business logic states), and accessibility requirements. If a UI state exists in the spec, the generated code must handle it.
|
|
23
23
|
4. **AI-first authoring.** The spec is structured for machine consumption: strongly typed, validatable, with generation hints that tell AI what it must, should, and may produce.
|
|
24
24
|
5. **Platform respect.** iOS should feel like iOS. Android should feel like Android. Web should feel like the web. The spec preserves platform identity; it does not erase it.
|
|
25
25
|
|
|
@@ -90,6 +90,19 @@ generation:
|
|
|
90
90
|
ios: { language: swift, framework: swiftui }
|
|
91
91
|
android: { language: kotlin, framework: compose }
|
|
92
92
|
web: { language: typescript, framework: react }
|
|
93
|
+
# shared: # optional: cross-platform shared code layers
|
|
94
|
+
# mobile_common:
|
|
95
|
+
# platforms: [ios, android]
|
|
96
|
+
# language: kotlin
|
|
97
|
+
# root: "../shared"
|
|
98
|
+
# scope: "Business logic, data models, repositories, view models. No UI."
|
|
99
|
+
# paths:
|
|
100
|
+
# domain: "commonMain/domain/"
|
|
101
|
+
# structure: # optional: per-target directory structure (overrides heuristics)
|
|
102
|
+
# ios:
|
|
103
|
+
# root: "../shared"
|
|
104
|
+
# scope: "Pure SwiftUI views and navigation."
|
|
105
|
+
# paths: { ui: "iosApp/ui/" }
|
|
93
106
|
```
|
|
94
107
|
|
|
95
108
|
---
|
|
@@ -542,7 +555,7 @@ The `custom` section follows the same shape as `registry` categories. App-specif
|
|
|
542
555
|
|
|
543
556
|
## 4. Component contracts
|
|
544
557
|
|
|
545
|
-
Each contract defines a **
|
|
558
|
+
Each contract defines a **reusable UI component family** — a category of UI elements that share the same role, props shape, UI interaction states, and accessibility pattern. The AI maps each contract to the most appropriate native widget per platform. Contracts describe how components look and respond to user interaction — they are UI rendering specifications, not business logic or domain state machines.
|
|
546
559
|
|
|
547
560
|
### Contract anatomy
|
|
548
561
|
|
|
@@ -552,7 +565,7 @@ Every contract contains:
|
|
|
552
565
|
|---------|---------|----------|
|
|
553
566
|
| `semantic` | Human-readable description of what this family does | Yes |
|
|
554
567
|
| `props` | Typed inputs the component accepts | Yes |
|
|
555
|
-
| `states` |
|
|
568
|
+
| `states` | UI interaction states (e.g. idle, pressed, disabled, loading, error) with valid transitions | Yes |
|
|
556
569
|
| `a11y` | Accessibility role, label pattern, focus behavior | Yes |
|
|
557
570
|
| `tokens` | Visual token bindings per variant | Yes |
|
|
558
571
|
| `platform_mapping` | Default native widget per platform | Yes |
|
|
@@ -1752,7 +1765,7 @@ When omitted, the item contract's default variant is used.
|
|
|
1752
1765
|
|
|
1753
1766
|
#### `state_binding`
|
|
1754
1767
|
|
|
1755
|
-
Binds a contract's
|
|
1768
|
+
Binds a contract's UI interaction states to data paths. This allows screen-level data to drive contract states declaratively, without requiring an explicit action.
|
|
1756
1769
|
|
|
1757
1770
|
```yaml
|
|
1758
1771
|
- contract: action_trigger
|
|
@@ -1770,7 +1783,7 @@ Binds a contract's state machine states to data paths. This allows screen-level
|
|
|
1770
1783
|
- Values must be data paths that resolve to `bool`
|
|
1771
1784
|
- When the bound value is `true`, the contract transitions to that state
|
|
1772
1785
|
- When the bound value returns to `false`, the contract transitions back to `default`
|
|
1773
|
-
- If multiple state bindings are `true` simultaneously, priority follows the contract's state
|
|
1786
|
+
- If multiple state bindings are `true` simultaneously, priority follows the contract's state priority order (e.g., `loading` takes precedence over `disabled`)
|
|
1774
1787
|
|
|
1775
1788
|
---
|
|
1776
1789
|
|
|
@@ -2953,7 +2966,7 @@ props:
|
|
|
2953
2966
|
condition: "tasks.$empty"
|
|
2954
2967
|
```
|
|
2955
2968
|
|
|
2956
|
-
Collection contracts handle `$loading`, `$error`, and `$empty` automatically via their
|
|
2969
|
+
Collection contracts handle `$loading`, `$error`, and `$empty` automatically via their built-in UI states (see Section 4.7). For non-collection data, screens can use `condition:` to show appropriate UI.
|
|
2957
2970
|
|
|
2958
2971
|
### 10.9 AI generation requirements
|
|
2959
2972
|
|
|
@@ -3148,7 +3161,7 @@ Custom contracts allow spec authors to define domain-specific component families
|
|
|
3148
3161
|
Use a custom contract when the component:
|
|
3149
3162
|
|
|
3150
3163
|
- Has domain-specific behavior that doesn't map cleanly to any built-in family
|
|
3151
|
-
- Requires
|
|
3164
|
+
- Requires dedicated UI interaction states (e.g., play/pause/seek for media)
|
|
3152
3165
|
- Needs platform-specific libraries or frameworks not covered by core contracts
|
|
3153
3166
|
- Would clutter the core spec if included as a built-in
|
|
3154
3167
|
|
|
@@ -3314,7 +3327,7 @@ ios:
|
|
|
3314
3327
|
|
|
3315
3328
|
**MUST:**
|
|
3316
3329
|
- Read and parse all registered custom contract definitions before generating code
|
|
3317
|
-
- Handle every declared
|
|
3330
|
+
- Handle every declared UI interaction state
|
|
3318
3331
|
- Apply `platform_mapping` to select the correct native component
|
|
3319
3332
|
- Include all `dependencies` in the generated project configuration (Package.swift, build.gradle, package.json)
|
|
3320
3333
|
- Implement all items listed in `generation.must_handle`
|
|
@@ -3809,7 +3822,7 @@ A drift detector compares:
|
|
|
3809
3822
|
- **Spec → Code**: Does the generated code match what the spec describes? (e.g., a button's action type, a screen's data sources, a flow's step order)
|
|
3810
3823
|
- **Code → Spec**: Does the current platform code contain UI decisions not reflected in the spec? (e.g., a new field added to a form, a navigation path changed)
|
|
3811
3824
|
|
|
3812
|
-
Drift detection is scoped to the semantic layer — it compares behavioral intent (contracts, props,
|
|
3825
|
+
Drift detection is scoped to the semantic layer — it compares behavioral intent (contracts, props, UI interaction states, data bindings), not visual details (padding values, animation curves). Platform-specific polish is expected to diverge from the spec; behavioral contracts are not.
|
|
3813
3826
|
|
|
3814
3827
|
Resolution strategies:
|
|
3815
3828
|
- **Update spec** — The code change is intentional; update the spec to match
|
package/status/index.ts
CHANGED
|
@@ -10,14 +10,19 @@
|
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import {
|
|
12
12
|
computeDrift,
|
|
13
|
+
computeSharedDrift,
|
|
13
14
|
discoverTargets,
|
|
14
15
|
explainDrift,
|
|
15
16
|
findProjectDir,
|
|
16
17
|
formatBaseline,
|
|
18
|
+
hasDriftChanges,
|
|
17
19
|
readOutputDirs,
|
|
18
20
|
readProjectName,
|
|
21
|
+
readSharedLayers,
|
|
22
|
+
readSharedLayerState,
|
|
19
23
|
resolveOutputDir,
|
|
20
24
|
stateFilePath,
|
|
25
|
+
type SharedLayerConfig,
|
|
21
26
|
type StateFile,
|
|
22
27
|
} from "../drift/index.js";
|
|
23
28
|
|
|
@@ -45,9 +50,21 @@ interface TargetStatus {
|
|
|
45
50
|
note?: string;
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
interface SharedLayerStatus {
|
|
54
|
+
name: string;
|
|
55
|
+
platforms: string[];
|
|
56
|
+
root: string;
|
|
57
|
+
snapshot: boolean;
|
|
58
|
+
snapshot_at: string | null;
|
|
59
|
+
generated_by_target: string | null;
|
|
60
|
+
has_drift: boolean;
|
|
61
|
+
status: "up to date" | "behind" | "needs generation";
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
export interface StatusResult {
|
|
49
65
|
project: string;
|
|
50
66
|
targets: TargetStatus[];
|
|
67
|
+
shared_layers?: SharedLayerStatus[];
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
function configuredTargets(projectDir: string): string[] {
|
|
@@ -141,6 +158,38 @@ function buildTargetStatus(cwd: string, projectDir: string, projectName: string,
|
|
|
141
158
|
};
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
function buildSharedLayerStatus(projectDir: string, layer: SharedLayerConfig): SharedLayerStatus {
|
|
162
|
+
const state = readSharedLayerState(layer);
|
|
163
|
+
if (!state) {
|
|
164
|
+
return {
|
|
165
|
+
name: layer.name,
|
|
166
|
+
platforms: layer.platforms,
|
|
167
|
+
root: layer.root,
|
|
168
|
+
snapshot: false,
|
|
169
|
+
snapshot_at: null,
|
|
170
|
+
generated_by_target: null,
|
|
171
|
+
has_drift: false,
|
|
172
|
+
status: "needs generation",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let hasDrift = false;
|
|
177
|
+
if (layer.tracks.length > 0) {
|
|
178
|
+
hasDrift = hasDriftChanges(computeSharedDrift(projectDir, layer).drift);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
name: layer.name,
|
|
183
|
+
platforms: layer.platforms,
|
|
184
|
+
root: layer.root,
|
|
185
|
+
snapshot: true,
|
|
186
|
+
snapshot_at: state.snapshot_at,
|
|
187
|
+
generated_by_target: state.generated_by_target,
|
|
188
|
+
has_drift: hasDrift,
|
|
189
|
+
status: hasDrift ? "behind" : "up to date",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
144
193
|
export function buildStatusResult(cwd: string = process.cwd()): StatusResult {
|
|
145
194
|
const projectDir = findProjectDir(cwd);
|
|
146
195
|
const projectName = readProjectName(projectDir);
|
|
@@ -148,9 +197,15 @@ export function buildStatusResult(cwd: string = process.cwd()): StatusResult {
|
|
|
148
197
|
buildTargetStatus(cwd, projectDir, projectName, target)
|
|
149
198
|
);
|
|
150
199
|
|
|
200
|
+
const sharedLayers = readSharedLayers(projectDir);
|
|
201
|
+
const sharedLayerStatuses = sharedLayers.length > 0
|
|
202
|
+
? sharedLayers.map((layer) => buildSharedLayerStatus(projectDir, layer))
|
|
203
|
+
: undefined;
|
|
204
|
+
|
|
151
205
|
return {
|
|
152
206
|
project: projectName,
|
|
153
207
|
targets,
|
|
208
|
+
...(sharedLayerStatuses ? { shared_layers: sharedLayerStatuses } : {}),
|
|
154
209
|
};
|
|
155
210
|
}
|
|
156
211
|
|
|
@@ -182,6 +237,21 @@ function printReport(result: StatusResult): void {
|
|
|
182
237
|
console.log(` next: ${target.recommended_next_step}`);
|
|
183
238
|
console.log("");
|
|
184
239
|
}
|
|
240
|
+
|
|
241
|
+
if (result.shared_layers && result.shared_layers.length > 0) {
|
|
242
|
+
console.log("Shared Layers");
|
|
243
|
+
console.log("─────────────");
|
|
244
|
+
for (const layer of result.shared_layers) {
|
|
245
|
+
console.log(`${layer.name} (${layer.platforms.join(", ")})`);
|
|
246
|
+
console.log(` root: ${layer.root}`);
|
|
247
|
+
console.log(` snapshot: ${layer.snapshot ? layer.snapshot_at : "missing"}`);
|
|
248
|
+
if (layer.generated_by_target) {
|
|
249
|
+
console.log(` generated by: ${layer.generated_by_target}`);
|
|
250
|
+
}
|
|
251
|
+
console.log(` status: ${layer.status}`);
|
|
252
|
+
console.log("");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
185
255
|
}
|
|
186
256
|
|
|
187
257
|
export function runStatus(argv: string[]): void {
|