@jskit-ai/ui-generator 0.1.13 → 0.1.15

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.
@@ -14,11 +14,20 @@ async function withTempApp(run) {
14
14
  }
15
15
  }
16
16
 
17
- async function writeVueFile(appRoot, relativePath, source = "") {
17
+ async function writeFileInApp(appRoot, relativePath, source) {
18
18
  const absoluteFile = path.join(appRoot, relativePath);
19
19
  await mkdir(path.dirname(absoluteFile), { recursive: true });
20
- await writeFile(
21
- absoluteFile,
20
+ await writeFile(absoluteFile, source, "utf8");
21
+ }
22
+
23
+ async function writeConfig(appRoot, source) {
24
+ await writeFileInApp(appRoot, "config/public.js", source);
25
+ }
26
+
27
+ async function writeShellLayout(appRoot, source = "") {
28
+ await writeFileInApp(
29
+ appRoot,
30
+ "src/components/ShellLayout.vue",
22
31
  source ||
23
32
  `<template>
24
33
  <div>
@@ -26,67 +35,76 @@ async function writeVueFile(appRoot, relativePath, source = "") {
26
35
  <ShellOutlet host="shell-layout" position="primary-menu" default />
27
36
  </div>
28
37
  </template>
29
- `,
30
- "utf8"
38
+ `
31
39
  );
32
40
  }
33
41
 
34
- test("buildUiPageTemplateContext resolves placement from default app ShellOutlet target", async () => {
42
+ test("buildUiPageTemplateContext resolves link placement from default app ShellOutlet target", async () => {
35
43
  await withTempApp(async (appRoot) => {
36
- await writeVueFile(
37
- appRoot,
38
- "src/components/ShellLayout.vue",
39
- `<template>
40
- <div>
41
- <ShellOutlet host="shell-layout" position="top-right" />
42
- <ShellOutlet host="shell-layout" position="primary-menu" />
43
- </div>
44
- </template>
45
- `
46
- );
47
- await writeVueFile(
44
+ await writeConfig(
48
45
  appRoot,
49
- "src/pages/admin/workspace/settings/index.vue",
50
- `<template>
51
- <section>
52
- <ShellOutlet host="admin-settings" position="forms" default />
53
- </section>
54
- </template>
46
+ `export const config = {
47
+ surfaceDefinitions: {
48
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
49
+ }
50
+ };
55
51
  `
56
52
  );
53
+ await writeShellLayout(appRoot);
57
54
 
58
55
  const context = await buildUiPageTemplateContext({
59
56
  appRoot,
57
+ targetFile: "admin/reports/index.vue",
60
58
  options: {}
61
59
  });
62
- assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_HOST__, "admin-settings");
63
- assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "forms");
64
- assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
65
- assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/");
66
- assert.equal(context.__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__, "/");
67
- assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, "");
60
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_HOST__, "shell-layout");
61
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "primary-menu");
62
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
63
+ assert.equal(context.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__, "/reports");
64
+ assert.equal(context.__JSKIT_UI_LINK_NON_WORKSPACE_SUFFIX__, "/reports");
65
+ assert.equal(context.__JSKIT_UI_LINK_TO_PROP_LINE__, "");
66
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_ID__, "ui-generator.page.admin.reports.link");
68
67
  });
69
68
  });
70
69
 
71
- test("buildUiPageTemplateContext supports explicit placement override", async () => {
70
+ test("buildUiPageTemplateContext supports explicit link placement override", async () => {
72
71
  await withTempApp(async (appRoot) => {
73
- await writeVueFile(appRoot, "src/components/ShellLayout.vue");
72
+ await writeConfig(
73
+ appRoot,
74
+ `export const config = {
75
+ surfaceDefinitions: {
76
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
77
+ }
78
+ };
79
+ `
80
+ );
81
+ await writeShellLayout(appRoot);
74
82
 
75
83
  const context = await buildUiPageTemplateContext({
76
84
  appRoot,
85
+ targetFile: "admin/reports/index.vue",
77
86
  options: {
78
- placement: "shell-layout:top-right"
87
+ "link-placement": "shell-layout:top-right"
79
88
  }
80
89
  });
81
- assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "top-right");
82
- assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
90
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "top-right");
91
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
83
92
  });
84
93
  });
85
94
 
86
- test("buildUiPageTemplateContext supports explicit package outlet placement", async () => {
95
+ test("buildUiPageTemplateContext supports explicit package outlet link placement", async () => {
87
96
  await withTempApp(async (appRoot) => {
88
- await writeVueFile(appRoot, "src/components/ShellLayout.vue");
89
- await writeVueFile(
97
+ await writeConfig(
98
+ appRoot,
99
+ `export const config = {
100
+ surfaceDefinitions: {
101
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
102
+ }
103
+ };
104
+ `
105
+ );
106
+ await writeShellLayout(appRoot);
107
+ await writeFileInApp(
90
108
  appRoot,
91
109
  ".jskit/lock.json",
92
110
  `${JSON.stringify(
@@ -106,7 +124,7 @@ test("buildUiPageTemplateContext supports explicit package outlet placement", as
106
124
  2
107
125
  )}\n`
108
126
  );
109
- await writeVueFile(
127
+ await writeFileInApp(
110
128
  appRoot,
111
129
  "node_modules/@example/users-web/package.descriptor.mjs",
112
130
  `export default {
@@ -126,63 +144,406 @@ test("buildUiPageTemplateContext supports explicit package outlet placement", as
126
144
 
127
145
  const context = await buildUiPageTemplateContext({
128
146
  appRoot,
147
+ targetFile: "admin/reports/index.vue",
129
148
  options: {
130
- placement: "workspace-tools:primary-menu"
149
+ "link-placement": "workspace-tools:primary-menu"
131
150
  }
132
151
  });
133
- assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_HOST__, "workspace-tools");
134
- assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "primary-menu");
152
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_HOST__, "workspace-tools");
153
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "primary-menu");
135
154
  });
136
155
  });
137
156
 
138
- test("buildUiPageTemplateContext supports explicit placement token and placement to", async () => {
157
+ test("buildUiPageTemplateContext supports explicit link component token and link-to", async () => {
139
158
  await withTempApp(async (appRoot) => {
140
- await writeVueFile(appRoot, "src/components/ShellLayout.vue");
159
+ await writeConfig(
160
+ appRoot,
161
+ `export const config = {
162
+ surfaceDefinitions: {
163
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
164
+ }
165
+ };
166
+ `
167
+ );
168
+ await writeShellLayout(appRoot);
141
169
 
142
170
  const context = await buildUiPageTemplateContext({
143
171
  appRoot,
172
+ targetFile: "admin/contacts/[contactId]/index/notes/index.vue",
144
173
  options: {
145
- name: "Notes",
146
- "directory-prefix": "contacts/[contactId]/(nestedChildren)",
147
- placement: "shell-layout:top-right",
148
- "placement-component-token": "local.main.ui.tab-link-item",
149
- "placement-to": "./notes"
174
+ "link-placement": "shell-layout:top-right",
175
+ "link-component-token": "local.main.ui.tab-link-item",
176
+ "link-to": "./notes"
150
177
  }
151
178
  });
152
- assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
153
- assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
154
- assert.equal(context.__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
155
- assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, " to: \"./notes\",\n");
179
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
180
+ assert.equal(context.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
181
+ assert.equal(context.__JSKIT_UI_LINK_NON_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
182
+ assert.equal(context.__JSKIT_UI_LINK_TO_PROP_LINE__, " to: \"./notes\",\n");
183
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_ID__, "ui-generator.page.admin.contacts.contact-id.notes.link");
156
184
  });
157
185
  });
158
186
 
159
- test("buildUiPageTemplateContext auto sets relative placement to for nestedChildren prefixes", async () => {
187
+ test("buildUiPageTemplateContext derives native route suffixes for index-owned child pages", async () => {
160
188
  await withTempApp(async (appRoot) => {
161
- await writeVueFile(appRoot, "src/components/ShellLayout.vue");
189
+ await writeConfig(
190
+ appRoot,
191
+ `export const config = {
192
+ surfaceDefinitions: {
193
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
194
+ }
195
+ };
196
+ `
197
+ );
198
+ await writeShellLayout(appRoot);
162
199
 
163
200
  const context = await buildUiPageTemplateContext({
164
201
  appRoot,
165
- options: {
166
- name: "Notes",
167
- "directory-prefix": "contacts/[contactId]/(nestedChildren)",
168
- placement: "shell-layout:top-right"
169
- }
202
+ targetFile: "admin/contacts/[contactId]/index/notes/index.vue",
203
+ options: {}
204
+ });
205
+ assert.equal(context.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
206
+ assert.equal(context.__JSKIT_UI_LINK_NON_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
207
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_ID__, "ui-generator.page.admin.contacts.contact-id.notes.link");
208
+ });
209
+ });
210
+
211
+ test("buildUiPageTemplateContext infers subpage link placement, tab token, and link-to from a file-route parent host", async () => {
212
+ await withTempApp(async (appRoot) => {
213
+ await writeConfig(
214
+ appRoot,
215
+ `export const config = {
216
+ surfaceDefinitions: {
217
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
218
+ }
219
+ };
220
+ `
221
+ );
222
+ await writeShellLayout(appRoot);
223
+ await writeFileInApp(
224
+ appRoot,
225
+ "src/pages/admin/contacts/[contactId].vue",
226
+ `<template>
227
+ <SectionContainerShell>
228
+ <template #tabs>
229
+ <ShellOutlet host="contact-view" position="sub-pages" />
230
+ </template>
231
+ <RouterView />
232
+ </SectionContainerShell>
233
+ </template>
234
+ `
235
+ );
236
+
237
+ const context = await buildUiPageTemplateContext({
238
+ appRoot,
239
+ targetFile: "admin/contacts/[contactId]/notes/index.vue",
240
+ options: {}
241
+ });
242
+
243
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_HOST__, "contact-view");
244
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "sub-pages");
245
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
246
+ assert.equal(context.__JSKIT_UI_LINK_TO_PROP_LINE__, " to: \"./notes\",\n");
247
+ });
248
+ });
249
+
250
+ test("buildUiPageTemplateContext inherits a file-route parent host for deeper descendants", async () => {
251
+ await withTempApp(async (appRoot) => {
252
+ await writeConfig(
253
+ appRoot,
254
+ `export const config = {
255
+ surfaceDefinitions: {
256
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
257
+ }
258
+ };
259
+ `
260
+ );
261
+ await writeShellLayout(appRoot);
262
+ await writeFileInApp(
263
+ appRoot,
264
+ "src/pages/admin/contacts/[contactId].vue",
265
+ `<template>
266
+ <SectionContainerShell>
267
+ <template #tabs>
268
+ <ShellOutlet host="contact-view" position="sub-pages" />
269
+ </template>
270
+ <RouterView />
271
+ </SectionContainerShell>
272
+ </template>
273
+ `
274
+ );
275
+
276
+ const context = await buildUiPageTemplateContext({
277
+ appRoot,
278
+ targetFile: "admin/contacts/[contactId]/notes/history/index.vue",
279
+ options: {}
280
+ });
281
+
282
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_HOST__, "contact-view");
283
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "sub-pages");
284
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
285
+ assert.equal(context.__JSKIT_UI_LINK_TO_PROP_LINE__, " to: \"./notes/history\",\n");
286
+ });
287
+ });
288
+
289
+ test("buildUiPageTemplateContext infers subpage link placement from an index-route parent host", async () => {
290
+ await withTempApp(async (appRoot) => {
291
+ await writeConfig(
292
+ appRoot,
293
+ `export const config = {
294
+ surfaceDefinitions: {
295
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
296
+ }
297
+ };
298
+ `
299
+ );
300
+ await writeShellLayout(appRoot);
301
+ await writeFileInApp(
302
+ appRoot,
303
+ "src/pages/admin/catalog/index.vue",
304
+ `<template>
305
+ <SectionContainerShell>
306
+ <template #tabs>
307
+ <ShellOutlet host="catalog" position="sub-pages" />
308
+ </template>
309
+ <RouterView />
310
+ </SectionContainerShell>
311
+ </template>
312
+ `
313
+ );
314
+
315
+ const context = await buildUiPageTemplateContext({
316
+ appRoot,
317
+ targetFile: "admin/catalog/index/products/index.vue",
318
+ options: {}
319
+ });
320
+
321
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_HOST__, "catalog");
322
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "sub-pages");
323
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
324
+ assert.equal(context.__JSKIT_UI_LINK_TO_PROP_LINE__, " to: \"./products\",\n");
325
+ });
326
+ });
327
+
328
+ test("buildUiPageTemplateContext finds the nearest index-route parent host", async () => {
329
+ await withTempApp(async (appRoot) => {
330
+ await writeConfig(
331
+ appRoot,
332
+ `export const config = {
333
+ surfaceDefinitions: {
334
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
335
+ }
336
+ };
337
+ `
338
+ );
339
+ await writeShellLayout(appRoot);
340
+ await writeFileInApp(
341
+ appRoot,
342
+ "src/pages/admin/catalog/index.vue",
343
+ `<template>
344
+ <SectionContainerShell>
345
+ <template #tabs>
346
+ <ShellOutlet host="catalog" position="sub-pages" />
347
+ </template>
348
+ <RouterView />
349
+ </SectionContainerShell>
350
+ </template>
351
+ `
352
+ );
353
+ await writeFileInApp(
354
+ appRoot,
355
+ "src/pages/admin/catalog/index/products/index.vue",
356
+ `<template>
357
+ <SectionContainerShell>
358
+ <template #tabs>
359
+ <ShellOutlet host="catalog-products" position="sub-pages" />
360
+ </template>
361
+ <RouterView />
362
+ </SectionContainerShell>
363
+ </template>
364
+ `
365
+ );
366
+
367
+ const context = await buildUiPageTemplateContext({
368
+ appRoot,
369
+ targetFile: "admin/catalog/index/products/index/variants/index.vue",
370
+ options: {}
371
+ });
372
+
373
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_HOST__, "catalog-products");
374
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "sub-pages");
375
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
376
+ assert.equal(context.__JSKIT_UI_LINK_TO_PROP_LINE__, " to: \"./variants\",\n");
377
+ });
378
+ });
379
+
380
+ test("buildUiPageTemplateContext infers subpage link placement from an index route hosted record page", async () => {
381
+ await withTempApp(async (appRoot) => {
382
+ await writeConfig(
383
+ appRoot,
384
+ `export const config = {
385
+ surfaceDefinitions: {
386
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
387
+ }
388
+ };
389
+ `
390
+ );
391
+ await writeShellLayout(appRoot);
392
+ await writeFileInApp(
393
+ appRoot,
394
+ "src/pages/admin/customers/[customerId]/index.vue",
395
+ `<template>
396
+ <SectionContainerShell>
397
+ <template #tabs>
398
+ <ShellOutlet host="customer-view" position="sub-pages" />
399
+ </template>
400
+ <RouterView />
401
+ </SectionContainerShell>
402
+ </template>
403
+ `
404
+ );
405
+
406
+ const context = await buildUiPageTemplateContext({
407
+ appRoot,
408
+ targetFile: "admin/customers/[customerId]/index/pets/index.vue",
409
+ options: {}
170
410
  });
171
- assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, " to: \"./notes\",\n");
172
- assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
411
+
412
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_HOST__, "customer-view");
413
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_POSITION__, "sub-pages");
414
+ assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
415
+ assert.equal(context.__JSKIT_UI_LINK_TO_PROP_LINE__, " to: \"./pets\",\n");
416
+ assert.equal(context.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__, "/customers/[customerId]/pets");
173
417
  });
174
418
  });
175
419
 
176
- test("buildUiPageTemplateContext validates placement format", async () => {
420
+ test("buildUiPageTemplateContext derives the same visible route from file and index page shapes", async () => {
177
421
  await withTempApp(async (appRoot) => {
178
- await writeVueFile(appRoot, "src/components/ShellLayout.vue");
422
+ await writeConfig(
423
+ appRoot,
424
+ `export const config = {
425
+ surfaceDefinitions: {
426
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
427
+ }
428
+ };
429
+ `
430
+ );
431
+ await writeShellLayout(appRoot);
432
+
433
+ const fileContext = await buildUiPageTemplateContext({
434
+ appRoot,
435
+ targetFile: "admin/catalog.vue",
436
+ options: {}
437
+ });
438
+ const indexContext = await buildUiPageTemplateContext({
439
+ appRoot,
440
+ targetFile: "admin/catalog/index.vue",
441
+ options: {}
442
+ });
443
+
444
+ assert.equal(fileContext.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__, "/catalog");
445
+ assert.equal(indexContext.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__, "/catalog");
446
+ assert.equal(fileContext.__JSKIT_UI_LINK_PLACEMENT_ID__, "ui-generator.page.admin.catalog.link");
447
+ assert.equal(indexContext.__JSKIT_UI_LINK_PLACEMENT_ID__, "ui-generator.page.admin.catalog.link");
448
+ });
449
+ });
450
+
451
+ test("buildUiPageTemplateContext rejects target files with a leading src segment", async () => {
452
+ await withTempApp(async (appRoot) => {
453
+ await writeConfig(
454
+ appRoot,
455
+ `export const config = {
456
+ surfaceDefinitions: {
457
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
458
+ }
459
+ };
460
+ `
461
+ );
462
+ await writeShellLayout(appRoot);
463
+
464
+ await assert.rejects(
465
+ () =>
466
+ buildUiPageTemplateContext({
467
+ appRoot,
468
+ targetFile: "src/components/ReportsPanel.vue",
469
+ options: {}
470
+ }),
471
+ /must be relative to src\/pages\/, without a leading src\/ segment/
472
+ );
473
+ });
474
+ });
475
+
476
+ test("buildUiPageTemplateContext fails when the target file matches no surface", async () => {
477
+ await withTempApp(async (appRoot) => {
478
+ await writeConfig(
479
+ appRoot,
480
+ `export const config = {
481
+ surfaceDefinitions: {
482
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
483
+ }
484
+ };
485
+ `
486
+ );
487
+ await writeShellLayout(appRoot);
488
+
489
+ await assert.rejects(
490
+ () =>
491
+ buildUiPageTemplateContext({
492
+ appRoot,
493
+ targetFile: "reports/index.vue",
494
+ options: {}
495
+ }),
496
+ /must be relative to src\/pages\/ and resolve to a configured surface/
497
+ );
498
+ });
499
+ });
500
+
501
+ test("buildUiPageTemplateContext chooses the most specific matching surface pagesRoot", async () => {
502
+ await withTempApp(async (appRoot) => {
503
+ await writeConfig(
504
+ appRoot,
505
+ `export const config = {
506
+ surfaceDefinitions: {
507
+ app: { id: "app", pagesRoot: "", enabled: true },
508
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
509
+ }
510
+ };
511
+ `
512
+ );
513
+ await writeShellLayout(appRoot);
514
+
515
+ const context = await buildUiPageTemplateContext({
516
+ appRoot,
517
+ targetFile: "admin/reports/index.vue",
518
+ options: {}
519
+ });
520
+
521
+ assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_ID__, "ui-generator.page.admin.reports.link");
522
+ assert.equal(context.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__, "/reports");
523
+ assert.equal(context.__JSKIT_UI_LINK_NON_WORKSPACE_SUFFIX__, "/reports");
524
+ });
525
+ });
526
+
527
+ test("buildUiPageTemplateContext validates link placement format", async () => {
528
+ await withTempApp(async (appRoot) => {
529
+ await writeConfig(
530
+ appRoot,
531
+ `export const config = {
532
+ surfaceDefinitions: {
533
+ admin: { id: "admin", pagesRoot: "admin", enabled: true }
534
+ }
535
+ };
536
+ `
537
+ );
538
+ await writeShellLayout(appRoot);
179
539
 
180
540
  await assert.rejects(
181
541
  () =>
182
542
  buildUiPageTemplateContext({
183
543
  appRoot,
544
+ targetFile: "admin/reports/index.vue",
184
545
  options: {
185
- placement: "invalid-placement"
546
+ "link-placement": "invalid-placement"
186
547
  }
187
548
  }),
188
549
  /option "placement" must be in "host:position" format/
@@ -76,17 +76,16 @@ export { MainClientProvider, registerMainClientComponent };
76
76
  );
77
77
  }
78
78
 
79
- test("ui-generator element subcommand creates component and outlet placement", async () => {
79
+ test("ui-generator placed-element subcommand creates component and outlet placement", async () => {
80
80
  await withTempApp(async (appRoot) => {
81
81
  await writeAppFixture(appRoot);
82
82
 
83
83
  const result = await runGeneratorSubcommand({
84
84
  appRoot,
85
- subcommand: "element",
85
+ subcommand: "placed-element",
86
86
  options: {
87
87
  name: "Ops Panel",
88
- surface: "admin",
89
- placement: "shell-layout:top-right"
88
+ surface: "admin"
90
89
  }
91
90
  });
92
91
 
@@ -111,12 +110,86 @@ test("ui-generator element subcommand creates component and outlet placement", a
111
110
  });
112
111
  });
113
112
 
114
- test("ui-generator element subcommand requires appRoot", async () => {
113
+ test("ui-generator placed-element subcommand supports explicit placement override", async () => {
114
+ await withTempApp(async (appRoot) => {
115
+ await writeAppFixture(appRoot);
116
+
117
+ await runGeneratorSubcommand({
118
+ appRoot,
119
+ subcommand: "placed-element",
120
+ options: {
121
+ name: "Ops Panel",
122
+ surface: "admin",
123
+ placement: "shell-layout:primary-menu"
124
+ }
125
+ });
126
+
127
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
128
+ assert.match(placementSource, /host: "shell-layout"/);
129
+ assert.match(placementSource, /position: "primary-menu"/);
130
+ });
131
+ });
132
+
133
+ test("ui-generator placed-element subcommand refuses to overwrite an existing component without force", async () => {
134
+ await withTempApp(async (appRoot) => {
135
+ await writeAppFixture(appRoot);
136
+ await writeFile(
137
+ path.join(appRoot, "src", "components", "OpsPanelElement.vue"),
138
+ "<template><div>custom</div></template>\n",
139
+ "utf8"
140
+ );
141
+
142
+ await assert.rejects(
143
+ () =>
144
+ runGeneratorSubcommand({
145
+ appRoot,
146
+ subcommand: "placed-element",
147
+ options: {
148
+ name: "Ops Panel",
149
+ surface: "admin"
150
+ }
151
+ }),
152
+ /ui-generator placed-element will not overwrite existing component file src\/components\/OpsPanelElement\.vue\. Re-run with --force to overwrite it\./
153
+ );
154
+ });
155
+ });
156
+
157
+ test("ui-generator placed-element subcommand overwrites an existing component when force is enabled", async () => {
158
+ await withTempApp(async (appRoot) => {
159
+ await writeAppFixture(appRoot);
160
+ await writeFile(
161
+ path.join(appRoot, "src", "components", "OpsPanelElement.vue"),
162
+ "<template><div>custom</div></template>\n",
163
+ "utf8"
164
+ );
165
+
166
+ const result = await runGeneratorSubcommand({
167
+ appRoot,
168
+ subcommand: "placed-element",
169
+ options: {
170
+ name: "Ops Panel",
171
+ surface: "admin",
172
+ force: "true"
173
+ }
174
+ });
175
+
176
+ assert.deepEqual(result.touchedFiles, [
177
+ "packages/main/src/client/providers/MainClientProvider.js",
178
+ "src/components/OpsPanelElement.vue",
179
+ "src/placement.js"
180
+ ]);
181
+
182
+ const componentSource = await readFile(path.join(appRoot, "src", "components", "OpsPanelElement.vue"), "utf8");
183
+ assert.match(componentSource, /<h2 class="text-h6 mb-2">Ops Panel<\/h2>/);
184
+ });
185
+ });
186
+
187
+ test("ui-generator placed-element subcommand requires appRoot", async () => {
115
188
  await assert.rejects(
116
189
  () =>
117
190
  runGeneratorSubcommand({
118
191
  appRoot: "",
119
- subcommand: "element",
192
+ subcommand: "placed-element",
120
193
  options: {
121
194
  name: "Ops Panel",
122
195
  surface: "admin"