@jskit-ai/ui-generator 0.1.48 → 0.1.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,7 +1,9 @@
1
+ import { GENERATED_UI_NAVIGATION_ROLE_OPTION } from "@jskit-ai/kernel/shared/support/generatedUiContract";
2
+
1
3
  export default Object.freeze({
2
4
  packageVersion: 1,
3
5
  packageId: "@jskit-ai/ui-generator",
4
- version: "0.1.48",
6
+ version: "0.1.50",
5
7
  kind: "generator",
6
8
  description: "Create non-CRUD pages, reusable UI elements, and subpage hosts.",
7
9
  options: {
@@ -40,23 +42,46 @@ export default Object.freeze({
40
42
  inputType: "text",
41
43
  defaultValue: "",
42
44
  promptLabel: "Placement target",
43
- promptHint: "Optional target for placed-element placement (format: host:position, default: shell-layout:top-right)."
45
+ promptHint: "Semantic placement target for placed-element, outlet, and topology mapping (format: area.slot, default for placed-element: shell.status)."
44
46
  },
45
- "link-placement": {
47
+ kind: {
46
48
  required: false,
47
49
  inputType: "text",
50
+ validationType: "enum",
51
+ allowedValues: ["component", "link"],
48
52
  defaultValue: "",
49
- promptLabel: "Link placement",
50
- promptHint: "Optional target for the generated page link placement (format: host:position)."
53
+ promptLabel: "Placement kind",
54
+ promptHint: "Use component for componentToken-backed entries, or link when topology should provide a link renderer."
51
55
  },
52
- "link-component-token": {
56
+ owner: {
53
57
  required: false,
54
58
  inputType: "text",
55
59
  defaultValue: "",
56
- promptLabel: "Link component token",
57
- promptHint:
58
- "Optional component token override for the generated page link placement (example: local.main.ui.tab-link-item)."
60
+ promptLabel: "Placement owner",
61
+ promptHint: "Optional owner id for semantic topology mappings. Page/settings placements default to the outlet host."
62
+ },
63
+ description: {
64
+ required: false,
65
+ inputType: "text",
66
+ defaultValue: "",
67
+ promptLabel: "Description",
68
+ promptHint: "Optional description for generated semantic topology mappings."
69
+ },
70
+ "link-renderer": {
71
+ required: false,
72
+ inputType: "text",
73
+ defaultValue: "local.main.ui.surface-aware-menu-link-item",
74
+ promptLabel: "Link renderer",
75
+ promptHint: "Default link renderer token for generated topology variants."
59
76
  },
77
+ "link-placement": {
78
+ required: false,
79
+ inputType: "text",
80
+ defaultValue: "",
81
+ promptLabel: "Link placement",
82
+ promptHint: "Optional semantic target for the generated page link placement (format: area.slot)."
83
+ },
84
+ "navigation-role": GENERATED_UI_NAVIGATION_ROLE_OPTION,
60
85
  "link-to": {
61
86
  required: false,
62
87
  inputType: "text",
@@ -70,7 +95,28 @@ export default Object.freeze({
70
95
  inputType: "text",
71
96
  defaultValue: "",
72
97
  promptLabel: "Outlet target",
73
- promptHint: "Used by add-subpages and outlet. Must be a target in host:position format."
98
+ promptHint: "Used by add-subpages, outlet, and topology. Must be a target in host:position format. For topology, maps all layouts unless layout-specific targets are provided."
99
+ },
100
+ "compact-target": {
101
+ required: false,
102
+ inputType: "text",
103
+ defaultValue: "",
104
+ promptLabel: "Compact outlet target",
105
+ promptHint: "Concrete outlet used by topology for compact layouts, in host:position format."
106
+ },
107
+ "medium-target": {
108
+ required: false,
109
+ inputType: "text",
110
+ defaultValue: "",
111
+ promptLabel: "Medium outlet target",
112
+ promptHint: "Concrete outlet used by topology for medium layouts, in host:position format."
113
+ },
114
+ "expanded-target": {
115
+ required: false,
116
+ inputType: "text",
117
+ defaultValue: "",
118
+ promptLabel: "Expanded outlet target",
119
+ promptHint: "Concrete outlet used by topology for expanded layouts, in host:position format."
74
120
  },
75
121
  title: {
76
122
  required: false,
@@ -120,9 +166,9 @@ export default Object.freeze({
120
166
  descriptionKey: "page-target-file"
121
167
  }
122
168
  ],
123
- optionNames: ["name", "link-placement", "link-component-token", "link-to", "force"],
169
+ optionNames: ["name", "navigation-role", "link-placement", "link-to", "force"],
124
170
  notes: [
125
- "If a nearest parent subpages target is found, placement, link component token, and props.to are inferred automatically.",
171
+ "If a nearest parent subpages target is found, semantic placement and props.to are inferred automatically.",
126
172
  "If the parent target page is index.vue, child pages belong under index/...",
127
173
  "If the target page file already exists, rerun with --force to overwrite it."
128
174
  ],
@@ -151,10 +197,10 @@ export default Object.freeze({
151
197
  entrypoint: "src/server/subcommands/element.js",
152
198
  export: "runGeneratorSubcommand",
153
199
  description: "Create a Vue component file under the chosen component directory (default: src/components) and add a placement entry that renders it.",
154
- optionNames: ["name", "surface", "path", "placement", "force"],
200
+ optionNames: ["name", "surface", "path", "placement", "owner", "force"],
155
201
  requiredOptionNames: ["name"],
156
202
  notes: [
157
- "If --placement is omitted, the placed element is added at shell-layout:top-right.",
203
+ "If --placement is omitted, the placed element is added at shell.status.",
158
204
  "If the placement target belongs to a page-owned outlet, JSKIT infers the surface automatically.",
159
205
  "If the target is shared and the app has multiple enabled surfaces, pass --surface explicitly.",
160
206
  "If the component file already exists, rerun with --force to overwrite it."
@@ -174,7 +220,7 @@ export default Object.freeze({
174
220
  " --name \"Ops Panel\" \\",
175
221
  " --surface admin \\",
176
222
  " --path src/widgets \\",
177
- " --placement shell-layout:top-right \\",
223
+ " --placement shell.status \\",
178
224
  " --force"
179
225
  ]
180
226
  }
@@ -204,7 +250,7 @@ export default Object.freeze({
204
250
  "npx jskit generate ui-generator add-subpages \\",
205
251
  " admin/customers/[customerId]/index.vue \\",
206
252
  " --title \"Customer\" \\",
207
- " --subtitle \"View and manage this customer.\""
253
+ " --subtitle \"Customer profile and activity.\""
208
254
  ]
209
255
  },
210
256
  {
@@ -226,9 +272,9 @@ export default Object.freeze({
226
272
  export: "runGeneratorSubcommand",
227
273
  description: "Inject a generic ShellOutlet block into an existing Vue page/component.",
228
274
  longDescription: [
229
- "A ShellOutlet creates a named placement target inside a Vue file. That target is what other parts of JSKIT render into later.",
230
- "After an outlet exists, `jskit list-placements` will discover it and show its `target`. That makes the outlet visible to humans and to generators that need a placement destination.",
231
- "Commands that create placed UI, such as `ui-generator placed-element`, and commands that add page links can then target that outlet by writing placement entries that point at the same target."
275
+ "A ShellOutlet creates a concrete placement recipient inside a Vue file.",
276
+ "If --placement is provided, the command also appends semantic topology for that outlet, so `jskit list-placements` exposes the public `area.slot` placement.",
277
+ "If --placement is omitted, the command only injects the concrete outlet. Use this before `ui-generator topology` when compact, medium, and expanded should map to different outlets."
232
278
  ],
233
279
  positionalArgs: [
234
280
  {
@@ -237,10 +283,12 @@ export default Object.freeze({
237
283
  descriptionKey: "existing-vue-sfc-target-file"
238
284
  }
239
285
  ],
240
- optionNames: ["target"],
286
+ optionNames: ["target", "placement", "owner", "surface", "description", "kind", "link-renderer", "compact-target", "medium-target", "expanded-target"],
241
287
  requiredOptionNames: ["target"],
242
288
  notes: [
243
- "Use --target host:position."
289
+ "Use --target host:position for the concrete outlet and --placement area.slot for the public semantic placement.",
290
+ "Omit --placement when you only want to add the concrete destination.",
291
+ "When --placement is present, the generated topology maps compact, medium, and expanded to the new concrete outlet unless layout-specific targets are provided."
244
292
  ],
245
293
  examples: [
246
294
  {
@@ -248,7 +296,8 @@ export default Object.freeze({
248
296
  lines: [
249
297
  "npx jskit generate ui-generator outlet \\",
250
298
  " src/components/ContactSummaryCard.vue \\",
251
- " --target contact-view:sub-pages"
299
+ " --target contact-view:sub-pages \\",
300
+ " --placement page.section-nav"
252
301
  ]
253
302
  },
254
303
  {
@@ -256,7 +305,51 @@ export default Object.freeze({
256
305
  lines: [
257
306
  "npx jskit generate ui-generator outlet \\",
258
307
  " src/pages/admin/customers/[customerId]/index.vue \\",
259
- " --target customer-view:summary-actions"
308
+ " --target customer-view:summary-actions \\",
309
+ " --placement page.actions \\",
310
+ " --surface admin"
311
+ ]
312
+ }
313
+ ]
314
+ },
315
+ topology: {
316
+ requiresShellWeb: true,
317
+ entrypoint: "src/server/subcommands/outlet.js",
318
+ export: "runGeneratorSubcommand",
319
+ description: "Append semantic placement topology that maps compact, medium, and expanded layouts to concrete outlets.",
320
+ longDescription: [
321
+ "Use this after adding one or more concrete ShellOutlet destinations.",
322
+ "The command writes only `src/placementTopology.js`; it does not inject Vue outlets.",
323
+ "Use --kind component for componentToken-backed elements. Use --kind link when topology should provide the standard link renderer."
324
+ ],
325
+ optionNames: ["placement", "kind", "target", "compact-target", "medium-target", "expanded-target", "owner", "surface", "description", "link-renderer"],
326
+ requiredOptionNames: ["placement", "kind"],
327
+ notes: [
328
+ "Use --target host:position when all layout classes should render in the same outlet.",
329
+ "Use --compact-target, --medium-target, and --expanded-target when layout classes should render in different outlets.",
330
+ "For page.* and settings.* placements, pass --owner if the layout targets use different outlet hosts."
331
+ ],
332
+ examples: [
333
+ {
334
+ label: "Component placement with adaptive outlets",
335
+ lines: [
336
+ "npx jskit generate ui-generator topology \\",
337
+ " --placement reports.actions \\",
338
+ " --kind component \\",
339
+ " --compact-target reports:bottom-actions \\",
340
+ " --medium-target reports:toolbar-actions \\",
341
+ " --expanded-target reports:side-actions \\",
342
+ " --surface admin"
343
+ ]
344
+ },
345
+ {
346
+ label: "Link placement using one outlet for every layout",
347
+ lines: [
348
+ "npx jskit generate ui-generator topology \\",
349
+ " --placement page.section-nav \\",
350
+ " --kind link \\",
351
+ " --target report-view:sub-pages \\",
352
+ " --surface admin"
260
353
  ]
261
354
  }
262
355
  ]
@@ -278,7 +371,7 @@ export default Object.freeze({
278
371
  mutations: {
279
372
  dependencies: {
280
373
  runtime: {
281
- "@jskit-ai/users-web": "0.1.80"
374
+ "@jskit-ai/users-web": "0.1.82"
282
375
  },
283
376
  dev: {}
284
377
  },
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@jskit-ai/ui-generator",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
7
7
  },
8
8
  "dependencies": {
9
- "@jskit-ai/kernel": "0.1.65",
10
- "@jskit-ai/shell-web": "0.1.64"
9
+ "@jskit-ai/kernel": "0.1.67",
10
+ "@jskit-ai/shell-web": "0.1.66"
11
11
  },
12
12
  "exports": {
13
13
  "./server/buildTemplateContext": "./src/server/buildTemplateContext.js"
@@ -2,6 +2,11 @@ import {
2
2
  resolvePageLinkTargetDetails,
3
3
  resolvePageTargetDetails
4
4
  } from "@jskit-ai/kernel/server/support";
5
+ import {
6
+ normalizeGeneratedUiNavigationRole,
7
+ resolveGeneratedUiNavigationRoleLinkPlacement,
8
+ shouldCreateGeneratedUiNavigationLink
9
+ } from "@jskit-ai/kernel/shared/support/generatedUiContract";
5
10
 
6
11
  const DEFAULT_GENERATED_LINK_ICON = "mdi-view-list-outline";
7
12
 
@@ -12,6 +17,34 @@ function resolveLinkToPropLine(linkTo = "") {
12
17
  return ` to: ${JSON.stringify(linkTo)},\n`;
13
18
  }
14
19
 
20
+ function resolveOwnerLine(owner = "") {
21
+ if (!owner) {
22
+ return "";
23
+ }
24
+ return ` owner: ${JSON.stringify(owner)},\n`;
25
+ }
26
+
27
+ function resolveNavigationInferenceRoutePath(pageTarget = {}) {
28
+ const routeSegments = Array.isArray(pageTarget?.visibleRouteSegments)
29
+ ? pageTarget.visibleRouteSegments.map((entry) => String(entry || "").trim()).filter(Boolean)
30
+ : [];
31
+ if (routeSegments.length > 0) {
32
+ return `/${routeSegments.join("/")}`;
33
+ }
34
+ return String(pageTarget?.routeUrlSuffix || "");
35
+ }
36
+
37
+ function shouldCreateNavigationLink(options = {}, inferenceContext = {}) {
38
+ return shouldCreateGeneratedUiNavigationLink(options, {
39
+ allowLinkTo: true,
40
+ routePath: inferenceContext?.routePath
41
+ });
42
+ }
43
+
44
+ function resolveNavigationRoleLinkPlacement(options = {}, inferenceContext = {}) {
45
+ return resolveGeneratedUiNavigationRoleLinkPlacement(options, inferenceContext);
46
+ }
47
+
15
48
  async function buildUiPageTemplateContext({
16
49
  appRoot,
17
50
  targetFile = "",
@@ -27,14 +60,16 @@ async function buildUiPageTemplateContext({
27
60
  pageTarget,
28
61
  targetFile,
29
62
  context: "ui-generator page",
30
- placement: options?.["link-placement"],
31
- componentToken: options?.["link-component-token"],
63
+ placement: resolveNavigationRoleLinkPlacement(options, {
64
+ routePath: resolveNavigationInferenceRoutePath(pageTarget)
65
+ }),
32
66
  linkTo: options?.["link-to"]
33
67
  });
34
68
 
35
69
  return {
36
70
  __JSKIT_UI_LINK_PLACEMENT_ID__: pageTarget.placementId,
37
71
  __JSKIT_UI_LINK_PLACEMENT_TARGET__: String(linkTarget.placementTarget?.id || ""),
72
+ __JSKIT_UI_LINK_OWNER_LINE__: resolveOwnerLine(linkTarget.placementTarget?.owner || ""),
38
73
  __JSKIT_UI_LINK_COMPONENT_TOKEN__: String(linkTarget.componentToken || ""),
39
74
  __JSKIT_UI_LINK_ICON__: DEFAULT_GENERATED_LINK_ICON,
40
75
  __JSKIT_UI_LINK_WORKSPACE_SUFFIX__: pageTarget.routeUrlSuffix,
@@ -44,4 +79,10 @@ async function buildUiPageTemplateContext({
44
79
  };
45
80
  }
46
81
 
47
- export { buildUiPageTemplateContext };
82
+ export {
83
+ buildUiPageTemplateContext,
84
+ normalizeGeneratedUiNavigationRole as normalizeNavigationRole,
85
+ resolveNavigationInferenceRoutePath,
86
+ resolveNavigationRoleLinkPlacement,
87
+ shouldCreateNavigationLink
88
+ };
@@ -1,17 +1,68 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
1
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
3
  import {
3
4
  DEFAULT_COMPONENT_DIRECTORY,
4
5
  DEFAULT_SUBPAGES_POSITION,
6
+ SUBPAGES_LINK_COMPONENT_TOKEN,
5
7
  deriveDefaultSubpagesHost,
6
8
  resolvePageTargetDetails,
7
9
  upgradePageFileToSubpages
8
10
  } from "./pageSupport.js";
9
11
  import {
12
+ PLACEMENT_TOPOLOGY_FILE,
13
+ appendTopologyBlockIfPlacementMissing,
10
14
  requireSinglePositionalTargetFile,
15
+ resolvePathWithinApp,
11
16
  resolveOutletTargetId,
12
17
  rejectUnexpectedOptions
13
18
  } from "./support.js";
14
19
 
20
+ function resolveOutletOwner(target = "") {
21
+ const normalizedTarget = normalizeText(target);
22
+ const separatorIndex = normalizedTarget.indexOf(":");
23
+ if (separatorIndex <= 0) {
24
+ return "";
25
+ }
26
+ return normalizedTarget.slice(0, separatorIndex);
27
+ }
28
+
29
+ function renderSectionNavTopologyBlock({
30
+ marker = "",
31
+ owner = "",
32
+ surface = "",
33
+ target = ""
34
+ } = {}) {
35
+ return (
36
+ `// ${marker}\n` +
37
+ "addPlacementTopology({\n" +
38
+ ` id: "page.section-nav",\n` +
39
+ ` owner: "${owner}",\n` +
40
+ ` description: "Navigation between child pages in this section.",\n` +
41
+ ` surfaces: ["${surface}"],\n` +
42
+ " variants: {\n" +
43
+ " compact: {\n" +
44
+ ` outlet: "${target}",\n` +
45
+ " renderers: {\n" +
46
+ ` link: "${SUBPAGES_LINK_COMPONENT_TOKEN}"\n` +
47
+ " }\n" +
48
+ " },\n" +
49
+ " medium: {\n" +
50
+ ` outlet: "${target}",\n` +
51
+ " renderers: {\n" +
52
+ ` link: "${SUBPAGES_LINK_COMPONENT_TOKEN}"\n` +
53
+ " }\n" +
54
+ " },\n" +
55
+ " expanded: {\n" +
56
+ ` outlet: "${target}",\n` +
57
+ " renderers: {\n" +
58
+ ` link: "${SUBPAGES_LINK_COMPONENT_TOKEN}"\n` +
59
+ " }\n" +
60
+ " }\n" +
61
+ " }\n" +
62
+ "});\n"
63
+ );
64
+ }
65
+
15
66
  function resolveSubpagesOutletTarget(options = {}, pageTarget = {}) {
16
67
  const rawTarget = normalizeText(options?.target);
17
68
  const defaultTarget = `${deriveDefaultSubpagesHost(pageTarget)}:${DEFAULT_SUBPAGES_POSITION}`;
@@ -49,6 +100,33 @@ async function runGeneratorSubcommand({
49
100
  });
50
101
  const outletTarget = resolveSubpagesOutletTarget(options, pageTarget);
51
102
 
103
+ const topologyPath = resolvePathWithinApp(appRoot, PLACEMENT_TOPOLOGY_FILE, {
104
+ context: "ui-generator add-subpages"
105
+ });
106
+ const owner = resolveOutletOwner(outletTarget.id);
107
+ const topologyMarker = `jskit:ui-generator.topology:page.section-nav:${owner}`;
108
+ const topologySource = await readFile(topologyPath.absolutePath, "utf8");
109
+ const expectedVariantTargets = {
110
+ compact: outletTarget.id,
111
+ medium: outletTarget.id,
112
+ expanded: outletTarget.id
113
+ };
114
+ const topologyApplied = await appendTopologyBlockIfPlacementMissing({
115
+ topologyPath,
116
+ source: topologySource,
117
+ marker: topologyMarker,
118
+ block: renderSectionNavTopologyBlock({
119
+ marker: topologyMarker,
120
+ owner,
121
+ surface: pageTarget.surfaceId,
122
+ target: outletTarget.id
123
+ }),
124
+ placementId: "page.section-nav",
125
+ owner,
126
+ variantTargets: expectedVariantTargets,
127
+ context: "ui-generator add-subpages"
128
+ });
129
+
52
130
  const result = await upgradePageFileToSubpages({
53
131
  appRoot,
54
132
  targetFile,
@@ -60,10 +138,19 @@ async function runGeneratorSubcommand({
60
138
  dryRun
61
139
  });
62
140
 
141
+ const touchedFiles = new Set(result.touchedFiles);
142
+ if (topologyApplied.changed) {
143
+ if (dryRun !== true) {
144
+ await writeFile(topologyPath.absolutePath, topologyApplied.content, "utf8");
145
+ }
146
+ touchedFiles.add(topologyPath.relativePath);
147
+ }
148
+ const touchedFileList = [...touchedFiles].sort((left, right) => left.localeCompare(right));
149
+
63
150
  return {
64
- touchedFiles: result.touchedFiles,
151
+ touchedFiles: touchedFileList,
65
152
  summary:
66
- result.touchedFiles.length > 0
153
+ touchedFileList.length > 0
67
154
  ? `Enabled subpages in ${result.targetFile} for "${pageTarget.routeUrlSuffix}" using outlet target "${outletTarget.id}".`
68
155
  : `Subpages are already enabled in ${result.targetFile}.`
69
156
  };
@@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import {
4
4
  listSurfacePageRoots,
5
5
  resolveBestSurfaceMatchFromPageFile,
6
- resolveShellOutletPlacementTargetFromApp,
6
+ resolveSemanticPlacementTargetFromApp,
7
7
  toPosixPath
8
8
  } from "@jskit-ai/kernel/server/support";
9
9
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface";
@@ -22,15 +22,29 @@ import {
22
22
  insertBeforeClassDeclaration
23
23
  } from "./support.js";
24
24
 
25
- const DEFAULT_ELEMENT_PLACEMENT = "shell-layout:top-right";
25
+ const DEFAULT_ELEMENT_PLACEMENT = "shell.status";
26
26
 
27
27
  function renderElementComponentSource(elementName = "") {
28
28
  return `<template>
29
- <section class="pa-4">
30
- <h2 class="text-h6 mb-2">${elementName}</h2>
31
- <p class="text-body-2 text-medium-emphasis">Replace this scaffold with your UI element implementation.</p>
29
+ <section class="generated-element-panel">
30
+ <div>
31
+ <p class="text-caption text-medium-emphasis mb-1">Status</p>
32
+ <h2 class="text-subtitle-1 font-weight-medium mb-0">${elementName}</h2>
33
+ </div>
34
+ <v-chip color="primary" variant="tonal" size="small">Ready</v-chip>
32
35
  </section>
33
36
  </template>
37
+
38
+ <style scoped>
39
+ .generated-element-panel {
40
+ align-items: center;
41
+ display: flex;
42
+ gap: 0.75rem;
43
+ justify-content: space-between;
44
+ min-height: 48px;
45
+ min-width: 0;
46
+ }
47
+ </style>
34
48
  `;
35
49
  }
36
50
 
@@ -41,14 +55,28 @@ async function resolvePlacedElementSurface({
41
55
  context = "ui-generator placed-element"
42
56
  } = {}) {
43
57
  const explicitSurface = normalizeSurfaceId(surface);
58
+ const placementSurfaces = Array.isArray(placementTarget?.surfaces)
59
+ ? placementTarget.surfaces.map((entry) => normalizeSurfaceId(entry)).filter(Boolean)
60
+ : [];
61
+ const isPlacementGlobal = placementSurfaces.length < 1 || placementSurfaces.includes("*");
62
+ const concreteSourcePath = normalizeText(placementTarget?.sourcePath).startsWith("src/")
63
+ ? normalizeText(placementTarget?.sourcePath)
64
+ : "";
44
65
  const inferredSurfaceMatch = await resolveBestSurfaceMatchFromPageFile(
45
- String(placementTarget?.sourcePath || ""),
66
+ concreteSourcePath,
46
67
  await listSurfacePageRoots(appRoot, { context }),
47
68
  { context }
48
69
  );
49
- const inferredSurface = normalizeSurfaceId(inferredSurfaceMatch?.surfaceId);
70
+ const inferredSurface =
71
+ normalizeSurfaceId(inferredSurfaceMatch?.surfaceId) ||
72
+ (placementSurfaces.length === 1 && !isPlacementGlobal ? placementSurfaces[0] : "");
50
73
 
51
74
  if (explicitSurface) {
75
+ if (!isPlacementGlobal && placementSurfaces.length > 0 && !placementSurfaces.includes(explicitSurface)) {
76
+ throw new Error(
77
+ `${context} target "${normalizeText(placementTarget?.id) || "<unknown>"}" is not available on surface "${explicitSurface}".`
78
+ );
79
+ }
52
80
  if (inferredSurface && explicitSurface !== inferredSurface) {
53
81
  throw new Error(
54
82
  `${context} target "${normalizeText(placementTarget?.id) || "<unknown>"}" belongs to surface "${inferredSurface}", ` +
@@ -89,7 +117,7 @@ async function runGeneratorSubcommand({
89
117
  if (Array.isArray(args) && args.length > 0) {
90
118
  throw new Error("ui-generator placed-element does not accept positional arguments.");
91
119
  }
92
- rejectUnexpectedOptions(options, ["name", "surface", "path", "placement", "force"], {
120
+ rejectUnexpectedOptions(options, ["name", "surface", "path", "placement", "owner", "force"], {
93
121
  context: "ui-generator placed-element"
94
122
  });
95
123
 
@@ -115,10 +143,11 @@ async function runGeneratorSubcommand({
115
143
  context: "ui-generator placed-element"
116
144
  });
117
145
  const componentToken = `local.main.ui.element.${elementNameKebab}`;
118
- const placementTarget = await resolveShellOutletPlacementTargetFromApp({
146
+ const placementTarget = await resolveSemanticPlacementTargetFromApp({
119
147
  appRoot,
120
148
  context: "ui-generator",
121
- placement: options?.placement || DEFAULT_ELEMENT_PLACEMENT
149
+ placement: options?.placement || DEFAULT_ELEMENT_PLACEMENT,
150
+ owner: options?.owner
122
151
  });
123
152
  const surface = await resolvePlacedElementSurface({
124
153
  appRoot,
@@ -179,12 +208,15 @@ async function runGeneratorSubcommand({
179
208
 
180
209
  const placementSource = await readFile(placementPath.absolutePath, "utf8");
181
210
  const placementMarker = `jskit:ui-generator.element:${surface}:${elementNameKebab}`;
211
+ const ownerLine = placementTarget.owner ? ` owner: "${placementTarget.owner}",\n` : "";
182
212
  const placementBlock =
183
213
  `// ${placementMarker}\n` +
184
214
  "{\n" +
185
215
  " addPlacement({\n" +
186
216
  ` id: "ui-generator.element.${elementNameKebab}",\n` +
187
217
  ` target: "${placementTarget.id}",\n` +
218
+ ownerLine +
219
+ ` kind: "component",\n` +
188
220
  ` surfaces: ["${surface}"],\n` +
189
221
  " order: 155,\n" +
190
222
  ` componentToken: "${componentToken}"\n` +