@topogram/cli 0.3.48 → 0.3.50

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.
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
- import { getProjection } from "../shared.js";
3
+ import { buildWebRealization } from "../../../realization/ui/index.js";
4
+ import { buildDesignIntentCoverage, renderDesignIntentCss } from "./design-intent.js";
4
5
 
5
6
  function slugify(value) {
6
7
  return String(value || "page")
@@ -9,14 +10,8 @@ function slugify(value) {
9
10
  .replace(/^-+|-+$/g, "") || "page";
10
11
  }
11
12
 
12
- function titleForScreen(graph, screenId) {
13
- for (const projection of graph.byKind.projection || []) {
14
- const screen = (projection.uiScreens || []).find((entry) => entry.id === screenId);
15
- if (screen?.title) {
16
- return screen.title;
17
- }
18
- }
19
- return screenId
13
+ function titleForScreen(screenId) {
14
+ return String(screenId || "page")
20
15
  .split(/[_\-\s]+/)
21
16
  .filter(Boolean)
22
17
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
@@ -55,10 +50,12 @@ ${body}
55
50
  `;
56
51
  }
57
52
 
58
- function renderStyles() {
59
- return `:root {
60
- color: #182026;
61
- background: #f6f8fb;
53
+ function renderStyles(design) {
54
+ return `${renderDesignIntentCss(design)}
55
+
56
+ :root {
57
+ color: var(--topogram-text-color);
58
+ background: var(--topogram-surface-background);
62
59
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
63
60
  }
64
61
 
@@ -70,14 +67,14 @@ body {
70
67
  display: flex;
71
68
  align-items: center;
72
69
  justify-content: space-between;
73
- gap: 1rem;
70
+ gap: var(--topogram-space-unit);
74
71
  padding: 1rem 1.25rem;
75
- border-bottom: 1px solid #d8e0ea;
76
- background: #ffffff;
72
+ border-bottom: 1px solid var(--topogram-border-color);
73
+ background: var(--topogram-surface-card);
77
74
  }
78
75
 
79
76
  .brand {
80
- color: #182026;
77
+ color: var(--topogram-text-color);
81
78
  font-weight: 700;
82
79
  text-decoration: none;
83
80
  }
@@ -89,27 +86,32 @@ nav {
89
86
  }
90
87
 
91
88
  nav a {
92
- color: #0f5cc0;
89
+ color: var(--topogram-action-primary-background);
93
90
  text-decoration: none;
94
91
  }
95
92
 
96
93
  main {
97
94
  display: grid;
98
- gap: 1rem;
95
+ gap: var(--topogram-space-unit);
99
96
  max-width: 56rem;
100
97
  margin: 0 auto;
101
- padding: 2rem 1.25rem 4rem;
98
+ padding: var(--topogram-page-padding);
102
99
  }
103
100
 
104
101
  .panel {
105
- border: 1px solid #d8e0ea;
106
- border-radius: 8px;
107
- background: #ffffff;
102
+ border: 1px solid var(--topogram-border-color);
103
+ border-radius: var(--topogram-radius-card);
104
+ background: var(--topogram-surface-card);
108
105
  padding: 1.25rem;
109
106
  }
110
107
 
111
108
  .muted {
112
- color: #607284;
109
+ color: var(--topogram-muted-color);
110
+ }
111
+
112
+ a:focus-visible {
113
+ outline: var(--topogram-focus-outline);
114
+ outline-offset: 2px;
113
115
  }
114
116
  `;
115
117
  }
@@ -158,6 +160,67 @@ console.log(\`Checked \${htmlFiles.length} vanilla page(s).\`);
158
160
  `;
159
161
  }
160
162
 
163
+ function buildVanillaGenerationCoverage(contract, files, routes) {
164
+ const diagnostics = [];
165
+ const designIntent = buildDesignIntentCoverage(contract, files, "styles.css");
166
+ diagnostics.push(...designIntent.diagnostics);
167
+ const screens = routes.map((route) => {
168
+ const contents = files[route.file] || "";
169
+ const rendered = Boolean(contents);
170
+ if (!rendered) {
171
+ diagnostics.push({
172
+ code: "screen_route_not_rendered",
173
+ severity: "error",
174
+ screen: route.screenId,
175
+ route: route.path,
176
+ message: `Screen '${route.screenId}' has route '${route.path}' but no vanilla HTML page was generated.`,
177
+ suggested_fix: "Check the vanilla web generator route emission for this screen."
178
+ });
179
+ }
180
+ return {
181
+ id: route.screenId,
182
+ route: route.path,
183
+ page: route.file,
184
+ rendered,
185
+ renderer: rendered ? "generator" : "missing",
186
+ component_usages: []
187
+ };
188
+ });
189
+ return {
190
+ type: "generation_coverage",
191
+ surface: "web",
192
+ generator: "topogram/vanilla-web",
193
+ projection: {
194
+ id: contract.projection.id,
195
+ name: contract.projection.name,
196
+ platform: contract.projection.platform
197
+ },
198
+ summary: {
199
+ routed_screens: screens.length,
200
+ rendered_screens: screens.filter((screen) => screen.rendered).length,
201
+ implementation_screens: 0,
202
+ generator_screens: screens.filter((screen) => screen.renderer === "generator").length,
203
+ component_usages: 0,
204
+ rendered_component_usages: 0,
205
+ diagnostics: diagnostics.length,
206
+ errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length,
207
+ warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length
208
+ },
209
+ design_intent: designIntent.coverage,
210
+ screens,
211
+ diagnostics
212
+ };
213
+ }
214
+
215
+ function assertGenerationCoverage(coverage) {
216
+ const errors = (coverage.diagnostics || []).filter((diagnostic) => diagnostic.severity === "error");
217
+ if (errors.length === 0) {
218
+ return;
219
+ }
220
+ const details = errors.map((diagnostic) => diagnostic.message).join("; ");
221
+ throw new Error(`Vanilla web generation coverage failed: ${details}`);
222
+ }
223
+
161
224
  function renderDevScript() {
162
225
  return `import http from "node:http";
163
226
  import fs from "node:fs";
@@ -192,20 +255,19 @@ http.createServer((req, res) => {
192
255
  }
193
256
 
194
257
  export function generateVanillaWebApp(graph, options = {}) {
195
- const projection = getProjection(graph, options.projectionId);
196
- const routeEntries = projection.uiRoutes?.length
197
- ? projection.uiRoutes
198
- : [{ screenId: "home", path: "/" }];
199
- const routes = routeEntries.map((route) => ({
200
- screenId: route.screenId,
201
- path: route.path,
202
- title: titleForScreen(graph, route.screenId),
203
- file: routeFileName(route.path)
258
+ const realization = buildWebRealization(graph, options);
259
+ const contract = realization.contract;
260
+ const routeScreens = (contract.screens || []).filter((screen) => Boolean(screen.route));
261
+ const routes = (routeScreens.length > 0 ? routeScreens : [{ id: "home", route: "/", title: "Home" }]).map((screen) => ({
262
+ screenId: screen.id,
263
+ path: screen.route || "/",
264
+ title: screen.title || titleForScreen(screen.id),
265
+ file: routeFileName(screen.route || "/")
204
266
  }));
205
267
  const nav = routes.map(({ title, file }) => ({ title, file }));
206
268
  const files = {
207
269
  "package.json": `${JSON.stringify({
208
- name: projection.id,
270
+ name: contract.projection.id,
209
271
  private: true,
210
272
  version: "0.1.0",
211
273
  type: "module",
@@ -215,7 +277,7 @@ export function generateVanillaWebApp(graph, options = {}) {
215
277
  check: "node ./scripts/check.mjs"
216
278
  }
217
279
  }, null, 2)}\n`,
218
- "styles.css": renderStyles(),
280
+ "styles.css": renderStyles(contract.design),
219
281
  "app.js": renderBrowserScript(),
220
282
  "scripts/build.mjs": renderBuildScript(),
221
283
  "scripts/check.mjs": renderCheckScript(),
@@ -229,11 +291,15 @@ export function generateVanillaWebApp(graph, options = {}) {
229
291
  body: ` <section class="panel">
230
292
  <p class="muted">Page ${index + 1} of ${routes.length}</p>
231
293
  <h1>${route.title}</h1>
232
- <p>This page was generated from the <code>${projection.id}</code> Topogram web projection.</p>
294
+ <p>This page was generated from the <code>${contract.projection.id}</code> Topogram web projection.</p>
233
295
  <p class="muted">Generated timestamp: <span data-generated-at>pending</span></p>
234
296
  </section>`
235
297
  });
236
298
  });
237
299
 
300
+ const coverage = buildVanillaGenerationCoverage(contract, files, routes);
301
+ assertGenerationCoverage(coverage);
302
+ files["topogram/generation-coverage.json"] = `${JSON.stringify(coverage, null, 2)}\n`;
303
+ files["topogram/ui-web-contract.json"] = `${JSON.stringify(contract, null, 2)}\n`;
238
304
  return files;
239
305
  }
@@ -2,6 +2,9 @@ import { IMPORT_TRACKS } from "./contracts.js";
2
2
  import { dedupeCandidateRecords, ensureTrailingNewline, idHintify, makeCandidateRecord } from "./shared.js";
3
3
  import { createImportContext } from "./context.js";
4
4
  import { getEnrichersForTrack, getExtractorsForTrack } from "./registry.js";
5
+ import {
6
+ collectionPatternFromPresentations
7
+ } from "../../ui/taxonomy.js";
5
8
 
6
9
  function parseImportTracks(fromValue) {
7
10
  if (!fromValue) {
@@ -95,25 +98,6 @@ function selectDetectionsForTrack(track, detections) {
95
98
  return detections;
96
99
  }
97
100
 
98
- function collectionPatternFromPresentations(presentations) {
99
- if (presentations.includes("data_grid")) return "data_grid_view";
100
- if (presentations.includes("table")) return "resource_table";
101
- if (presentations.includes("cards")) return "resource_cards";
102
- if (presentations.includes("board")) return "board_view";
103
- if (presentations.includes("calendar")) return "calendar_view";
104
- if (presentations.includes("gallery")) return "resource_cards";
105
- return "search_results";
106
- }
107
-
108
- function presentationFromPattern(pattern) {
109
- if (pattern === "data_grid_view") return "data_grid";
110
- if (pattern === "resource_table") return "table";
111
- if (pattern === "resource_cards") return "cards";
112
- if (pattern === "board_view") return "board";
113
- if (pattern === "calendar_view") return "calendar";
114
- return "list";
115
- }
116
-
117
101
  function importedApiCapabilityIds(allCandidates) {
118
102
  return [...(allCandidates?.api?.capabilities || [])]
119
103
  .map((capability) => capability.id_hint)
@@ -181,6 +165,17 @@ function deriveUiComponentCandidates(candidates) {
181
165
  pattern,
182
166
  data_prop: "rows",
183
167
  data_source: loadCapability,
168
+ inferred_props: [{ name: "rows", type: "array", required: true, source: loadCapability }],
169
+ inferred_events: [],
170
+ inferred_region: "results",
171
+ inferred_pattern: pattern,
172
+ evidence: screen.provenance || [],
173
+ missing_decisions: [
174
+ "confirm component reuse boundary",
175
+ "confirm prop names and data source",
176
+ "confirm events and behavior",
177
+ "confirm supported regions and patterns"
178
+ ],
184
179
  notes: [
185
180
  "Imported component candidates are review-only.",
186
181
  "Confirm props, behavior, events, and reuse before adoption."
@@ -294,8 +289,11 @@ function reportMarkdown(track, candidates) {
294
289
  );
295
290
  }
296
291
  if (track === "ui") {
292
+ const componentLines = (candidates.components || []).map((component) =>
293
+ `- \`${component.id_hint}\` confidence ${component.confidence || "unknown"} pattern \`${component.pattern || component.inferred_pattern || "unknown"}\` region \`${component.region || component.inferred_region || "unknown"}\` evidence ${(component.evidence || component.provenance || []).length} missing decisions ${(component.missing_decisions || []).length}`
294
+ );
297
295
  return ensureTrailingNewline(
298
- `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Components: ${candidates.components.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n`
296
+ `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Components: ${candidates.components.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Component Candidates\n\n${componentLines.length ? componentLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review candidates under \`topogram/candidates/app/ui/drafts/components/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram component check <path>\`, and \`topogram component behavior <path>\`.\n`
299
297
  );
300
298
  }
301
299
  if (track === "verification") {
@@ -327,7 +325,15 @@ function componentCandidateFileName(component) {
327
325
  }
328
326
 
329
327
  function renderComponentCandidate(component) {
328
+ const evidenceCount = (component.evidence || component.provenance || []).length;
329
+ const missingDecisions = component.missing_decisions || [
330
+ "confirm component reuse boundary",
331
+ "confirm prop names and data source",
332
+ "confirm events and behavior"
333
+ ];
330
334
  return `component ${component.id_hint} {
335
+ # Import metadata: confidence ${component.confidence || "unknown"}; evidence ${evidenceCount}; inferred pattern ${component.pattern || component.inferred_pattern || "search_results"}; inferred region ${component.region || component.inferred_region || "results"}.
336
+ # Missing decisions: ${missingDecisions.join("; ")}.
331
337
  name "${component.label || component.id_hint}"
332
338
  description "Candidate reusable component inferred from imported UI evidence. Review props, behavior, events, and reuse before adoption."
333
339
  category collection
@@ -353,6 +359,26 @@ function uiComponentLinesForCandidates(componentCandidates, allCandidates) {
353
359
  });
354
360
  }
355
361
 
362
+ function enrichUiComponentDataSources(uiCandidates, allCandidates) {
363
+ if (!uiCandidates || !Array.isArray(uiCandidates.components)) {
364
+ return uiCandidates;
365
+ }
366
+ return {
367
+ ...uiCandidates,
368
+ components: uiCandidates.components.map((component) => {
369
+ const dataSource = inferredDataSourceForComponent(component, allCandidates);
370
+ const dataProp = component.data_prop || "rows";
371
+ return {
372
+ ...component,
373
+ data_source: component.data_source || dataSource,
374
+ inferred_props: (component.inferred_props || []).map((prop) =>
375
+ prop.name === dataProp ? { ...prop, source: prop.source || dataSource } : prop
376
+ )
377
+ };
378
+ })
379
+ };
380
+ }
381
+
356
382
  function draftUiProjectionFiles(context, candidates, allCandidates = {}) {
357
383
  const ui = candidates || { screens: [], routes: [], actions: [], stacks: [] };
358
384
  const screens = [...(ui.screens || [])].sort((a, b) => String(a.route_path || "").localeCompare(String(b.route_path || "")) || a.id_hint.localeCompare(b.id_hint));
@@ -477,6 +503,20 @@ ${capabilityHints.length > 0 ? capabilityHints.map((hint) => ` ${hint}`).join
477
503
  shell ${shell}
478
504
  ${presentations.includes("search") ? " global_search true\n" : ""}${presentations.includes("multi_window") ? " windowing multi_window\n" : ""} }
479
505
 
506
+ ui_design {
507
+ density comfortable
508
+ tone operational
509
+ radius_scale medium
510
+ color_role primary accent
511
+ color_role danger critical
512
+ typography_role body readable
513
+ typography_role heading prominent
514
+ action_role primary prominent
515
+ action_role destructive danger
516
+ accessibility contrast aa
517
+ accessibility focus visible
518
+ }
519
+
480
520
  ui_screens {
481
521
  ${uiScreensBlock}
482
522
  }
@@ -616,6 +656,9 @@ export function runImportApp(inputPath, options = {}) {
616
656
  }
617
657
 
618
658
  if (candidates.ui) {
659
+ candidates.ui = enrichUiComponentDataSources(candidates.ui, candidates);
660
+ files["candidates/app/ui/candidates.json"] = `${JSON.stringify(candidates.ui, null, 2)}\n`;
661
+ files["candidates/app/ui/report.md"] = reportMarkdown("ui", candidates.ui);
619
662
  Object.assign(files, draftUiProjectionFiles(context, candidates.ui, candidates));
620
663
  }
621
664
 
@@ -1,5 +1,6 @@
1
1
  import { getProjection, uiProjectionCandidates } from "../../generator/surfaces/shared.js";
2
2
  import { buildComponentBehaviorRealizations } from "../../component-behavior.js";
3
+ import { defaultPatternForScreen } from "../../ui/taxonomy.js";
3
4
 
4
5
  function toBooleanFlag(value, fallback = false) {
5
6
  if (value === "true") return true;
@@ -47,21 +48,6 @@ function ownershipFieldByCapability(graph) {
47
48
  return output;
48
49
  }
49
50
 
50
- function deriveDefaultPattern(screen, collectionEntries) {
51
- if (screen.kind === "detail") return "detail_panel";
52
- if (screen.kind === "form") return "edit_form";
53
- if (screen.kind === "board") return "board_view";
54
- if (screen.kind === "calendar") return "calendar_view";
55
- if (screen.kind === "dashboard" || screen.kind === "analytics" || screen.kind === "report") return "summary_stats";
56
- if (screen.kind === "feed" || screen.kind === "inbox") return "activity_feed";
57
- const view = collectionEntries.find((entry) => entry.operation === "view")?.value;
58
- if (view === "data_grid") return "data_grid_view";
59
- if (view === "table") return "resource_table";
60
- if (view === "cards" || view === "gallery") return "resource_cards";
61
- if (screen.kind === "list") return "resource_table";
62
- return null;
63
- }
64
-
65
51
  function componentById(graph, componentId) {
66
52
  return (graph.byKind.component || []).find((component) => component.id === componentId) || null;
67
53
  }
@@ -88,6 +74,50 @@ function regionContractFor(regionEntries, regionName) {
88
74
  return (regionEntries || []).find((entry) => entry.region === regionName) || null;
89
75
  }
90
76
 
77
+ function buildDesignIntentContract(projection) {
78
+ const design = {
79
+ density: "comfortable",
80
+ tone: "operational",
81
+ radiusScale: "medium",
82
+ colorRoles: {},
83
+ typographyRoles: {},
84
+ actionRoles: {},
85
+ accessibility: {}
86
+ };
87
+
88
+ for (const entry of projection.uiDesign || []) {
89
+ if (entry.key === "density" && entry.role) {
90
+ design.density = entry.role;
91
+ continue;
92
+ }
93
+ if (entry.key === "tone" && entry.role) {
94
+ design.tone = entry.role;
95
+ continue;
96
+ }
97
+ if (entry.key === "radius_scale" && entry.role) {
98
+ design.radiusScale = entry.role;
99
+ continue;
100
+ }
101
+ if (entry.key === "color_role" && entry.role && entry.value) {
102
+ design.colorRoles[entry.role] = entry.value;
103
+ continue;
104
+ }
105
+ if (entry.key === "typography_role" && entry.role && entry.value) {
106
+ design.typographyRoles[entry.role] = entry.value;
107
+ continue;
108
+ }
109
+ if (entry.key === "action_role" && entry.role && entry.value) {
110
+ design.actionRoles[entry.role] = entry.value;
111
+ continue;
112
+ }
113
+ if (entry.key === "accessibility" && entry.role && entry.value) {
114
+ design.accessibility[entry.role] = entry.value;
115
+ }
116
+ }
117
+
118
+ return design;
119
+ }
120
+
91
121
  export function buildComponentUsageContract(graph, entry, options = {}) {
92
122
  const componentId = entry.component?.id || null;
93
123
  const contract = componentId ? componentContractFor(graph, componentId) : null;
@@ -184,7 +214,7 @@ function buildUiScreenContract(graph, projection, screen, ownershipFields) {
184
214
  );
185
215
  const visibilityEntries = (projection.uiVisibility || []).filter((entry) => screenActionIds.has(entry.capability?.id));
186
216
  const patterns = new Set(regionEntries.map((entry) => entry.pattern).filter(Boolean));
187
- const derivedDefaultPattern = deriveDefaultPattern(screen, collectionEntries);
217
+ const derivedDefaultPattern = defaultPatternForScreen(screen, collectionEntries);
188
218
  if (derivedDefaultPattern) {
189
219
  patterns.add(derivedDefaultPattern);
190
220
  }
@@ -286,6 +316,7 @@ export function buildUiSharedRealization(graph, options = {}) {
286
316
  realizes: projection.realizes,
287
317
  outputs: projection.outputs,
288
318
  components: buildComponentContractMap(graph, componentUsages),
319
+ design: buildDesignIntentContract(projection),
289
320
  appShell: buildAppShellContract(projection),
290
321
  navigation: buildNavigationContract(projection, screens),
291
322
  screens
@@ -305,6 +336,7 @@ export function buildUiSharedRealization(graph, options = {}) {
305
336
  realizes: projection.realizes,
306
337
  outputs: projection.outputs,
307
338
  components: buildComponentContractMap(graph, componentUsages),
339
+ design: buildDesignIntentContract(projection),
308
340
  appShell: buildAppShellContract(projection),
309
341
  navigation: buildNavigationContract(projection, screens),
310
342
  screens
@@ -1,8 +1,6 @@
1
1
  import { buildApiRealization } from "../api/index.js";
2
2
  import { generatorDefaultsMap, getProjection, sharedUiProjectionForWeb } from "../../generator/surfaces/shared.js";
3
3
  import {
4
- buildComponentContractMap,
5
- buildComponentUsageContract,
6
4
  buildUiSharedRealization
7
5
  } from "./build-ui-shared-realization.js";
8
6
 
@@ -38,6 +36,7 @@ export function buildWebRealization(graph, options = {}) {
38
36
  realizes: [],
39
37
  outputs: [],
40
38
  components: {},
39
+ design: null,
41
40
  screens: []
42
41
  };
43
42
  const concreteContract = buildUiSharedRealization(graph, { projectionId: projection.id });
@@ -70,30 +69,15 @@ export function buildWebRealization(graph, options = {}) {
70
69
  screenMap.set(screen.id, {
71
70
  ...existing,
72
71
  ...screen,
73
- components: [...(existing.components || []), ...(screen.components || [])],
72
+ components: [...(existing.components || [])],
74
73
  regions: mergeByKey(existing.regions || [], screen.regions || [], (entry) => entry.region),
75
74
  patterns: [...new Set([...(existing.patterns || []), ...(screen.patterns || [])])]
76
75
  });
77
76
  }
78
- for (const entry of projection.uiComponents || []) {
79
- if (!screenMap.has(entry.screenId)) {
80
- continue;
81
- }
82
- const screen = screenMap.get(entry.screenId);
83
- if ((screen.components || []).some((usage) => componentUsageFingerprint(usage) === componentUsageFingerprintFromEntry(entry))) {
84
- continue;
85
- }
86
- screen.components = [...(screen.components || []), buildComponentUsageContract(graph, entry, {
87
- region: (screen.regions || []).find((region) => region.region === entry.region) || null
88
- })];
89
- }
90
77
 
91
78
  const appShell = projection.uiAppShell?.length || !sharedProjection ? concreteContract.appShell : sharedContract.appShell;
92
79
  const navigation = projection.uiNavigation?.length || !sharedProjection ? concreteContract.navigation : sharedContract.navigation;
93
- const componentContracts = {
94
- ...(sharedContract.components || {}),
95
- ...buildComponentContractMap(graph, projection.uiComponents || [])
96
- };
80
+ const design = projection.uiDesign?.length || !sharedProjection ? concreteContract.design : sharedContract.design;
97
81
 
98
82
  const contract = {
99
83
  projection: {
@@ -109,7 +93,8 @@ export function buildWebRealization(graph, options = {}) {
109
93
  : null,
110
94
  generatorDefaults: generatorDefaultsMap(projection),
111
95
  outputs: projection.outputs,
112
- components: componentContracts,
96
+ components: sharedProjection ? (sharedContract.components || {}) : (concreteContract.components || {}),
97
+ design: design || null,
113
98
  appShell: appShell || null,
114
99
  navigation: {
115
100
  groups: navigation?.groups || [],
@@ -171,21 +156,3 @@ function mergeByKey(left, right, keyFn) {
171
156
  for (const entry of right) output.set(keyFn(entry), { ...(output.get(keyFn(entry)) || {}), ...entry });
172
157
  return [...output.values()];
173
158
  }
174
-
175
- function componentUsageFingerprint(usage) {
176
- return [
177
- usage?.region || "",
178
- usage?.component?.id || "",
179
- ...(usage?.dataBindings || []).map((binding) => `data:${binding.prop}:${binding.source?.id || ""}`),
180
- ...(usage?.eventBindings || []).map((binding) => `event:${binding.event}:${binding.action}:${binding.target?.id || ""}`)
181
- ].join("|");
182
- }
183
-
184
- function componentUsageFingerprintFromEntry(entry) {
185
- return [
186
- entry?.region || "",
187
- entry?.component?.id || "",
188
- ...(entry?.dataBindings || []).map((binding) => `data:${binding.prop}:${binding.source?.id || ""}`),
189
- ...(entry?.eventBindings || []).map((binding) => `event:${binding.event}:${binding.action}:${binding.target?.id || ""}`)
190
- ].join("|");
191
- }
@@ -503,6 +503,7 @@ function buildProjectionPlan(statement) {
503
503
  uiVisibility: statement.uiVisibility,
504
504
  uiRoutes: statement.uiRoutes,
505
505
  uiWeb: statement.uiWeb,
506
+ uiDesign: statement.uiDesign,
506
507
  uiComponents: statement.uiComponents,
507
508
  dbTables: statement.dbTables,
508
509
  dbColumns: statement.dbColumns,
@@ -1269,6 +1270,17 @@ function parseProjectionUiAppShellBlock(statement) {
1269
1270
  }));
1270
1271
  }
1271
1272
 
1273
+ function parseProjectionUiDesignBlock(statement) {
1274
+ return blockEntries(getFieldValue(statement, "ui_design")).map((entry) => ({
1275
+ type: "ui_design_token",
1276
+ key: tokenValue(entry.items[0]) || null,
1277
+ role: tokenValue(entry.items[1]) || null,
1278
+ value: tokenValue(entry.items[2]) || null,
1279
+ raw: normalizeSequence(entry.items),
1280
+ loc: entry.loc
1281
+ }));
1282
+ }
1283
+
1272
1284
  function parseProjectionUiNavigationBlock(statement) {
1273
1285
  return blockEntries(getFieldValue(statement, "ui_navigation")).map((entry) => {
1274
1286
  const directives = {};
@@ -1869,6 +1881,7 @@ export function normalizeStatement(statement, registry) {
1869
1881
  uiWeb: parseProjectionUiWebBlock(statement, registry),
1870
1882
  uiIos: parseProjectionUiIosBlock(statement, registry),
1871
1883
  uiAppShell: parseProjectionUiAppShellBlock(statement),
1884
+ uiDesign: parseProjectionUiDesignBlock(statement),
1872
1885
  uiNavigation: parseProjectionUiNavigationBlock(statement),
1873
1886
  uiScreenRegions: parseProjectionUiScreenRegionsBlock(statement),
1874
1887
  uiComponents: parseProjectionUiComponentsBlock(statement, registry),