@jskit-ai/shell-web 0.1.63 → 0.1.65

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,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/shell-web",
4
- version: "0.1.63",
4
+ version: "0.1.65",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
@@ -71,46 +71,157 @@ export default Object.freeze({
71
71
  },
72
72
  {
73
73
  target: "shell-layout:primary-menu",
74
- defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
75
74
  surfaces: ["*"],
76
75
  source: "src/client/components/ShellLayout.vue"
77
76
  },
78
77
  {
79
78
  target: "shell-layout:secondary-menu",
80
- defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
81
79
  surfaces: ["*"],
82
80
  source: "src/client/components/ShellLayout.vue"
83
81
  },
84
82
  {
85
83
  target: "home-settings:primary-menu",
86
- defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
87
84
  surfaces: ["home"],
88
85
  source: "templates/src/pages/home/settings.vue"
89
86
  }
90
87
  ],
88
+ topology: {
89
+ placements: [
90
+ {
91
+ id: "shell.primary-nav",
92
+ description: "Primary top-level navigation for the current surface.",
93
+ surfaces: ["*"],
94
+ default: true,
95
+ variants: {
96
+ compact: {
97
+ outlet: "shell-layout:primary-menu",
98
+ renderers: {
99
+ link: "local.main.ui.surface-aware-menu-link-item"
100
+ }
101
+ },
102
+ medium: {
103
+ outlet: "shell-layout:primary-menu",
104
+ renderers: {
105
+ link: "local.main.ui.surface-aware-menu-link-item"
106
+ }
107
+ },
108
+ expanded: {
109
+ outlet: "shell-layout:primary-menu",
110
+ renderers: {
111
+ link: "local.main.ui.surface-aware-menu-link-item"
112
+ }
113
+ }
114
+ }
115
+ },
116
+ {
117
+ id: "shell.status",
118
+ description: "Surface status, connection, and utility indicators.",
119
+ surfaces: ["*"],
120
+ variants: {
121
+ compact: {
122
+ outlet: "shell-layout:top-right"
123
+ },
124
+ medium: {
125
+ outlet: "shell-layout:top-right"
126
+ },
127
+ expanded: {
128
+ outlet: "shell-layout:top-right"
129
+ }
130
+ }
131
+ },
132
+ {
133
+ id: "shell.secondary-nav",
134
+ description: "Secondary navigation for lower-priority shell links.",
135
+ surfaces: ["*"],
136
+ variants: {
137
+ compact: {
138
+ outlet: "shell-layout:secondary-menu",
139
+ renderers: {
140
+ link: "local.main.ui.surface-aware-menu-link-item"
141
+ }
142
+ },
143
+ medium: {
144
+ outlet: "shell-layout:secondary-menu",
145
+ renderers: {
146
+ link: "local.main.ui.surface-aware-menu-link-item"
147
+ }
148
+ },
149
+ expanded: {
150
+ outlet: "shell-layout:secondary-menu",
151
+ renderers: {
152
+ link: "local.main.ui.surface-aware-menu-link-item"
153
+ }
154
+ }
155
+ }
156
+ },
157
+ {
158
+ id: "shell.identity",
159
+ description: "Current user, workspace, and surface identity controls.",
160
+ surfaces: ["*"],
161
+ variants: {
162
+ compact: {
163
+ outlet: "shell-layout:top-left"
164
+ },
165
+ medium: {
166
+ outlet: "shell-layout:top-left"
167
+ },
168
+ expanded: {
169
+ outlet: "shell-layout:top-left"
170
+ }
171
+ }
172
+ },
173
+ {
174
+ id: "page.section-nav",
175
+ owner: "home-settings",
176
+ description: "Navigation between child pages in the home settings section.",
177
+ surfaces: ["home"],
178
+ variants: {
179
+ compact: {
180
+ outlet: "home-settings:primary-menu",
181
+ renderers: {
182
+ link: "local.main.ui.surface-aware-menu-link-item"
183
+ }
184
+ },
185
+ medium: {
186
+ outlet: "home-settings:primary-menu",
187
+ renderers: {
188
+ link: "local.main.ui.surface-aware-menu-link-item"
189
+ }
190
+ },
191
+ expanded: {
192
+ outlet: "home-settings:primary-menu",
193
+ renderers: {
194
+ link: "local.main.ui.surface-aware-menu-link-item"
195
+ }
196
+ }
197
+ }
198
+ }
199
+ ]
200
+ },
91
201
  contributions: [
92
202
  {
93
203
  id: "shell-web.home.menu.home",
94
- target: "shell-layout:primary-menu",
204
+ target: "shell.primary-nav",
205
+ kind: "link",
95
206
  surfaces: ["home"],
96
207
  order: 50,
97
- componentToken: "local.main.ui.surface-aware-menu-link-item",
98
208
  source: "templates/src/placement.js"
99
209
  },
100
210
  {
101
211
  id: "shell-web.home.menu.settings",
102
- target: "shell-layout:primary-menu",
212
+ target: "shell.primary-nav",
213
+ kind: "link",
103
214
  surfaces: ["home"],
104
215
  order: 100,
105
- componentToken: "local.main.ui.surface-aware-menu-link-item",
106
216
  source: "templates/src/placement.js"
107
217
  },
108
218
  {
109
219
  id: "shell-web.home.settings.general",
110
- target: "home-settings:primary-menu",
220
+ target: "page.section-nav",
221
+ owner: "home-settings",
222
+ kind: "link",
111
223
  surfaces: ["home"],
112
224
  order: 100,
113
- componentToken: "local.main.ui.surface-aware-menu-link-item",
114
225
  source: "templates/src/placement.js"
115
226
  }
116
227
  ]
@@ -122,7 +233,7 @@ export default Object.freeze({
122
233
  runtime: {
123
234
  "@mdi/js": "^7.4.47",
124
235
  "@tanstack/vue-query": "^5.90.5",
125
- "@jskit-ai/kernel": "0.1.64",
236
+ "@jskit-ai/kernel": "0.1.66",
126
237
  "vuetify": "^4.0.0"
127
238
  },
128
239
  dev: {}
@@ -254,6 +365,14 @@ export default Object.freeze({
254
365
  category: "shell-web",
255
366
  id: "shell-web-placement-registry"
256
367
  },
368
+ {
369
+ from: "templates/src/placementTopology.js",
370
+ to: "src/placementTopology.js",
371
+ ownership: "app",
372
+ reason: "Install app-owned semantic placement topology used by shell-web placement runtime.",
373
+ category: "shell-web",
374
+ id: "shell-web-placement-topology"
375
+ },
257
376
  {
258
377
  from: "templates/src/pages/home.vue",
259
378
  toSurface: "home",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -25,7 +25,7 @@
25
25
  "dependencies": {
26
26
  "@mdi/js": "^7.4.47",
27
27
  "@tanstack/vue-query": "^5.90.5",
28
- "@jskit-ai/kernel": "0.1.64",
28
+ "@jskit-ai/kernel": "0.1.66",
29
29
  "pinia": "^3.0.4",
30
30
  "vuetify": "^4.0.0"
31
31
  },
@@ -52,13 +52,9 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
52
52
  <ShellOutlet
53
53
  target="shell-layout:primary-menu"
54
54
  default
55
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
56
55
  />
57
56
  <v-divider class="my-2" />
58
- <ShellOutlet
59
- target="shell-layout:secondary-menu"
60
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
61
- />
57
+ <ShellOutlet target="shell-layout:secondary-menu" />
62
58
  </v-list>
63
59
  </slot>
64
60
  </v-navigation-drawer>
@@ -6,6 +6,7 @@ import {
6
6
  ref
7
7
  } from "vue";
8
8
  import { useRoute } from "vue-router";
9
+ import { useDisplay } from "vuetify";
9
10
  import { useWebPlacementContext, useWebPlacementRuntime } from "../placement/inject.js";
10
11
  import { resolveRuntimePathname } from "../placement/pathname.js";
11
12
  import {
@@ -25,10 +26,6 @@ const props = defineProps({
25
26
  context: {
26
27
  type: Object,
27
28
  default: () => ({})
28
- },
29
- defaultLinkComponentToken: {
30
- type: String,
31
- default: ""
32
29
  }
33
30
  });
34
31
 
@@ -39,6 +36,13 @@ try {
39
36
  route = null;
40
37
  }
41
38
 
39
+ let display = null;
40
+ try {
41
+ display = useDisplay();
42
+ } catch {
43
+ display = null;
44
+ }
45
+
42
46
  const placementRuntime = useWebPlacementRuntime();
43
47
  const { context: placementContext } = useWebPlacementContext();
44
48
  const revision = ref(
@@ -82,11 +86,37 @@ const resolvedTargetId = computed(() => {
82
86
  return String(props.target || "").trim();
83
87
  });
84
88
 
89
+ const resolvedLayoutClass = computed(() => {
90
+ const displayName = String(display?.name?.value || "").trim().toLowerCase();
91
+ if (displayName === "xs" || displayName === "sm") {
92
+ return "compact";
93
+ }
94
+ if (displayName === "md") {
95
+ return "medium";
96
+ }
97
+ if (displayName === "lg" || displayName === "xl" || displayName === "xxl") {
98
+ return "expanded";
99
+ }
100
+
101
+ const viewportWidth =
102
+ typeof window === "object" && window?.innerWidth
103
+ ? Number(window.innerWidth)
104
+ : 0;
105
+ if (viewportWidth > 0 && viewportWidth < 600) {
106
+ return "compact";
107
+ }
108
+ if (viewportWidth > 0 && viewportWidth < 1280) {
109
+ return "medium";
110
+ }
111
+ return "expanded";
112
+ });
113
+
85
114
  const placements = computed(() => {
86
115
  void revision.value;
87
116
  return placementRuntime.getPlacements({
88
117
  surface: resolvedSurface.value,
89
118
  target: resolvedTargetId.value,
119
+ layoutClass: resolvedLayoutClass.value,
90
120
  context: props.context
91
121
  });
92
122
  });
@@ -7,10 +7,6 @@ const props = defineProps({
7
7
  type: String,
8
8
  required: true
9
9
  },
10
- defaultLinkComponentToken: {
11
- type: String,
12
- default: ""
13
- },
14
10
  icon: {
15
11
  type: String,
16
12
  default: mdiCogOutline
@@ -48,10 +44,7 @@ const props = defineProps({
48
44
  </template>
49
45
 
50
46
  <v-list :min-width="props.minWidth" density="comfortable" class="py-1">
51
- <ShellOutlet
52
- :target="props.target"
53
- :default-link-component-token="props.defaultLinkComponentToken"
54
- />
47
+ <ShellOutlet :target="props.target" />
55
48
  </v-list>
56
49
  </v-menu>
57
50
  </template>
@@ -2,6 +2,11 @@ export {
2
2
  createPlacementRegistry
3
3
  } from "./registry.js";
4
4
 
5
+ export {
6
+ definePlacement,
7
+ definePlacementTopology
8
+ } from "./validators.js";
9
+
5
10
  export {
6
11
  useWebPlacementContext
7
12
  } from "./inject.js";
@@ -1,6 +1,10 @@
1
1
  import { DEFAULT_DEBUG_DEPTH, explodePayload } from "./debug.js";
2
2
  import { createListenerSubscription } from "@jskit-ai/kernel/shared/support/listenerSet";
3
3
  import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import {
5
+ normalizePlacementLayoutClass,
6
+ normalizePlacementTopologyDefinition
7
+ } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
4
8
  import {
5
9
  isRenderableComponent,
6
10
  normalizePlacementDefinition,
@@ -100,6 +104,40 @@ function normalizePlacementList(placements, context = {}) {
100
104
  .map((entry) => entry.placement);
101
105
  }
102
106
 
107
+ function normalizeTopologyList(topology, context = {}) {
108
+ if (Array.isArray(topology)) {
109
+ const normalized = normalizePlacementTopologyDefinition(
110
+ { placements: topology },
111
+ {
112
+ context: context.source || "placement topology"
113
+ }
114
+ );
115
+ return normalizeTopologyList(normalized, context);
116
+ }
117
+
118
+ const candidates = ensureArray(topology);
119
+ const entries = [];
120
+ for (const candidate of candidates) {
121
+ const normalized = normalizePlacementTopologyDefinition(candidate, {
122
+ context: context.source || "placement topology"
123
+ });
124
+ entries.push(...normalized.placements);
125
+ }
126
+
127
+ const byKey = new Map();
128
+ for (const entry of entries) {
129
+ const key = `${entry.id}::${entry.owner || ""}`;
130
+ if (byKey.has(key)) {
131
+ throw new Error(
132
+ `Duplicate semantic placement "${entry.id}"${entry.owner ? ` for owner "${entry.owner}"` : ""} in ${context.source || "placement topology"}.`
133
+ );
134
+ }
135
+ byKey.set(key, entry);
136
+ }
137
+
138
+ return Object.freeze([...byKey.values()]);
139
+ }
140
+
103
141
  function matchesSurface(placementSurfaces, requestedSurface) {
104
142
  if (requestedSurface === WEB_PLACEMENT_SURFACE_ANY) {
105
143
  return true;
@@ -108,6 +146,67 @@ function matchesSurface(placementSurfaces, requestedSurface) {
108
146
  return surfaces.includes(WEB_PLACEMENT_SURFACE_ANY) || surfaces.includes(requestedSurface);
109
147
  }
110
148
 
149
+ function resolveTopologyPlacement(topologyEntries = [], placement = {}, requestedSurface = WEB_PLACEMENT_SURFACE_ANY) {
150
+ const semanticTarget = String(placement.target || "").trim();
151
+ const owner = String(placement.owner || "").trim();
152
+ const matches = topologyEntries.filter((entry) => {
153
+ if (entry.id !== semanticTarget) {
154
+ return false;
155
+ }
156
+ if (owner && entry.owner !== owner) {
157
+ return false;
158
+ }
159
+ if (!owner && entry.owner) {
160
+ return false;
161
+ }
162
+ return matchesSurface(entry.surfaces, requestedSurface);
163
+ });
164
+
165
+ if (matches.length === 1) {
166
+ return matches[0];
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function resolveRenderablePlacement({
172
+ placement = {},
173
+ topologyEntries = [],
174
+ requestedSurface = WEB_PLACEMENT_SURFACE_ANY,
175
+ requestedTarget = "",
176
+ requestedLayoutClass = "compact"
177
+ } = {}) {
178
+ if (placement.targetType === "concrete") {
179
+ if (placement.target !== requestedTarget) {
180
+ return null;
181
+ }
182
+ return placement;
183
+ }
184
+
185
+ const topologyPlacement = resolveTopologyPlacement(topologyEntries, placement, requestedSurface);
186
+ if (!topologyPlacement) {
187
+ return null;
188
+ }
189
+
190
+ const variant = topologyPlacement.variants?.[requestedLayoutClass] || null;
191
+ if (!variant || variant.outlet !== requestedTarget) {
192
+ return null;
193
+ }
194
+
195
+ const componentToken = String(placement.componentToken || variant.renderers?.[placement.kind] || "").trim();
196
+ if (!componentToken) {
197
+ return null;
198
+ }
199
+
200
+ return Object.freeze({
201
+ ...placement,
202
+ target: requestedTarget,
203
+ semanticTarget: placement.target,
204
+ topologyOwner: topologyPlacement.owner || "",
205
+ layoutClass: requestedLayoutClass,
206
+ componentToken
207
+ });
208
+ }
209
+
111
210
  function resolveContextContributors(app, baseContext = {}, logger) {
112
211
  const contributors = app.resolveTag("web-placement.context.client");
113
212
  let merged = {};
@@ -232,6 +331,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
232
331
  const listeners = new Set();
233
332
  const subscribe = createListenerSubscription(listeners);
234
333
  let placementDefinitions = Object.freeze([]);
334
+ let placementTopology = Object.freeze([]);
235
335
  let sharedContext = Object.freeze({});
236
336
  let revision = 0;
237
337
 
@@ -276,6 +376,22 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
276
376
  });
277
377
  }
278
378
 
379
+ function replacePlacementTopology(topology = [], { source = "app placement topology" } = {}) {
380
+ missingTokens.clear();
381
+ invalidComponentTokens.clear();
382
+ failedTokens.clear();
383
+ placementTopology = normalizeTopologyList(topology, { source });
384
+ debugLog("replacePlacementTopology", {
385
+ source,
386
+ count: placementTopology.length,
387
+ ids: placementTopology.map((entry) => entry.owner ? `${entry.id}#${entry.owner}` : entry.id)
388
+ });
389
+ notify({
390
+ type: "placement-topology.replaced",
391
+ source
392
+ });
393
+ }
394
+
279
395
  function getContext() {
280
396
  return sharedContext;
281
397
  }
@@ -308,13 +424,17 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
308
424
  return revision;
309
425
  }
310
426
 
311
- function getPlacements({ surface = WEB_PLACEMENT_SURFACE_ANY, target = "", context = {} } = {}) {
427
+ function getPlacements({ surface = WEB_PLACEMENT_SURFACE_ANY, target = "", layoutClass = "", context = {} } = {}) {
312
428
  const normalizedTarget = normalizePlacementTarget(target, { strict: false });
313
429
  if (!normalizedTarget) {
314
430
  return Object.freeze([]);
315
431
  }
316
432
 
317
433
  const normalizedSurface = normalizeSurface(surface);
434
+ const normalizedLayoutClass =
435
+ normalizePlacementLayoutClass(layoutClass) ||
436
+ normalizePlacementLayoutClass(sharedContext?.layoutClass) ||
437
+ "compact";
318
438
  const baseContext = isRecord(context) ? { ...context } : {};
319
439
  const contextFromRuntime = isRecord(sharedContext) ? sharedContext : {};
320
440
  const contextFromContributors = resolveContextContributors(
@@ -323,6 +443,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
323
443
  app,
324
444
  surface: normalizedSurface,
325
445
  target: normalizedTarget,
446
+ layoutClass: normalizedLayoutClass,
326
447
  context: {
327
448
  ...contextFromRuntime,
328
449
  ...baseContext
@@ -336,44 +457,54 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
336
457
  ...baseContext,
337
458
  app,
338
459
  surface: normalizedSurface,
339
- target: normalizedTarget
460
+ target: normalizedTarget,
461
+ layoutClass: normalizedLayoutClass
340
462
  };
341
463
 
342
464
  debugLog("getPlacements:start", {
343
465
  surface: normalizedSurface,
344
466
  target: normalizedTarget,
467
+ layoutClass: normalizedLayoutClass,
345
468
  contextKeys: Object.keys(baseContext),
346
469
  sharedContextKeys: Object.keys(contextFromRuntime),
347
- placementCount: placementDefinitions.length
470
+ placementCount: placementDefinitions.length,
471
+ topologyCount: placementTopology.length
348
472
  });
349
473
 
350
474
  const matches = [];
351
475
  for (const placement of placementDefinitions) {
352
- if (placement.target !== normalizedTarget) {
476
+ const renderablePlacement = resolveRenderablePlacement({
477
+ placement,
478
+ topologyEntries: placementTopology,
479
+ requestedSurface: normalizedSurface,
480
+ requestedTarget: normalizedTarget,
481
+ requestedLayoutClass: normalizedLayoutClass
482
+ });
483
+ if (!renderablePlacement) {
353
484
  continue;
354
485
  }
355
- const placementSurfaces = Array.isArray(placement.surfaces)
356
- ? placement.surfaces
486
+ const placementSurfaces = Array.isArray(renderablePlacement.surfaces)
487
+ ? renderablePlacement.surfaces
357
488
  : [WEB_PLACEMENT_SURFACE_ANY];
358
489
 
359
490
  if (!matchesSurface(placementSurfaces, normalizedSurface)) {
360
491
  debugLog("getPlacements:skip-surfaces", {
361
- placementId: placement.id,
492
+ placementId: renderablePlacement.id,
362
493
  placementSurfaces,
363
494
  requestedSurface: normalizedSurface
364
495
  });
365
496
  continue;
366
497
  }
367
- if (!shouldIncludePlacement(placement, placementContext, runtimeLogger)) {
498
+ if (!shouldIncludePlacement(renderablePlacement, placementContext, runtimeLogger)) {
368
499
  debugLog("getPlacements:skip-when", {
369
- placementId: placement.id
500
+ placementId: renderablePlacement.id
370
501
  });
371
502
  continue;
372
503
  }
373
504
 
374
505
  const component = resolvePlacementComponent(
375
506
  app,
376
- placement,
507
+ renderablePlacement,
377
508
  runtimeLogger,
378
509
  missingTokens,
379
510
  invalidComponentTokens,
@@ -381,22 +512,22 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
381
512
  );
382
513
  if (!component) {
383
514
  debugLog("getPlacements:skip-component", {
384
- placementId: placement.id,
385
- componentToken: placement.componentToken
515
+ placementId: renderablePlacement.id,
516
+ componentToken: renderablePlacement.componentToken
386
517
  });
387
518
  continue;
388
519
  }
389
520
 
390
521
  debugLog("getPlacements:include", {
391
- placementId: placement.id,
392
- componentToken: placement.componentToken,
522
+ placementId: renderablePlacement.id,
523
+ componentToken: renderablePlacement.componentToken,
393
524
  placementSurfaces,
394
- order: placement.order
525
+ order: renderablePlacement.order
395
526
  });
396
527
 
397
528
  matches.push(
398
529
  Object.freeze({
399
- ...placement,
530
+ ...renderablePlacement,
400
531
  component
401
532
  })
402
533
  );
@@ -405,6 +536,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
405
536
  debugLog("getPlacements:done", {
406
537
  surface: normalizedSurface,
407
538
  target: normalizedTarget,
539
+ layoutClass: normalizedLayoutClass,
408
540
  resultIds: matches.map((entry) => entry.id)
409
541
  });
410
542
 
@@ -413,6 +545,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
413
545
 
414
546
  return Object.freeze({
415
547
  replacePlacements,
548
+ replacePlacementTopology,
416
549
  getPlacements,
417
550
  getContext,
418
551
  setContext,
@@ -1,5 +1,10 @@
1
1
  import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
- import { normalizeShellOutletTargetId } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
2
+ import {
3
+ normalizePlacementKind,
4
+ normalizePlacementTopologyDefinition,
5
+ normalizeSemanticPlacementId,
6
+ resolvePlacementTargetReference
7
+ } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
3
8
 
4
9
  const WEB_PLACEMENT_SURFACE_ANY = "*";
5
10
 
@@ -57,13 +62,13 @@ function toInteger(value, fallback = 1000) {
57
62
  }
58
63
 
59
64
  function normalizePlacementTarget(value, { strict = false, source = "placement" } = {}) {
60
- const normalized = normalizeShellOutletTargetId(String(value || "").toLowerCase());
61
- if (normalized) {
62
- return normalized;
65
+ const reference = resolvePlacementTargetReference(String(value || "").toLowerCase());
66
+ if (reference?.id) {
67
+ return reference.id;
63
68
  }
64
69
 
65
70
  if (strict) {
66
- throw new TypeError(`${source} requires target in "host:position" format.`);
71
+ throw new TypeError(`${source} requires semantic target in "area.slot" format or internal concrete target in "host:position" format.`);
67
72
  }
68
73
  return "";
69
74
  }
@@ -132,8 +137,19 @@ function normalizePlacementDefinition(value, { strict = false, source = "placeme
132
137
  return null;
133
138
  }
134
139
 
140
+ const targetReference = resolvePlacementTargetReference(target);
141
+ const internal = value.internal === true;
142
+ if (targetReference?.type === "concrete" && internal !== true) {
143
+ if (strict) {
144
+ throw new TypeError(`${source} "${id}" targets concrete outlet "${target}" without internal: true.`);
145
+ }
146
+ return null;
147
+ }
148
+
149
+ const owner = normalizeText(value.owner).toLowerCase();
150
+ const kind = normalizePlacementKind(value.kind) || (normalizeText(value.componentToken) ? "component" : "link");
135
151
  const componentToken = normalizeText(value.componentToken);
136
- if (!componentToken) {
152
+ if (kind === "component" && !componentToken) {
137
153
  if (strict) {
138
154
  throw new TypeError(`${source} "${id}" requires componentToken.`);
139
155
  }
@@ -150,12 +166,16 @@ function normalizePlacementDefinition(value, { strict = false, source = "placeme
150
166
  return Object.freeze({
151
167
  id,
152
168
  target,
169
+ targetType: targetReference?.type || "",
170
+ owner,
171
+ kind,
153
172
  surfaces,
154
173
  order: toInteger(value.order, 1000),
155
174
  componentToken,
156
175
  props,
157
176
  when,
158
- enabled: value.enabled !== false
177
+ enabled: value.enabled !== false,
178
+ internal
159
179
  });
160
180
  }
161
181
 
@@ -166,6 +186,12 @@ function definePlacement(value = {}) {
166
186
  });
167
187
  }
168
188
 
189
+ function definePlacementTopology(value = {}) {
190
+ return normalizePlacementTopologyDefinition(value, {
191
+ context: "placement topology"
192
+ });
193
+ }
194
+
169
195
  export {
170
196
  isRecord,
171
197
  isRenderableComponent,
@@ -174,5 +200,7 @@ export {
174
200
  normalizePlacementTarget,
175
201
  normalizePlacementSurfaces,
176
202
  normalizePlacementDefinition,
177
- definePlacement
203
+ definePlacement,
204
+ definePlacementTopology,
205
+ normalizeSemanticPlacementId
178
206
  };
@@ -30,6 +30,7 @@ import { resolveBootstrapErrorStatusCode } from "../bootstrap/bootstrapErrorStat
30
30
 
31
31
  // Keep this constant for diagnostics, but keep import() below as a literal string so Vite can statically analyze it.
32
32
  const APP_PLACEMENT_MODULE_SPECIFIER = "/src/placement.js";
33
+ const APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER = "/src/placementTopology.js";
33
34
  const APP_ERROR_MODULE_SPECIFIER = "/src/error.js";
34
35
 
35
36
  function createShellWebQueryClient() {
@@ -88,6 +89,42 @@ async function loadAppPlacementDefinitions(logger) {
88
89
  return [];
89
90
  }
90
91
 
92
+ async function loadAppPlacementTopology(logger) {
93
+ try {
94
+ const moduleNamespace = await import("/src/placementTopology.js");
95
+ const exported = moduleNamespace?.default;
96
+ const resolved = typeof exported === "function" ? exported() : exported;
97
+ if (Array.isArray(resolved)) {
98
+ return resolved;
99
+ }
100
+ if (resolved && typeof resolved === "object") {
101
+ return [resolved];
102
+ }
103
+
104
+ logger.warn(
105
+ {
106
+ module: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER,
107
+ exportedType: typeof exported
108
+ },
109
+ "App placement topology module default export did not resolve to an object or array; using empty topology."
110
+ );
111
+ } catch (error) {
112
+ if (isMissingDynamicModule(error, APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER)) {
113
+ return [];
114
+ }
115
+
116
+ logger.warn(
117
+ {
118
+ module: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER,
119
+ error: String(error?.message || error || "unknown error")
120
+ },
121
+ "Failed to load app placement topology module; using empty topology."
122
+ );
123
+ }
124
+
125
+ return [];
126
+ }
127
+
91
128
  function createErrorConfigToolkit(errorRuntime) {
92
129
  return Object.freeze({
93
130
  createDefaultErrorPolicy,
@@ -297,6 +334,10 @@ class ShellWebClientProvider {
297
334
  const logger = createSharedProviderLogger(isRecord(app) ? app : null);
298
335
  const placementRuntime = app.make("runtime.web-placement.client");
299
336
  if (placementRuntime && typeof placementRuntime.replacePlacements === "function") {
337
+ const placementTopology = await loadAppPlacementTopology(logger);
338
+ if (typeof placementRuntime.replacePlacementTopology === "function") {
339
+ placementRuntime.replacePlacementTopology(placementTopology, { source: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER });
340
+ }
300
341
  const placements = await loadAppPlacementDefinitions(logger);
301
342
  placementRuntime.replacePlacements(placements, { source: APP_PLACEMENT_MODULE_SPECIFIER });
302
343
  const appConfig = getClientAppConfig();
@@ -52,13 +52,9 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
52
52
  <ShellOutlet
53
53
  target="shell-layout:primary-menu"
54
54
  default
55
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
56
55
  />
57
56
  <v-divider class="my-2" />
58
- <ShellOutlet
59
- target="shell-layout:secondary-menu"
60
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
61
- />
57
+ <ShellOutlet target="shell-layout:secondary-menu" />
62
58
  </v-list>
63
59
  </slot>
64
60
  </v-navigation-drawer>
@@ -15,10 +15,7 @@ import { RouterView } from "vue-router";
15
15
  <v-row no-gutters>
16
16
  <v-col cols="12" md="3" lg="2" class="pr-md-4 mb-4 mb-md-0">
17
17
  <v-list nav density="comfortable" rounded="lg" border>
18
- <ShellOutlet
19
- target="home-settings:primary-menu"
20
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
21
- />
18
+ <ShellOutlet target="home-settings:primary-menu" />
22
19
  </v-list>
23
20
  </v-col>
24
21
 
@@ -13,10 +13,10 @@ export default function getPlacements() {
13
13
 
14
14
  addPlacement({
15
15
  id: "shell-web.home.menu.home",
16
- target: "shell-layout:primary-menu",
16
+ target: "shell.primary-nav",
17
+ kind: "link",
17
18
  surfaces: ["home"],
18
19
  order: 50,
19
- componentToken: "local.main.ui.surface-aware-menu-link-item",
20
20
  props: {
21
21
  label: "Home",
22
22
  surface: "home",
@@ -28,10 +28,10 @@ addPlacement({
28
28
 
29
29
  addPlacement({
30
30
  id: "shell-web.home.menu.settings",
31
- target: "shell-layout:primary-menu",
31
+ target: "shell.primary-nav",
32
+ kind: "link",
32
33
  surfaces: ["home"],
33
34
  order: 100,
34
- componentToken: "local.main.ui.surface-aware-menu-link-item",
35
35
  props: {
36
36
  label: "Settings",
37
37
  surface: "home",
@@ -42,10 +42,11 @@ addPlacement({
42
42
 
43
43
  addPlacement({
44
44
  id: "shell-web.home.settings.general",
45
- target: "home-settings:primary-menu",
45
+ target: "page.section-nav",
46
+ owner: "home-settings",
47
+ kind: "link",
46
48
  surfaces: ["home"],
47
49
  order: 100,
48
- componentToken: "local.main.ui.surface-aware-menu-link-item",
49
50
  props: {
50
51
  label: "General",
51
52
  surface: "home",
@@ -0,0 +1,108 @@
1
+ const placements = [];
2
+
3
+ function addPlacementTopology(value = {}) {
4
+ placements.push(value);
5
+ }
6
+
7
+ export { addPlacementTopology };
8
+ export default { placements };
9
+
10
+ const menuLinkRenderers = Object.freeze({
11
+ link: "local.main.ui.surface-aware-menu-link-item"
12
+ });
13
+
14
+ addPlacementTopology({
15
+ id: "shell.primary-nav",
16
+ description: "Primary top-level navigation for the current surface.",
17
+ surfaces: ["*"],
18
+ default: true,
19
+ variants: {
20
+ compact: {
21
+ outlet: "shell-layout:primary-menu",
22
+ renderers: menuLinkRenderers
23
+ },
24
+ medium: {
25
+ outlet: "shell-layout:primary-menu",
26
+ renderers: menuLinkRenderers
27
+ },
28
+ expanded: {
29
+ outlet: "shell-layout:primary-menu",
30
+ renderers: menuLinkRenderers
31
+ }
32
+ }
33
+ });
34
+
35
+ addPlacementTopology({
36
+ id: "shell.secondary-nav",
37
+ description: "Secondary navigation for the current surface.",
38
+ surfaces: ["*"],
39
+ variants: {
40
+ compact: {
41
+ outlet: "shell-layout:secondary-menu",
42
+ renderers: menuLinkRenderers
43
+ },
44
+ medium: {
45
+ outlet: "shell-layout:secondary-menu",
46
+ renderers: menuLinkRenderers
47
+ },
48
+ expanded: {
49
+ outlet: "shell-layout:secondary-menu",
50
+ renderers: menuLinkRenderers
51
+ }
52
+ }
53
+ });
54
+
55
+ addPlacementTopology({
56
+ id: "shell.identity",
57
+ description: "Current surface identity and switcher controls.",
58
+ surfaces: ["*"],
59
+ variants: {
60
+ compact: {
61
+ outlet: "shell-layout:top-left"
62
+ },
63
+ medium: {
64
+ outlet: "shell-layout:top-left"
65
+ },
66
+ expanded: {
67
+ outlet: "shell-layout:top-left"
68
+ }
69
+ }
70
+ });
71
+
72
+ addPlacementTopology({
73
+ id: "shell.status",
74
+ description: "Surface status, connection, and utility indicators.",
75
+ surfaces: ["*"],
76
+ variants: {
77
+ compact: {
78
+ outlet: "shell-layout:top-right"
79
+ },
80
+ medium: {
81
+ outlet: "shell-layout:top-right"
82
+ },
83
+ expanded: {
84
+ outlet: "shell-layout:top-right"
85
+ }
86
+ }
87
+ });
88
+
89
+ addPlacementTopology({
90
+ id: "page.section-nav",
91
+ owner: "home-settings",
92
+ description: "Navigation between child pages in the home settings section.",
93
+ surfaces: ["home"],
94
+ variants: {
95
+ compact: {
96
+ outlet: "home-settings:primary-menu",
97
+ renderers: menuLinkRenderers
98
+ },
99
+ medium: {
100
+ outlet: "home-settings:primary-menu",
101
+ renderers: menuLinkRenderers
102
+ },
103
+ expanded: {
104
+ outlet: "home-settings:primary-menu",
105
+ renderers: menuLinkRenderers
106
+ }
107
+ }
108
+ });
@@ -14,9 +14,9 @@ test("shell-web outlet menu widget exposes a configurable nested outlet", async
14
14
  );
15
15
 
16
16
  assert.match(source, /import \{ mdiCogOutline \} from "@mdi\/js";/);
17
- assert.match(source, /defaultLinkComponentToken: \{/);
18
17
  assert.match(source, /:target="props\.target"/);
19
- assert.match(source, /:default-link-component-token="props\.defaultLinkComponentToken"/);
18
+ assert.doesNotMatch(source, /defaultLinkComponentToken/);
19
+ assert.doesNotMatch(source, /default-link-component-token/);
20
20
  assert.match(source, /default: mdiCogOutline/);
21
21
  assert.doesNotMatch(source, /mdi-[a-z0-9-]+/);
22
22
  });
@@ -7,13 +7,13 @@ test("placement registry stores unique entries and builds immutable array", () =
7
7
 
8
8
  const firstAdded = registry.addPlacement({
9
9
  id: "example.profile",
10
- target: "shell-layout:top-right",
10
+ target: "shell.status",
11
11
  surfaces: ["*"],
12
12
  componentToken: "example.profile.component"
13
13
  });
14
14
  const duplicateAdded = registry.addPlacement({
15
15
  id: "example.profile",
16
- target: "shell-layout:top-right",
16
+ target: "shell.status",
17
17
  surfaces: ["*"],
18
18
  componentToken: "example.profile.component.duplicate"
19
19
  });
@@ -33,7 +33,7 @@ test("placement registry accepts explicit non-global surface ids", () => {
33
33
 
34
34
  const added = registry.addPlacement({
35
35
  id: "example.admin",
36
- target: "shell-layout:top-right",
36
+ target: "shell.status",
37
37
  surfaces: ["admin"],
38
38
  componentToken: "example.admin.component"
39
39
  });
@@ -32,6 +32,82 @@ function createPlacementContext() {
32
32
  };
33
33
  }
34
34
 
35
+ function semanticTopologyEntry({
36
+ id = "shell.primary-nav",
37
+ owner = "",
38
+ surfaces = ["*"],
39
+ compact = "shell-layout:primary-menu",
40
+ medium = "shell-layout:primary-menu",
41
+ expanded = "shell-layout:primary-menu",
42
+ compactRenderer = "component.menu",
43
+ mediumRenderer = "component.menu",
44
+ expandedRenderer = "component.menu"
45
+ } = {}) {
46
+ const createVariant = (outlet, renderer) => ({
47
+ outlet,
48
+ renderers: renderer ? { link: renderer } : {}
49
+ });
50
+ return {
51
+ id,
52
+ owner,
53
+ surfaces,
54
+ variants: {
55
+ compact: createVariant(compact, compactRenderer),
56
+ medium: createVariant(medium, mediumRenderer),
57
+ expanded: createVariant(expanded, expandedRenderer)
58
+ }
59
+ };
60
+ }
61
+
62
+ test("web placement runtime resolves semantic targets through topology variants", () => {
63
+ const app = createAppStub({
64
+ tokens: {
65
+ "component.bottom": () => null,
66
+ "component.menu": () => null
67
+ }
68
+ });
69
+
70
+ const runtime = createWebPlacementRuntime({ app });
71
+ runtime.replacePlacementTopology([
72
+ semanticTopologyEntry({
73
+ id: "shell.primary-nav",
74
+ compact: "shell-layout:bottom-nav",
75
+ medium: "shell-layout:primary-menu",
76
+ expanded: "shell-layout:primary-menu",
77
+ compactRenderer: "component.bottom",
78
+ mediumRenderer: "component.menu",
79
+ expandedRenderer: "component.menu"
80
+ })
81
+ ]);
82
+ runtime.replacePlacements([
83
+ definePlacement({
84
+ id: "test.home",
85
+ target: "shell.primary-nav",
86
+ kind: "link",
87
+ surfaces: ["app"],
88
+ order: 10
89
+ })
90
+ ]);
91
+ runtime.setContext(createPlacementContext());
92
+
93
+ const compactEntries = runtime.getPlacements({
94
+ surface: "app",
95
+ target: "shell-layout:bottom-nav",
96
+ layoutClass: "compact"
97
+ });
98
+ assert.deepEqual(compactEntries.map((entry) => entry.id), ["test.home"]);
99
+ assert.equal(compactEntries[0].semanticTarget, "shell.primary-nav");
100
+ assert.equal(compactEntries[0].componentToken, "component.bottom");
101
+
102
+ const mediumEntries = runtime.getPlacements({
103
+ surface: "app",
104
+ target: "shell-layout:primary-menu",
105
+ layoutClass: "medium"
106
+ });
107
+ assert.deepEqual(mediumEntries.map((entry) => entry.id), ["test.home"]);
108
+ assert.equal(mediumEntries[0].componentToken, "component.menu");
109
+ });
110
+
35
111
  test("web placement runtime filters by surface/host/position, resolves component tokens, and sorts by order", () => {
36
112
  const app = createAppStub({
37
113
  tokens: {
@@ -48,21 +124,24 @@ test("web placement runtime filters by surface/host/position, resolves component
48
124
  target: "shell-layout:primary-menu",
49
125
  surfaces: ["app"],
50
126
  order: 30,
51
- componentToken: "component.menu"
127
+ componentToken: "component.menu",
128
+ internal: true
52
129
  }),
53
130
  definePlacement({
54
131
  id: "test.profile",
55
132
  target: "shell-layout:top-right",
56
133
  surfaces: ["*"],
57
134
  order: 20,
58
- componentToken: "component.profile"
135
+ componentToken: "component.profile",
136
+ internal: true
59
137
  }),
60
138
  definePlacement({
61
139
  id: "test.alerts",
62
140
  target: "shell-layout:top-right",
63
141
  surfaces: ["app"],
64
142
  order: 10,
65
- componentToken: "component.alerts"
143
+ componentToken: "component.alerts",
144
+ internal: true
66
145
  })
67
146
  ]);
68
147
  runtime.setContext(createPlacementContext());
@@ -93,14 +172,16 @@ test("web placement runtime preserves source order when placements share the sam
93
172
  target: "home-settings:primary-menu",
94
173
  surfaces: ["app"],
95
174
  order: 155,
96
- componentToken: "component.beta"
175
+ componentToken: "component.beta",
176
+ internal: true
97
177
  }),
98
178
  definePlacement({
99
179
  id: "test.alpha",
100
180
  target: "home-settings:primary-menu",
101
181
  surfaces: ["app"],
102
182
  order: 155,
103
- componentToken: "component.alpha"
183
+ componentToken: "component.alpha",
184
+ internal: true
104
185
  })
105
186
  ]);
106
187
  runtime.setContext(createPlacementContext());
@@ -129,6 +210,7 @@ test("web placement runtime applies context contributors and placement when() pr
129
210
  target: "auth-profile-menu:primary-menu",
130
211
  surfaces: ["*"],
131
212
  componentToken: "component.guest",
213
+ internal: true,
132
214
  when: ({ auth }) => !Boolean(auth?.authenticated)
133
215
  }),
134
216
  definePlacement({
@@ -136,6 +218,7 @@ test("web placement runtime applies context contributors and placement when() pr
136
218
  target: "auth-profile-menu:primary-menu",
137
219
  surfaces: ["*"],
138
220
  componentToken: "component.authenticated",
221
+ internal: true,
139
222
  when: ({ auth }) => Boolean(auth?.authenticated)
140
223
  })
141
224
  ]);
@@ -165,6 +248,7 @@ test("web placement runtime uses runtime context and local context overrides con
165
248
  target: "auth-profile-menu:primary-menu",
166
249
  surfaces: ["*"],
167
250
  componentToken: "component.allowed",
251
+ internal: true,
168
252
  when: ({ auth }) => Boolean(auth?.authenticated)
169
253
  })
170
254
  ]);
@@ -224,13 +308,15 @@ test("web placement runtime rejects duplicate placement ids", () => {
224
308
  id: "dup.entry",
225
309
  target: "shell-layout:top-right",
226
310
  surfaces: ["*"],
227
- componentToken: "component.a"
311
+ componentToken: "component.a",
312
+ internal: true
228
313
  }),
229
314
  definePlacement({
230
315
  id: "dup.entry",
231
316
  target: "shell-layout:primary-menu",
232
317
  surfaces: ["*"],
233
- componentToken: "component.b"
318
+ componentToken: "component.b",
319
+ internal: true
234
320
  })
235
321
  ]);
236
322
  }, /Duplicate placement id/);
@@ -271,13 +357,15 @@ test("web placement runtime skips throwing component tokens and logs resolution
271
357
  id: "bad",
272
358
  target: "shell-layout:top-right",
273
359
  surfaces: ["*"],
274
- componentToken: "component.bad"
360
+ componentToken: "component.bad",
361
+ internal: true
275
362
  }),
276
363
  definePlacement({
277
364
  id: "good",
278
365
  target: "shell-layout:top-right",
279
366
  surfaces: ["*"],
280
- componentToken: "component.good"
367
+ componentToken: "component.good",
368
+ internal: true
281
369
  })
282
370
  ]);
283
371
 
@@ -323,7 +411,8 @@ test("web placement runtime clears failed token cache when placements are replac
323
411
  id: "toggle",
324
412
  target: "shell-layout:top-right",
325
413
  surfaces: ["*"],
326
- componentToken: "component.toggle"
414
+ componentToken: "component.toggle",
415
+ internal: true
327
416
  })
328
417
  ]);
329
418
 
@@ -339,7 +428,8 @@ test("web placement runtime clears failed token cache when placements are replac
339
428
  id: "toggle",
340
429
  target: "shell-layout:top-right",
341
430
  surfaces: ["*"],
342
- componentToken: "component.toggle"
431
+ componentToken: "component.toggle",
432
+ internal: true
343
433
  })
344
434
  ]);
345
435
 
@@ -362,21 +452,24 @@ test("web placement runtime follows explicit surface targeting without role indi
362
452
  target: "shell-layout:top-right",
363
453
  surfaces: ["*"],
364
454
  order: 10,
365
- componentToken: "component.global"
455
+ componentToken: "component.global",
456
+ internal: true
366
457
  }),
367
458
  definePlacement({
368
459
  id: "app.link",
369
460
  target: "shell-layout:top-right",
370
461
  surfaces: ["app"],
371
462
  order: 20,
372
- componentToken: "component.app"
463
+ componentToken: "component.app",
464
+ internal: true
373
465
  }),
374
466
  definePlacement({
375
467
  id: "admin.link",
376
468
  target: "shell-layout:top-right",
377
469
  surfaces: ["admin"],
378
470
  order: 30,
379
- componentToken: "component.admin"
471
+ componentToken: "component.admin",
472
+ internal: true
380
473
  })
381
474
  ]);
382
475
  runtime.setContext(createPlacementContext());
@@ -24,6 +24,18 @@ function readContributions(target = "") {
24
24
  : [];
25
25
  }
26
26
 
27
+ function readTopology(id = "", owner = "") {
28
+ const placements = descriptor?.metadata?.ui?.placements?.topology?.placements;
29
+ const normalizedId = String(id || "").trim();
30
+ const normalizedOwner = String(owner || "").trim();
31
+ return Array.isArray(placements)
32
+ ? placements.filter((entry) =>
33
+ String(entry?.id || "").trim() === normalizedId &&
34
+ String(entry?.owner || "").trim() === normalizedOwner
35
+ )
36
+ : [];
37
+ }
38
+
27
39
  function findFileMutation(id) {
28
40
  const files = descriptor?.mutations?.files;
29
41
  return Array.isArray(files)
@@ -35,7 +47,7 @@ test("shell-web home settings template exposes surface-derived settings outlets"
35
47
  const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "settings.vue"), "utf8");
36
48
 
37
49
  assert.match(source, /target="home-settings:primary-menu"/);
38
- assert.match(source, /default-link-component-token="local\.main\.ui\.surface-aware-menu-link-item"/);
50
+ assert.doesNotMatch(source, /default-link-component-token/);
39
51
  assert.match(source, /<RouterView \/>/);
40
52
  });
41
53
 
@@ -64,7 +76,8 @@ test("shell-web placement template seeds default Home and Settings drawer naviga
64
76
  const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "placement.js"), "utf8");
65
77
 
66
78
  assert.match(source, /id: "shell-web\.home\.menu\.home"/);
67
- assert.match(source, /target: "shell-layout:primary-menu"/);
79
+ assert.match(source, /target: "shell\.primary-nav"/);
80
+ assert.match(source, /kind: "link"/);
68
81
  assert.match(source, /surfaces: \["home"\]/);
69
82
  assert.match(source, /label: "Home"/);
70
83
  assert.match(source, /unscopedSuffix: "\/"/);
@@ -72,7 +85,8 @@ test("shell-web placement template seeds default Home and Settings drawer naviga
72
85
  assert.match(source, /label: "Settings"/);
73
86
  assert.match(source, /unscopedSuffix: "\/settings"/);
74
87
  assert.match(source, /id: "shell-web\.home\.settings\.general"/);
75
- assert.match(source, /target: "home-settings:primary-menu"/);
88
+ assert.match(source, /target: "page\.section-nav"/);
89
+ assert.match(source, /owner: "home-settings"/);
76
90
  assert.match(source, /label: "General"/);
77
91
  assert.match(source, /unscopedSuffix: "\/settings\/general"/);
78
92
  assert.doesNotMatch(source, /to: "\.\/general"/);
@@ -84,7 +98,6 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
84
98
  [
85
99
  {
86
100
  target: "home-settings:primary-menu",
87
- defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
88
101
  surfaces: ["home"],
89
102
  source: "templates/src/pages/home/settings.vue"
90
103
  }
@@ -92,41 +105,45 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
92
105
  );
93
106
 
94
107
  assert.deepEqual(
95
- readContributions("shell-layout:primary-menu"),
108
+ readContributions("shell.primary-nav"),
96
109
  [
97
110
  {
98
111
  id: "shell-web.home.menu.home",
99
- target: "shell-layout:primary-menu",
112
+ target: "shell.primary-nav",
113
+ kind: "link",
100
114
  surfaces: ["home"],
101
115
  order: 50,
102
- componentToken: "local.main.ui.surface-aware-menu-link-item",
103
116
  source: "templates/src/placement.js"
104
117
  },
105
118
  {
106
119
  id: "shell-web.home.menu.settings",
107
- target: "shell-layout:primary-menu",
120
+ target: "shell.primary-nav",
121
+ kind: "link",
108
122
  surfaces: ["home"],
109
123
  order: 100,
110
- componentToken: "local.main.ui.surface-aware-menu-link-item",
111
124
  source: "templates/src/placement.js"
112
125
  }
113
126
  ]
114
127
  );
115
128
 
116
129
  assert.deepEqual(
117
- readContributions("home-settings:primary-menu"),
130
+ readContributions("page.section-nav"),
118
131
  [
119
132
  {
120
133
  id: "shell-web.home.settings.general",
121
- target: "home-settings:primary-menu",
134
+ target: "page.section-nav",
135
+ owner: "home-settings",
136
+ kind: "link",
122
137
  surfaces: ["home"],
123
138
  order: 100,
124
- componentToken: "local.main.ui.surface-aware-menu-link-item",
125
139
  source: "templates/src/placement.js"
126
140
  }
127
141
  ]
128
142
  );
129
143
 
144
+ assert.equal(readTopology("shell.primary-nav").length, 1);
145
+ assert.equal(readTopology("page.section-nav", "home-settings").length, 1);
146
+
130
147
  assert.deepEqual(findFileMutation("shell-web-page-home-settings-shell"), {
131
148
  from: "templates/src/pages/home/settings.vue",
132
149
  toSurface: "home",