@jskit-ai/ui-generator 0.1.49 → 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.
@@ -13,21 +13,27 @@ import {
13
13
  readLocalLinkItemComponentSource
14
14
  } from "@jskit-ai/shell-web/server/support/localLinkItemScaffolds";
15
15
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
16
+ import {
17
+ buildGeneratedUiScreenClassName,
18
+ resolveGeneratedUiSurfaceProfile
19
+ } from "@jskit-ai/kernel/shared/support/generatedUiContract";
16
20
  import {
17
21
  DEFAULT_COMPONENT_DIRECTORY,
18
22
  MAIN_CLIENT_PROVIDER_FILE,
19
23
  resolvePathWithinApp,
20
24
  insertImportIfMissing,
21
25
  insertBeforeClassDeclaration,
22
- findScriptBlock,
26
+ findScriptSetupBlock,
27
+ insertScriptSetupBlock,
23
28
  indentBlock
24
29
  } from "./support.js";
25
30
 
26
31
  const DEFAULT_SUBPAGES_POSITION = "sub-pages";
27
32
  const SECTION_CONTAINER_SHELL_COMPONENT = "SectionContainerShell";
28
- const SUBPAGES_LINK_COMPONENT_TOKEN = "local.main.ui.surface-aware-menu-link-item";
33
+ const SUBPAGES_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
29
34
  const DEFAULT_MENU_COMPONENT_DIRECTORY = path.join(DEFAULT_COMPONENT_DIRECTORY, "menus");
30
35
  const SUBPAGES_LINK_COMPONENT_DEFINITION = findLocalLinkItemDefinition(SUBPAGES_LINK_COMPONENT_TOKEN);
36
+ const OPERATOR_SURFACE_IDS = new Set(["admin", "console"]);
31
37
 
32
38
  if (!SUBPAGES_LINK_COMPONENT_DEFINITION) {
33
39
  throw new Error(`ui-generator add-subpages could not resolve ${SUBPAGES_LINK_COMPONENT_TOKEN} scaffold definition.`);
@@ -35,25 +41,97 @@ if (!SUBPAGES_LINK_COMPONENT_DEFINITION) {
35
41
 
36
42
  const SUBPAGES_LINK_COMPONENT = SUBPAGES_LINK_COMPONENT_DEFINITION.componentName;
37
43
 
38
- const ROUTE_TAG_PATTERN = /<route\b[^>]*>[\s\S]*?<\/route>\s*/gi;
39
44
  const TEMPLATE_TOKEN_PATTERN = /<\/?template\b[^>]*>/gi;
40
45
  const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b[^>]*\/?>\s*/gi;
41
46
  const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
42
47
  const ROUTER_VIEW_LINE_PATTERN = /^\s*<RouterView(?:\s[^>]*)?\s*\/>\s*$/gm;
43
48
 
49
+ function resolveGeneratedPageSurfaceProfile({
50
+ surfaceId = "",
51
+ routePath = ""
52
+ } = {}) {
53
+ const routeSegments = normalizeText(routePath)
54
+ .replaceAll("\\", "/")
55
+ .split("/")
56
+ .map((entry) => normalizeText(entry).toLowerCase())
57
+ .filter(Boolean);
58
+ if (routeSegments.includes("settings")) {
59
+ return "settings";
60
+ }
61
+ if (OPERATOR_SURFACE_IDS.has(normalizeText(surfaceId).toLowerCase())) {
62
+ return "operator";
63
+ }
64
+ return "task";
65
+ }
66
+
44
67
  function trimEdgeBlankLines(source = "") {
45
68
  return String(source || "")
46
69
  .replace(/^\s*\n/, "")
47
70
  .replace(/\n\s*$/, "");
48
71
  }
49
72
 
50
- function renderPlainPageSource(pageTitle = "") {
73
+ function renderPlainPageSource(pageTitle = "", {
74
+ surfaceId = "",
75
+ routePath = ""
76
+ } = {}) {
77
+ const surfaceProfileId = resolveGeneratedPageSurfaceProfile({ surfaceId, routePath });
78
+ const surfaceProfile = resolveGeneratedUiSurfaceProfile(surfaceProfileId);
79
+ const screenClass = buildGeneratedUiScreenClassName("generated-page-screen d-flex flex-column ga-4", {
80
+ surfaceProfile: surfaceProfileId
81
+ });
51
82
  return `<template>
52
- <section class="pa-4">
53
- <h1 class="text-h5 mb-2">${pageTitle}</h1>
54
- <p class="text-body-2 text-medium-emphasis">Replace this scaffold with your page implementation.</p>
83
+ <section class="${screenClass}">
84
+ <header>
85
+ <p class="text-overline text-medium-emphasis mb-1">${surfaceProfile.titleLabel}</p>
86
+ <h1 class="generated-page-screen__title">${pageTitle}</h1>
87
+ </header>
88
+
89
+ <v-sheet rounded="lg" border class="generated-page-screen__empty-state">
90
+ <h2 class="text-h6 mb-2">No ${pageTitle} activity yet</h2>
91
+ <p class="text-body-2 text-medium-emphasis mb-0">
92
+ ${surfaceProfile.emptyStateBody}
93
+ </p>
94
+ </v-sheet>
55
95
  </section>
56
96
  </template>
97
+
98
+ <style scoped>
99
+ .generated-ui-screen {
100
+ --generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
101
+ --generated-ui-screen-panel-padding: 2rem 1.25rem;
102
+ --generated-ui-screen-panel-align: center;
103
+ }
104
+
105
+ .generated-ui-screen--operator {
106
+ --generated-ui-screen-panel-padding: 1.5rem 1rem;
107
+ }
108
+
109
+ .generated-ui-screen--settings {
110
+ --generated-ui-screen-panel-padding: 1.5rem 1rem;
111
+ }
112
+
113
+ .generated-page-screen__title {
114
+ font-size: var(--generated-ui-screen-title-size);
115
+ font-weight: 650;
116
+ letter-spacing: -0.02em;
117
+ line-height: 1.15;
118
+ margin: 0;
119
+ }
120
+
121
+ .generated-page-screen__empty-state {
122
+ margin-inline: auto;
123
+ max-width: 34rem;
124
+ padding: var(--generated-ui-screen-panel-padding);
125
+ text-align: var(--generated-ui-screen-panel-align);
126
+ width: 100%;
127
+ }
128
+
129
+ @media (max-width: 640px) {
130
+ .generated-ui-screen {
131
+ --generated-ui-screen-panel-padding: 1.25rem 1rem;
132
+ }
133
+ }
134
+ </style>
57
135
  `;
58
136
  }
59
137
 
@@ -81,41 +159,50 @@ const hasTabs = computed(() => Boolean(slots.tabs));
81
159
 
82
160
  <template>
83
161
  <section class="section-container-shell d-flex flex-column ga-4">
84
- <v-card rounded="lg" elevation="1" border>
85
- <v-card-item v-if="hasHeading">
86
- <v-card-title v-if="resolvedTitle" class="px-0">{{ resolvedTitle }}</v-card-title>
87
- <v-card-subtitle v-if="resolvedSubtitle" class="px-0">{{ resolvedSubtitle }}</v-card-subtitle>
88
- </v-card-item>
89
- <template v-if="hasTabs">
90
- <v-divider v-if="hasHeading" />
91
- <v-card-text class="section-container-shell__tabs">
92
- <slot name="tabs" />
93
- </v-card-text>
94
- </template>
95
- </v-card>
162
+ <header v-if="hasHeading" class="section-container-shell__heading">
163
+ <h1 v-if="resolvedTitle" class="section-container-shell__title">{{ resolvedTitle }}</h1>
164
+ <p v-if="resolvedSubtitle" class="text-body-2 text-medium-emphasis mb-0">{{ resolvedSubtitle }}</p>
165
+ </header>
166
+
167
+ <v-sheet v-if="hasTabs" rounded="lg" border class="section-container-shell__nav">
168
+ <slot name="tabs" />
169
+ </v-sheet>
96
170
 
97
171
  <slot />
98
172
  </section>
99
173
  </template>
100
174
 
101
175
  <style scoped>
102
- .section-container-shell__tabs {
103
- display: flex;
176
+ .section-container-shell__heading {
177
+ min-width: 0;
178
+ }
179
+
180
+ .section-container-shell__title {
181
+ font-size: clamp(1.35rem, 2vw, 1.85rem);
182
+ font-weight: 650;
183
+ letter-spacing: -0.02em;
184
+ line-height: 1.15;
185
+ margin: 0 0 0.35rem;
186
+ }
187
+
188
+ .section-container-shell__nav {
104
189
  align-items: center;
190
+ display: flex;
105
191
  gap: 0.5rem;
106
192
  overflow-x: auto;
107
- padding: 0.75rem;
193
+ padding: 0.5rem;
108
194
  scrollbar-width: thin;
109
195
  }
110
196
 
111
- .section-container-shell__tabs :deep(.tab-link-item) {
197
+ .section-container-shell__nav :deep(.tab-link-item) {
112
198
  flex: 0 0 auto;
199
+ min-height: 48px;
113
200
  }
114
201
 
115
202
  @media (max-width: 640px) {
116
- .section-container-shell__tabs {
203
+ .section-container-shell__nav {
117
204
  gap: 0.375rem;
118
- padding: 0.5rem;
205
+ margin-inline: -0.25rem;
119
206
  }
120
207
  }
121
208
  </style>
@@ -332,7 +419,7 @@ function renderSubpagesTemplate({
332
419
 
333
420
  function applySubpagesScriptImports(source = "", { sectionContainerComponentImportPath = "" } = {}) {
334
421
  const sourceText = String(source || "");
335
- const scriptBlock = findScriptBlock(sourceText);
422
+ const scriptBlock = findScriptSetupBlock(sourceText);
336
423
 
337
424
  const importLines = [
338
425
  "import ShellOutlet from \"@jskit-ai/shell-web/client/components/ShellOutlet\";",
@@ -341,16 +428,7 @@ function applySubpagesScriptImports(source = "", { sectionContainerComponentImpo
341
428
  ];
342
429
 
343
430
  if (!scriptBlock) {
344
- const scriptSetupBlock = `<script setup>\n${importLines.join("\n")}\n</script>\n`;
345
- let insertionIndex = 0;
346
- for (const match of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
347
- insertionIndex = match.index + String(match[0] || "").length;
348
- }
349
- const separator = insertionIndex > 0 ? "\n" : "";
350
- return {
351
- changed: true,
352
- content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
353
- };
431
+ return insertScriptSetupBlock(sourceText, importLines.join("\n"));
354
432
  }
355
433
 
356
434
  let nextScriptContent = scriptBlock.content;
@@ -1,9 +1,16 @@
1
1
  import path from "node:path";
2
2
  import {
3
+ importFreshModuleFromAbsolutePath,
3
4
  resolveRequiredAppRoot,
4
5
  toPosixPath
5
6
  } from "@jskit-ai/kernel/server/support";
6
- import { normalizeShellOutletTargetId } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
7
+ import {
8
+ PLACEMENT_LAYOUT_CLASSES,
9
+ normalizePlacementOwnerId,
10
+ normalizePlacementTopologyDefinition,
11
+ normalizeSemanticPlacementId,
12
+ normalizeShellOutletTargetId
13
+ } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
7
14
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
8
15
  import { toCamelCase, toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
9
16
 
@@ -208,38 +215,132 @@ function insertBeforeClassDeclaration(source = "", line = "", { className = "",
208
215
  };
209
216
  }
210
217
 
211
- function findScriptBlock(source = "") {
218
+ function findScriptSetupBlock(source = "") {
212
219
  const sourceText = String(source || "");
213
- let firstMatch = null;
214
-
215
220
  for (const match of sourceText.matchAll(SCRIPT_TAG_PATTERN)) {
216
- if (!firstMatch) {
217
- firstMatch = match;
218
- }
219
-
220
221
  const attributesSource = String(match[1] || "");
221
- if (SCRIPT_SETUP_ATTRIBUTE_PATTERN.test(attributesSource)) {
222
- return Object.freeze({
223
- index: match.index,
224
- source: String(match[0] || ""),
225
- attributesSource,
226
- content: String(match[2] || "")
227
- });
222
+ if (!SCRIPT_SETUP_ATTRIBUTE_PATTERN.test(attributesSource)) {
223
+ continue;
228
224
  }
225
+
226
+ return Object.freeze({
227
+ index: match.index,
228
+ source: String(match[0] || ""),
229
+ attributesSource,
230
+ content: String(match[2] || "")
231
+ });
229
232
  }
230
233
 
231
- if (!firstMatch) {
232
- return null;
234
+ return null;
235
+ }
236
+
237
+ function insertScriptSetupBlock(source = "", content = "") {
238
+ const sourceText = String(source || "");
239
+ const normalizedContent = String(content || "").trim();
240
+ if (!normalizedContent) {
241
+ return {
242
+ changed: false,
243
+ content: sourceText
244
+ };
233
245
  }
234
246
 
235
- return Object.freeze({
236
- index: firstMatch.index,
237
- source: String(firstMatch[0] || ""),
238
- attributesSource: String(firstMatch[1] || ""),
239
- content: String(firstMatch[2] || "")
247
+ const scriptSetupBlock = `<script setup>\n${normalizedContent}\n</script>\n`;
248
+ let insertionIndex = 0;
249
+ for (const match of sourceText.matchAll(/<route\b[^>]*>[\s\S]*?<\/route>\s*/gi)) {
250
+ insertionIndex = match.index + String(match[0] || "").length;
251
+ }
252
+ const separator = insertionIndex > 0 ? "\n" : "";
253
+ return {
254
+ changed: true,
255
+ content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
256
+ };
257
+ }
258
+
259
+ async function loadPlacementTopologyDefinitionFromPath(topologyPath = {}, { context = "ui-generator topology" } = {}) {
260
+ const moduleNamespace = await importFreshModuleFromAbsolutePath(topologyPath.absolutePath);
261
+ const exported = moduleNamespace?.default;
262
+ const resolved = typeof exported === "function" ? exported() : exported;
263
+ return normalizePlacementTopologyDefinition(resolved, {
264
+ context
240
265
  });
241
266
  }
242
267
 
268
+ function normalizeExpectedTopologyVariantTargets(variantTargets = null) {
269
+ if (!variantTargets || typeof variantTargets !== "object" || Array.isArray(variantTargets)) {
270
+ return null;
271
+ }
272
+
273
+ const targets = {};
274
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
275
+ const target = normalizeShellOutletTargetId(variantTargets[layoutClass]);
276
+ if (target) {
277
+ targets[layoutClass] = target;
278
+ }
279
+ }
280
+
281
+ return Object.keys(targets).length > 0 ? Object.freeze(targets) : null;
282
+ }
283
+
284
+ function describeTopologyVariantTargets(variantTargets = {}) {
285
+ return PLACEMENT_LAYOUT_CLASSES
286
+ .map((layoutClass) => {
287
+ const target = normalizeShellOutletTargetId(variantTargets?.[layoutClass]);
288
+ return target ? `${layoutClass}:${target}` : "";
289
+ })
290
+ .filter(Boolean)
291
+ .join(", ");
292
+ }
293
+
294
+ function placementMatchesExpectedVariantTargets(placement = {}, expectedVariantTargets = null) {
295
+ if (!expectedVariantTargets) {
296
+ return true;
297
+ }
298
+
299
+ const variants = placement?.variants && typeof placement.variants === "object" ? placement.variants : {};
300
+ return Object.entries(expectedVariantTargets).every(([layoutClass, expectedTarget]) =>
301
+ normalizeShellOutletTargetId(variants?.[layoutClass]?.outlet) === expectedTarget
302
+ );
303
+ }
304
+
305
+ async function appendTopologyBlockIfPlacementMissing({
306
+ topologyPath = {},
307
+ source = "",
308
+ marker = "",
309
+ block = "",
310
+ placementId = "",
311
+ owner = "",
312
+ variantTargets = null,
313
+ context = "ui-generator topology"
314
+ } = {}) {
315
+ const normalizedPlacementId = normalizeSemanticPlacementId(placementId);
316
+ const normalizedOwner = normalizePlacementOwnerId(owner);
317
+ if (!normalizedPlacementId) {
318
+ throw new Error(`${context} requires semantic placement id in "area.slot" format.`);
319
+ }
320
+
321
+ const topology = await loadPlacementTopologyDefinitionFromPath(topologyPath, { context });
322
+ const existingPlacement = (Array.isArray(topology.placements) ? topology.placements : []).find(
323
+ (placement) => placement.id === normalizedPlacementId && (placement.owner || "") === normalizedOwner
324
+ );
325
+ if (existingPlacement) {
326
+ const expectedVariantTargets = normalizeExpectedTopologyVariantTargets(variantTargets);
327
+ if (!placementMatchesExpectedVariantTargets(existingPlacement, expectedVariantTargets)) {
328
+ const ownerLabel = normalizedOwner ? ` for owner "${normalizedOwner}"` : "";
329
+ throw new Error(
330
+ `${context} semantic placement "${normalizedPlacementId}"${ownerLabel} already exists with different outlet mapping. ` +
331
+ `Existing: ${describeTopologyVariantTargets(existingPlacement.variants)}. ` +
332
+ `Requested: ${describeTopologyVariantTargets(expectedVariantTargets)}.`
333
+ );
334
+ }
335
+ return {
336
+ changed: false,
337
+ content: String(source || "")
338
+ };
339
+ }
340
+
341
+ return appendBlockIfMarkerMissing(source, marker, block);
342
+ }
343
+
243
344
  function parseTagAttributes(attributesSource = "") {
244
345
  const attributes = {};
245
346
  const source = String(attributesSource || "");
@@ -279,7 +380,9 @@ export {
279
380
  appendBlockIfMarkerMissing,
280
381
  insertImportIfMissing,
281
382
  insertBeforeClassDeclaration,
282
- findScriptBlock,
383
+ findScriptSetupBlock,
384
+ insertScriptSetupBlock,
385
+ appendTopologyBlockIfPlacementMissing,
283
386
  parseTagAttributes,
284
387
  indentBlock
285
388
  };
@@ -113,7 +113,7 @@ test("ui-generator add-subpages derives the default target from an index-route p
113
113
 
114
114
  assert.deepEqual(result.touchedFiles, [
115
115
  "packages/main/src/client/providers/MainClientProvider.js",
116
- "src/components/menus/SurfaceAwareMenuLinkItem.vue",
116
+ "src/components/menus/TabLinkItem.vue",
117
117
  "src/components/SectionContainerShell.vue",
118
118
  `src/pages/${targetFile}`,
119
119
  "src/placementTopology.js"
@@ -130,10 +130,11 @@ test("ui-generator add-subpages derives the default target from an index-route p
130
130
  assert.match(topologySource, /compact: \{/);
131
131
  assert.match(topologySource, /medium: \{/);
132
132
  assert.match(topologySource, /expanded: \{/);
133
+ assert.match(topologySource, /link: "local\.main\.ui\.tab-link-item"/);
133
134
  assert.match(pageSource, /<RouterView \/>/);
134
135
  assert.equal(
135
- await readFile(path.join(appRoot, "src", "components", "menus", "SurfaceAwareMenuLinkItem.vue"), "utf8"),
136
- await readLocalLinkItemComponentSource("local.main.ui.surface-aware-menu-link-item")
136
+ await readFile(path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"), "utf8"),
137
+ await readLocalLinkItemComponentSource("local.main.ui.tab-link-item")
137
138
  );
138
139
  });
139
140
  });
@@ -227,6 +228,41 @@ test("ui-generator add-subpages supports explicit target host:position", async (
227
228
  });
228
229
  });
229
230
 
231
+ test("ui-generator add-subpages creates script setup instead of adding template imports to normal script", async () => {
232
+ await withTempApp(async (appRoot) => {
233
+ await writeAppFixture(appRoot);
234
+
235
+ const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
236
+ await writePageFile(
237
+ appRoot,
238
+ targetFile,
239
+ `<script>
240
+ export default {
241
+ name: "PracticePage"
242
+ };
243
+ </script>
244
+
245
+ <template>
246
+ <section>Practice</section>
247
+ </template>
248
+ `
249
+ );
250
+
251
+ await runGeneratorSubcommand({
252
+ appRoot,
253
+ subcommand: "add-subpages",
254
+ args: [targetFile],
255
+ options: {}
256
+ });
257
+
258
+ const pageSource = await readPageFile(appRoot, targetFile);
259
+ assert.match(pageSource, /<script setup>\nimport ShellOutlet from "@jskit-ai\/shell-web\/client\/components\/ShellOutlet";/);
260
+ assert.match(pageSource, /import \{ RouterView \} from "vue-router";/);
261
+ assert.match(pageSource, /import SectionContainerShell from "\/src\/components\/SectionContainerShell\.vue";/);
262
+ assert.match(pageSource, /<script>\nexport default/);
263
+ });
264
+ });
265
+
230
266
  test("ui-generator add-subpages does not rewrite existing scaffold support components", async () => {
231
267
  await withTempApp(async (appRoot) => {
232
268
  await writeAppFixture(appRoot);
@@ -234,7 +270,7 @@ test("ui-generator add-subpages does not rewrite existing scaffold support compo
234
270
  const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
235
271
  await writePageFile(appRoot, targetFile);
236
272
  const customSectionShellSource = `<template><section class="custom-shell"><slot /></section></template>\n`;
237
- const customSurfaceAwareLinkSource = `<template><button class="custom-surface-aware-link"><slot /></button></template>\n`;
273
+ const customTabLinkSource = `<template><button class="custom-tab-link"><slot /></button></template>\n`;
238
274
  await writeFile(
239
275
  path.join(appRoot, "src", "components", "SectionContainerShell.vue"),
240
276
  customSectionShellSource,
@@ -242,8 +278,8 @@ test("ui-generator add-subpages does not rewrite existing scaffold support compo
242
278
  );
243
279
  await mkdir(path.join(appRoot, "src", "components", "menus"), { recursive: true });
244
280
  await writeFile(
245
- path.join(appRoot, "src", "components", "menus", "SurfaceAwareMenuLinkItem.vue"),
246
- customSurfaceAwareLinkSource,
281
+ path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"),
282
+ customTabLinkSource,
247
283
  "utf8"
248
284
  );
249
285
 
@@ -266,9 +302,97 @@ test("ui-generator add-subpages does not rewrite existing scaffold support compo
266
302
  customSectionShellSource
267
303
  );
268
304
  assert.equal(
269
- await readFile(path.join(appRoot, "src", "components", "menus", "SurfaceAwareMenuLinkItem.vue"), "utf8"),
270
- customSurfaceAwareLinkSource
305
+ await readFile(path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"), "utf8"),
306
+ customTabLinkSource
307
+ );
308
+ });
309
+ });
310
+
311
+ test("ui-generator add-subpages validates topology before changing page or support files", async () => {
312
+ await withTempApp(async (appRoot) => {
313
+ await writeAppFixture(appRoot);
314
+
315
+ const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
316
+ const originalPageSource = "<template><section>Practice</section></template>\n";
317
+ await writePageFile(appRoot, targetFile, originalPageSource);
318
+ const providerPath = path.join(appRoot, "packages", "main", "src", "client", "providers", "MainClientProvider.js");
319
+ const originalProviderSource = await readFile(providerPath, "utf8");
320
+ await writeFile(
321
+ path.join(appRoot, "src", "placementTopology.js"),
322
+ `export default {
323
+ placements: [
324
+ {
325
+ id: "page.section-nav",
326
+ owner: "practice",
327
+ variants: {}
328
+ }
329
+ ]
330
+ };
331
+ `,
332
+ "utf8"
271
333
  );
334
+
335
+ await assert.rejects(
336
+ runGeneratorSubcommand({
337
+ appRoot,
338
+ subcommand: "add-subpages",
339
+ args: [targetFile],
340
+ options: {}
341
+ }),
342
+ /requires compact topology variant/
343
+ );
344
+
345
+ assert.equal(await readPageFile(appRoot, targetFile), originalPageSource);
346
+ assert.equal(await readFile(providerPath, "utf8"), originalProviderSource);
347
+ await assert.rejects(
348
+ readFile(path.join(appRoot, "src", "components", "SectionContainerShell.vue"), "utf8"),
349
+ /ENOENT/
350
+ );
351
+ await assert.rejects(
352
+ readFile(path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"), "utf8"),
353
+ /ENOENT/
354
+ );
355
+ });
356
+ });
357
+
358
+ test("ui-generator add-subpages rejects existing section-nav topology for a different outlet", async () => {
359
+ await withTempApp(async (appRoot) => {
360
+ await writeAppFixture(appRoot);
361
+
362
+ const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
363
+ const originalPageSource = "<template><section>Practice</section></template>\n";
364
+ await writePageFile(appRoot, targetFile, originalPageSource);
365
+ await writeFile(
366
+ path.join(appRoot, "src", "placementTopology.js"),
367
+ `export default {
368
+ placements: [
369
+ {
370
+ id: "page.section-nav",
371
+ owner: "practice",
372
+ surfaces: ["admin"],
373
+ variants: {
374
+ compact: { outlet: "practice:existing-tabs" },
375
+ medium: { outlet: "practice:existing-tabs" },
376
+ expanded: { outlet: "practice:existing-tabs" }
377
+ }
378
+ }
379
+ ]
380
+ };
381
+ `,
382
+ "utf8"
383
+ );
384
+
385
+ await assert.rejects(
386
+ runGeneratorSubcommand({
387
+ appRoot,
388
+ subcommand: "add-subpages",
389
+ args: [targetFile],
390
+ options: {}
391
+ }),
392
+ /semantic placement "page\.section-nav" for owner "practice" already exists with different outlet mapping/
393
+ );
394
+
395
+ assert.equal(await readPageFile(appRoot, targetFile), originalPageSource);
272
396
  });
273
397
  });
274
398
 
@@ -90,6 +90,12 @@ async function writePlacementTopology(appRoot, entries = []) {
90
90
  surfaces: ["*"],
91
91
  outlet: "shell-layout:top-right",
92
92
  linkRenderer: "local.main.ui.surface-aware-menu-link-item"
93
+ }),
94
+ renderTopologyEntry({
95
+ id: "shell.global-actions",
96
+ surfaces: ["*"],
97
+ outlet: "shell-layout:top-right",
98
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
93
99
  })
94
100
  ];
95
101
  await writeFileInApp(
@@ -186,6 +192,31 @@ test("buildUiPageTemplateContext supports explicit link placement override", asy
186
192
  });
187
193
  });
188
194
 
195
+ test("buildUiPageTemplateContext maps utility navigation role to global actions", async () => {
196
+ await withTempApp(async (appRoot) => {
197
+ await writeConfig(
198
+ appRoot,
199
+ `export const config = {
200
+ surfaceDefinitions: {
201
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
202
+ }
203
+ };
204
+ `
205
+ );
206
+ await writeShellLayout(appRoot);
207
+
208
+ const context = await buildUiPageTemplateContext({
209
+ appRoot,
210
+ targetFile: "admin/help/index.vue",
211
+ options: {
212
+ "navigation-role": "utility"
213
+ }
214
+ });
215
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_TARGET__, "shell.global-actions");
216
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "");
217
+ });
218
+ });
219
+
189
220
  test("buildUiPageTemplateContext supports explicit package semantic link placement", async () => {
190
221
  await withTempApp(async (appRoot) => {
191
222
  await writeConfig(
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import test from "node:test";
6
+ import { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
6
7
  import { runGeneratorSubcommand } from "../src/server/subcommands/element.js";
7
8
 
8
9
  async function withTempApp(run) {
@@ -175,6 +176,16 @@ test("ui-generator placed-element subcommand creates component and outlet placem
175
176
  assert.match(providerSource, /import OpsPanelElement from "\/src\/components\/OpsPanelElement\.vue";/);
176
177
  assert.match(providerSource, /registerMainClientComponent\("local\.main\.ui\.element\.ops-panel", \(\) => OpsPanelElement\);/);
177
178
 
179
+ const componentSource = await readFile(path.join(appRoot, "src", "components", "OpsPanelElement.vue"), "utf8");
180
+ assertGeneratedUiSourceContract(componentSource, {
181
+ profile: "placed-element",
182
+ sourceName: "OpsPanelElement.vue"
183
+ });
184
+ assert.match(componentSource, /class="generated-element-panel"/);
185
+ assert.match(componentSource, /<h2 class="text-subtitle-1 font-weight-medium mb-0">Ops Panel<\/h2>/);
186
+ assert.match(componentSource, /<v-chip color="primary" variant="tonal" size="small">Ready<\/v-chip>/);
187
+ assert.doesNotMatch(componentSource, /Replace this scaffold|Use this area|This is your page/);
188
+
178
189
  const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
179
190
  assert.match(placementSource, /id: "ui-generator\.element\.ops-panel"/);
180
191
  assert.match(placementSource, /target: "shell\.status"/);
@@ -350,7 +361,8 @@ test("ui-generator placed-element subcommand overwrites an existing component wh
350
361
  ]);
351
362
 
352
363
  const componentSource = await readFile(path.join(appRoot, "src", "components", "OpsPanelElement.vue"), "utf8");
353
- assert.match(componentSource, /<h2 class="text-h6 mb-2">Ops Panel<\/h2>/);
364
+ assert.match(componentSource, /<h2 class="text-subtitle-1 font-weight-medium mb-0">Ops Panel<\/h2>/);
365
+ assert.doesNotMatch(componentSource, /custom/);
354
366
  });
355
367
  });
356
368