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 CHANGED
@@ -46,7 +46,7 @@ This scaffolds a spec directory, starter tokens, and **configures the MCP server
46
46
  ## Key concepts
47
47
 
48
48
  - **Tokens** — design values (color, typography, spacing, elevation, motion) with semantic names and constrained ranges
49
- - **Contracts** — 7 behavioral component families defined by role, props, state machines, and accessibility
49
+ - **Contracts** — 7 reusable UI component families defined by role, props, interaction states, and accessibility
50
50
  - **Screens** — compositions of contracts with data bindings, adaptive layout, and conditional rendering
51
51
  - **Flows** — multi-screen navigation journeys, intent-based and platform-agnostic
52
52
  - **Actions** — 13 typed action types with composition, error handling, and optimistic updates
@@ -80,7 +80,7 @@ When you ask your AI to "add a settings page" or "update the home feed," the MCP
80
80
 
81
81
  **Using without MCP?** You can provide spec context to any AI manually:
82
82
 
83
- > Generate a native iOS app from this OpenUISpec. Follow all contract state machines, apply token ranges for iOS, and implement navigation flows as defined.
83
+ > Generate a native iOS app from this OpenUISpec. Follow all contract UI states, apply token ranges for iOS, and implement navigation flows as defined.
84
84
 
85
85
  ## Examples
86
86
 
package/check/index.ts CHANGED
@@ -14,10 +14,13 @@ import { existsSync, readFileSync } from "node:fs";
14
14
  import { join, resolve } from "node:path";
15
15
  import YAML from "yaml";
16
16
  import {
17
+ computeSharedDrift,
17
18
  findProjectDir,
19
+ hasDriftChanges,
18
20
  readManifest,
19
21
  readProjectName,
20
22
  resolveOutputDir,
23
+ sharedLayersForTarget,
21
24
  } from "../drift/index.js";
22
25
  import {
23
26
  buildAjv,
@@ -153,6 +156,20 @@ function determinePrepare(
153
156
  );
154
157
  }
155
158
 
159
+ // Check for shared layer drift (only when tracks are configured)
160
+ const sharedLayers = sharedLayersForTarget(projectDir, target);
161
+ for (const layer of sharedLayers) {
162
+ if (layer.tracks.length === 0) continue;
163
+ const driftResult = computeSharedDrift(projectDir, layer);
164
+ if (driftResult.state !== null) {
165
+ if (hasDriftChanges(driftResult.drift)) {
166
+ warnings.push(
167
+ `Shared layer "${layer.name}" has spec drift — shared code may need updates before ${target} generation.`
168
+ );
169
+ }
170
+ }
171
+ }
172
+
156
173
  const ready =
157
174
  missing.length === 0 && backendContextReady && !pendingUserConfirmation;
158
175
 
package/cli/index.ts CHANGED
@@ -95,6 +95,19 @@ function checkRulesVersion(): void {
95
95
 
96
96
  // ── spec helpers (shared with MCP server) ────────────────────────────
97
97
 
98
+ function lookupLocaleKey(content: Record<string, unknown>, key: string): { found: boolean; value: unknown } {
99
+ if (key in content) return { found: true, value: content[key] };
100
+ const parts = key.split(".");
101
+ let current: unknown = content;
102
+ for (const part of parts) {
103
+ if (current === null || current === undefined || typeof current !== "object" || Array.isArray(current)) {
104
+ return { found: false, value: undefined };
105
+ }
106
+ current = (current as Record<string, unknown>)[part];
107
+ }
108
+ return current !== undefined ? { found: true, value: current } : { found: false, value: undefined };
109
+ }
110
+
98
111
  function resolveSpecDir(projectDir: string, manifest: any, key: string): string {
99
112
  return resolve(projectDir, manifest.includes?.[key] ?? `./${key}/`);
100
113
  }
@@ -293,7 +306,10 @@ async function main(): Promise<void> {
293
306
  const content = JSON.parse(readFileSync(filePath, "utf-8"));
294
307
  if (keys) {
295
308
  const filtered: Record<string, unknown> = {};
296
- for (const key of keys) { if (key in content) filtered[key] = content[key]; }
309
+ for (const key of keys) {
310
+ const result = lookupLocaleKey(content, key);
311
+ if (result.found) filtered[key] = result.value;
312
+ }
297
313
  console.log(JSON.stringify(filtered, null, 2));
298
314
  } else {
299
315
  console.log(JSON.stringify(content, null, 2));
package/cli/init.ts CHANGED
@@ -33,6 +33,7 @@ export type InitOptionsResponse = {
33
33
  options?: string[];
34
34
  }>;
35
35
  configure_targets_note: string;
36
+ shared_layer_note?: string;
36
37
  };
37
38
 
38
39
  export function listInitOptions(): InitOptionsResponse {
@@ -78,9 +79,17 @@ export function listInitOptions(): InitOptionsResponse {
78
79
  type: "yes_no",
79
80
  default: defaults.configureTargets,
80
81
  },
82
+ {
83
+ key: "with_shared",
84
+ prompt: "Does the project share code between platforms (e.g. KMP commonMain)?",
85
+ type: "yes_no",
86
+ default: false,
87
+ },
81
88
  ],
82
89
  configure_targets_note:
83
90
  "If configure_targets is true, use `openuispec configure-target <target> --list-options` for each target after init to present stack choices to the user.",
91
+ shared_layer_note:
92
+ "If with_shared is true, add shared layer config to generation.shared in the manifest. Each shared layer needs: name, platforms (subset of targets), language, root (path relative to openuispec.yaml), tracks (spec categories: manifest, contracts, flows, screens, tokens, platform, locales), and scope (what code belongs there). Also add generation.structure entries for each target to define where platform-specific UI code goes and its scope.",
84
93
  };
85
94
  }
86
95
 
@@ -171,7 +180,12 @@ function getPackageVersion(): string {
171
180
  function manifestTemplate(
172
181
  name: string,
173
182
  targets: string[],
174
- options: { withApi: boolean; backendPath: string | null }
183
+ options: {
184
+ withApi: boolean;
185
+ backendPath: string | null;
186
+ sharedLayers: SharedLayerAnswers[];
187
+ structures: StructureAnswers[];
188
+ }
175
189
  ): string {
176
190
  const targetList = targets.join(", ");
177
191
  const outputLines = targets
@@ -186,6 +200,38 @@ function manifestTemplate(
186
200
  })
187
201
  .join("\n");
188
202
 
203
+ function yamlPathsBlock(paths: Record<string, string>): string {
204
+ const entries = Object.entries(paths);
205
+ return entries.length > 0
206
+ ? `\n paths:\n${entries.map(([k, v]) => ` ${k}: "${v}"`).join("\n")}`
207
+ : "";
208
+ }
209
+
210
+ let sharedBlock = "";
211
+ if (options.sharedLayers.length > 0) {
212
+ const layers = options.sharedLayers.map((layer) => {
213
+ const tracksLine = layer.tracks.length > 0
214
+ ? `\n tracks: [${layer.tracks.join(", ")}]`
215
+ : "";
216
+ return ` ${layer.name}:
217
+ platforms: [${layer.platforms.join(", ")}]
218
+ language: ${layer.language}
219
+ root: "${layer.root}"
220
+ scope: "${layer.scope}"${tracksLine}${yamlPathsBlock(layer.paths)}`;
221
+ }).join("\n");
222
+ sharedBlock = ` shared:\n${layers}\n`;
223
+ }
224
+
225
+ let structureBlock = "";
226
+ if (options.structures.length > 0) {
227
+ const entries = options.structures.map((s) => {
228
+ const scopeLine = s.scope ? `\n scope: "${s.scope}"` : "";
229
+ return ` ${s.target}:
230
+ root: "${s.root}"${scopeLine}${yamlPathsBlock(s.paths)}`;
231
+ }).join("\n");
232
+ structureBlock = ` structure:\n${entries}\n`;
233
+ }
234
+
189
235
  return `# ${name} — OpenUISpec v0.1
190
236
  spec_version: "0.1"
191
237
 
@@ -218,7 +264,7 @@ ${options.withApi ? ` code_roots:
218
264
  backend: "${options.backendPath}" # Required when api.endpoints are declared
219
265
  ` : ""} output_format:
220
266
  ${outputLines}
221
-
267
+ ${sharedBlock}${structureBlock}
222
268
  data_model: {}
223
269
 
224
270
  api:
@@ -679,6 +725,23 @@ export function extractRulesVersion(filePath: string): string | null {
679
725
 
680
726
  export { getPackageVersion };
681
727
 
728
+ interface SharedLayerAnswers {
729
+ name: string;
730
+ platforms: string[];
731
+ language: string;
732
+ root: string;
733
+ tracks: string[];
734
+ scope: string;
735
+ paths: Record<string, string>;
736
+ }
737
+
738
+ interface StructureAnswers {
739
+ target: string;
740
+ root: string;
741
+ scope: string;
742
+ paths: Record<string, string>;
743
+ }
744
+
682
745
  interface InitOptions {
683
746
  defaults: boolean;
684
747
  quiet: boolean;
@@ -688,6 +751,7 @@ interface InitOptions {
688
751
  withApi?: boolean;
689
752
  backendPath?: string;
690
753
  configureTargets?: boolean;
754
+ withShared?: boolean;
691
755
  }
692
756
 
693
757
  interface InitAnswers {
@@ -697,6 +761,8 @@ interface InitAnswers {
697
761
  withApi: boolean;
698
762
  backendPath: string | null;
699
763
  configureTargets: boolean;
764
+ sharedLayers: SharedLayerAnswers[];
765
+ structures: StructureAnswers[];
700
766
  }
701
767
 
702
768
  function parseTargetsValue(raw: string): string[] {
@@ -752,6 +818,12 @@ function parseInitArgs(argv: string[]): InitOptions {
752
818
  case "--no-configure-targets":
753
819
  options.configureTargets = false;
754
820
  break;
821
+ case "--with-shared":
822
+ options.withShared = true;
823
+ break;
824
+ case "--no-shared":
825
+ options.withShared = false;
826
+ break;
755
827
  default:
756
828
  if (arg.startsWith("--")) {
757
829
  console.error(`Error: Unknown init option: ${arg}`);
@@ -773,9 +845,127 @@ function collectDefaults(): InitAnswers {
773
845
  withApi: true,
774
846
  backendPath: "../backend/",
775
847
  configureTargets: true,
848
+ sharedLayers: [],
849
+ structures: [],
776
850
  };
777
851
  }
778
852
 
853
+ const SHARED_LAYER_DEFAULTS: Record<string, {
854
+ language: string;
855
+ root: string;
856
+ tracks: string[];
857
+ scope: string;
858
+ paths: Record<string, string>;
859
+ structureScope: Record<string, string>;
860
+ }> = {
861
+ kmp: {
862
+ language: "kotlin",
863
+ root: "../shared",
864
+ tracks: [],
865
+ scope: "Business logic, data models, repositories, API clients, view models/stores. No UI rendering.",
866
+ paths: { domain: "commonMain/domain/", features: "commonMain/features/" },
867
+ structureScope: {
868
+ ios: "Pure SwiftUI views and navigation. All business logic comes from the shared layer.",
869
+ android: "Pure Compose UI and navigation. All business logic comes from the shared layer.",
870
+ },
871
+ },
872
+ };
873
+
874
+ function defaultSharedConfig(targets: string[]): {
875
+ sharedLayers: SharedLayerAnswers[];
876
+ structures: StructureAnswers[];
877
+ } {
878
+ const mobilePlatforms = targets.filter((t) => t === "ios" || t === "android");
879
+ if (mobilePlatforms.length < 2) {
880
+ return { sharedLayers: [], structures: [] };
881
+ }
882
+
883
+ const preset = SHARED_LAYER_DEFAULTS.kmp;
884
+ const sharedLayers: SharedLayerAnswers[] = [{
885
+ name: "mobile_common",
886
+ platforms: mobilePlatforms,
887
+ language: preset.language,
888
+ root: preset.root,
889
+ tracks: preset.tracks,
890
+ scope: preset.scope,
891
+ paths: preset.paths,
892
+ }];
893
+
894
+ const structures: StructureAnswers[] = mobilePlatforms.map((t) => ({
895
+ target: t,
896
+ root: preset.root,
897
+ scope: preset.structureScope[t] ?? `Pure ${t} UI. Business logic comes from the shared layer.`,
898
+ paths: { ui: `${t}App/ui/` },
899
+ }));
900
+
901
+ return { sharedLayers, structures };
902
+ }
903
+
904
+ async function collectSharedLayerAnswers(
905
+ rl: ReturnType<typeof createInterface>,
906
+ targets: string[],
907
+ ): Promise<{ sharedLayers: SharedLayerAnswers[]; structures: StructureAnswers[] }> {
908
+ const withShared = await askYesNo(rl, "\nShare code between platforms (e.g. KMP commonMain)?", false);
909
+ if (!withShared) return { sharedLayers: [], structures: [] };
910
+
911
+ const defaults = defaultSharedConfig(targets);
912
+ const defaultLayer = defaults.sharedLayers[0];
913
+ if (!defaultLayer) return { sharedLayers: [], structures: [] };
914
+
915
+ console.log("\n Shared layer defaults (KMP):");
916
+ console.log(` platforms: ${defaultLayer.platforms.join(", ")}`);
917
+ console.log(` language: ${defaultLayer.language}`);
918
+ console.log(` root: ${defaultLayer.root}`);
919
+ console.log(` scope: ${defaultLayer.scope}`);
920
+
921
+ const useDefaults = await askYesNo(rl, " Use these defaults?", true);
922
+ if (useDefaults) return defaults;
923
+
924
+ const layerName = await ask(rl, " Shared layer name", defaultLayer.name);
925
+ const platformsRaw = await askList(rl, " Platforms", targets, defaultLayer.platforms);
926
+ const language = await ask(rl, " Language", defaultLayer.language);
927
+ const root = await ask(rl, " Root path (relative to openuispec.yaml)", defaultLayer.root);
928
+ const scope = await ask(rl, " Scope (what code belongs here)", defaultLayer.scope);
929
+ const wantTracks = await askYesNo(rl, " Enable hash-based drift tracking for this layer?", false);
930
+ const tracksRaw = wantTracks
931
+ ? await askList(
932
+ rl,
933
+ " Tracked spec categories",
934
+ ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"],
935
+ ["manifest", "contracts", "flows"]
936
+ )
937
+ : [];
938
+
939
+ const sharedLayers: SharedLayerAnswers[] = [{
940
+ name: layerName,
941
+ platforms: platformsRaw,
942
+ language,
943
+ root,
944
+ tracks: tracksRaw,
945
+ scope,
946
+ paths: defaultLayer.paths,
947
+ }];
948
+
949
+ const structures: StructureAnswers[] = [];
950
+ for (const t of platformsRaw) {
951
+ const defaultStructure = defaults.structures.find((s) => s.target === t);
952
+ const structRoot = await ask(rl, ` ${t} structure root`, defaultStructure?.root ?? root);
953
+ const structScope = await ask(
954
+ rl,
955
+ ` ${t} scope (what code belongs in the ${t} target)`,
956
+ defaultStructure?.scope ?? `Pure ${t} UI rendering.`
957
+ );
958
+ structures.push({
959
+ target: t,
960
+ root: structRoot,
961
+ scope: structScope,
962
+ paths: defaultStructure?.paths ?? { ui: `${t}App/ui/` },
963
+ });
964
+ }
965
+
966
+ return { sharedLayers, structures };
967
+ }
968
+
779
969
  async function collectInteractiveAnswers(rl: ReturnType<typeof createInterface>): Promise<InitAnswers> {
780
970
  const defaults = collectDefaults();
781
971
  const name = await ask(rl, "Project name", defaults.name);
@@ -792,6 +982,7 @@ async function collectInteractiveAnswers(rl: ReturnType<typeof createInterface>)
792
982
  ? await ask(rl, "Backend folder path relative to openuispec.yaml", defaults.backendPath ?? "../backend/")
793
983
  : null;
794
984
  const configureTargets = await askYesNo(rl, "Configure target stacks now?", defaults.configureTargets);
985
+ const { sharedLayers, structures } = await collectSharedLayerAnswers(rl, targets);
795
986
 
796
987
  return {
797
988
  name,
@@ -800,6 +991,8 @@ async function collectInteractiveAnswers(rl: ReturnType<typeof createInterface>)
800
991
  withApi,
801
992
  backendPath,
802
993
  configureTargets,
994
+ sharedLayers,
995
+ structures,
803
996
  };
804
997
  }
805
998
 
@@ -823,6 +1016,8 @@ function collectNonInteractiveAnswers(argv: string[]): InitAnswers {
823
1016
 
824
1017
  const withApi = parsed.withApi ?? defaults.withApi;
825
1018
  const backendPath = withApi ? parsed.backendPath ?? defaults.backendPath : null;
1019
+ const withShared = parsed.withShared ?? false;
1020
+ const { sharedLayers, structures } = withShared ? defaultSharedConfig(targets) : { sharedLayers: [], structures: [] };
826
1021
 
827
1022
  return {
828
1023
  name: parsed.name ?? defaults.name,
@@ -831,6 +1026,8 @@ function collectNonInteractiveAnswers(argv: string[]): InitAnswers {
831
1026
  withApi,
832
1027
  backendPath,
833
1028
  configureTargets: parsed.configureTargets ?? defaults.configureTargets,
1029
+ sharedLayers,
1030
+ structures,
834
1031
  };
835
1032
  }
836
1033
 
@@ -879,6 +1076,8 @@ export async function init(argv: string[] = []): Promise<void> {
879
1076
  manifestTemplate(answers.name, answers.targets, {
880
1077
  withApi: answers.withApi,
881
1078
  backendPath: answers.backendPath,
1079
+ sharedLayers: answers.sharedLayers,
1080
+ structures: answers.structures,
882
1081
  }),
883
1082
  quiet
884
1083
  );
@@ -64,6 +64,42 @@ Paths are relative to `openuispec.yaml`. The `.openuispec-state.json` file recor
64
64
  - `generation.extra_rules` can hold project-wide generation conventions
65
65
  - `drift --snapshot` requires that target output directory to already exist
66
66
 
67
+ ## Shared code layers
68
+
69
+ Projects that share business logic between platforms (e.g. KMP `commonMain`) can declare `generation.shared` to tell AI what code belongs in the shared layer vs platform-specific targets:
70
+
71
+ ```yaml
72
+ generation:
73
+ targets: [ios, android, web]
74
+ shared:
75
+ mobile_common:
76
+ platforms: [ios, android]
77
+ language: kotlin
78
+ root: "../shared"
79
+ scope: "Business logic, data models, repositories, API clients, view models. No UI rendering."
80
+ # tracks: [manifest] # optional — enables hash-based drift detection for this layer
81
+ paths:
82
+ domain: "commonMain/domain/"
83
+ features: "commonMain/features/"
84
+ structure:
85
+ ios:
86
+ root: "../shared"
87
+ scope: "Pure SwiftUI views and navigation. All business logic comes from the shared layer."
88
+ paths:
89
+ ui: "iosApp/ui/"
90
+ android:
91
+ root: "../shared"
92
+ scope: "Pure Compose UI and navigation. All business logic comes from the shared layer."
93
+ paths:
94
+ ui: "androidApp/ui/"
95
+ ```
96
+
97
+ - **`scope`** (required on shared, optional on structure) — tells AI what code belongs where. This is the primary mechanism for routing generation work between shared and platform layers.
98
+ - **`tracks`** (optional) — when set, enables hash-based drift detection scoped to specific spec categories (`manifest`, `tokens`, `contracts`, `screens`, `flows`, `platform`, `locales`). When omitted, the shared layer relies on `scope` alone.
99
+ - **`structure`** — when present, overrides the heuristic code root discovery for a target. Paths are relative to `root`.
100
+ - Shared layers are not targets — they are tracked alongside targets in `prepare` and `status` output.
101
+ - `openuispec init --with-shared` scaffolds KMP defaults when both ios and android targets are selected.
102
+
67
103
  ## Spec sections overview
68
104
 
69
105
  | Section | What it defines |
@@ -87,6 +87,13 @@
87
87
  - backend generation context
88
88
  - if the manifest declares `api.endpoints`, `generation.code_roots.backend` is required
89
89
  - `prepare` should surface the resolved backend root so AI can inspect backend code when generating API clients
90
+ - Shared code layers (`generation.shared`):
91
+ - when configured, `prepare` includes `shared_layers` in its output with per-layer `scope`, `already_generated`, and `guidance`
92
+ - `scope` tells AI what code belongs in the shared layer vs the platform target — this is the primary routing mechanism
93
+ - optional `tracks` enables hash-based drift detection scoped to specific spec categories
94
+ - `generation.structure` overrides heuristic code root discovery when present, and its `scope` field tells AI what goes in the platform-specific target
95
+ - `suggestCodeRoots` includes shared layer roots and structure paths alongside target output directories
96
+ - generation rules include shared layer and target scope descriptions
90
97
  - Important positioning:
91
98
  - `prepare` does not generate code
92
99
  - `prepare` does not verify code correctness