@jskit-ai/shell-web 0.1.32 → 0.1.34

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.
Files changed (31) hide show
  1. package/package.descriptor.mjs +111 -33
  2. package/package.json +8 -2
  3. package/src/client/components/ShellLayout.vue +11 -4
  4. package/src/client/components/ShellMenuLinkItem.vue +71 -0
  5. package/src/client/components/ShellOutlet.vue +10 -7
  6. package/src/client/components/ShellOutletMenuWidget.vue +57 -0
  7. package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +116 -0
  8. package/src/client/components/ShellTabLinkItem.vue +128 -0
  9. package/src/client/index.js +4 -0
  10. package/src/client/lib/menuIcons.js +210 -0
  11. package/src/client/placement/runtime.js +22 -22
  12. package/src/client/placement/validators.js +19 -49
  13. package/src/client/support/menuLinkTarget.js +97 -0
  14. package/src/server/support/localLinkItemScaffolds.js +80 -0
  15. package/templates/expected-existing/src/App.vue +13 -0
  16. package/templates/expected-existing/src/pages/home/index.vue +12 -0
  17. package/templates/expected-existing/src/pages/home.vue +13 -0
  18. package/templates/src/components/ShellLayout.vue +11 -4
  19. package/templates/src/components/menus/MenuLinkItem.vue +30 -0
  20. package/templates/src/components/menus/SurfaceAwareMenuLinkItem.vue +42 -0
  21. package/templates/src/components/menus/TabLinkItem.vue +34 -0
  22. package/templates/src/pages/home/settings/index.vue +8 -8
  23. package/templates/src/pages/home/settings.vue +4 -1
  24. package/test/bootstrapClaimContract.test.js +66 -0
  25. package/test/linkItemScaffoldContract.test.js +209 -0
  26. package/test/outletMenuWidgetContract.test.js +33 -0
  27. package/test/placementRegistry.test.js +17 -6
  28. package/test/placementRuntime.test.js +59 -44
  29. package/test/settingsPlacementContract.test.js +16 -5
  30. package/templates/src/pages/console/index.vue +0 -24
  31. package/templates/src/pages/console.vue +0 -20
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
6
+
7
+ function createLocalLinkItemDefinition({
8
+ token = "",
9
+ componentFile = "",
10
+ componentName = "",
11
+ templateFile = ""
12
+ } = {}) {
13
+ return Object.freeze({
14
+ token: String(token || "").trim(),
15
+ componentFile: String(componentFile || "").trim(),
16
+ componentName: String(componentName || "").trim(),
17
+ templateFile: String(templateFile || "").trim()
18
+ });
19
+ }
20
+
21
+ const LOCAL_LINK_ITEM_COMPONENT_DEFINITIONS = Object.freeze([
22
+ createLocalLinkItemDefinition({
23
+ token: "local.main.ui.menu-link-item",
24
+ componentFile: "src/components/menus/MenuLinkItem.vue",
25
+ componentName: "MenuLinkItem",
26
+ templateFile: "templates/src/components/menus/MenuLinkItem.vue"
27
+ }),
28
+ createLocalLinkItemDefinition({
29
+ token: "local.main.ui.surface-aware-menu-link-item",
30
+ componentFile: "src/components/menus/SurfaceAwareMenuLinkItem.vue",
31
+ componentName: "SurfaceAwareMenuLinkItem",
32
+ templateFile: "templates/src/components/menus/SurfaceAwareMenuLinkItem.vue"
33
+ }),
34
+ createLocalLinkItemDefinition({
35
+ token: "local.main.ui.tab-link-item",
36
+ componentFile: "src/components/menus/TabLinkItem.vue",
37
+ componentName: "TabLinkItem",
38
+ templateFile: "templates/src/components/menus/TabLinkItem.vue"
39
+ })
40
+ ]);
41
+
42
+ const LOCAL_LINK_ITEM_COMPONENT_TOKENS = Object.freeze(
43
+ LOCAL_LINK_ITEM_COMPONENT_DEFINITIONS.map((entry) => entry.token)
44
+ );
45
+
46
+ function findLocalLinkItemDefinition(componentToken = "") {
47
+ const normalizedComponentToken = String(componentToken || "").trim();
48
+ return LOCAL_LINK_ITEM_COMPONENT_DEFINITIONS.find((entry) => entry.token === normalizedComponentToken) || null;
49
+ }
50
+
51
+ function resolveLocalLinkItemDefinition(componentTokenOrDefinition = "") {
52
+ if (
53
+ componentTokenOrDefinition &&
54
+ typeof componentTokenOrDefinition === "object" &&
55
+ !Array.isArray(componentTokenOrDefinition)
56
+ ) {
57
+ return componentTokenOrDefinition;
58
+ }
59
+ return findLocalLinkItemDefinition(componentTokenOrDefinition);
60
+ }
61
+
62
+ function resolveLocalLinkItemTemplateAbsolutePath(componentTokenOrDefinition = "") {
63
+ const definition = resolveLocalLinkItemDefinition(componentTokenOrDefinition);
64
+ if (!definition) {
65
+ throw new Error(`Unknown local link-item scaffold: ${String(componentTokenOrDefinition || "").trim() || "(empty)"}.`);
66
+ }
67
+ return path.join(PACKAGE_DIR, definition.templateFile);
68
+ }
69
+
70
+ async function readLocalLinkItemComponentSource(componentTokenOrDefinition = "") {
71
+ return await readFile(resolveLocalLinkItemTemplateAbsolutePath(componentTokenOrDefinition), "utf8");
72
+ }
73
+
74
+ export {
75
+ LOCAL_LINK_ITEM_COMPONENT_DEFINITIONS,
76
+ LOCAL_LINK_ITEM_COMPONENT_TOKENS,
77
+ findLocalLinkItemDefinition,
78
+ readLocalLinkItemComponentSource,
79
+ resolveLocalLinkItemTemplateAbsolutePath
80
+ };
@@ -0,0 +1,13 @@
1
+ <template>
2
+ <v-app>
3
+ <v-main>
4
+ <v-container class="py-10 py-md-14">
5
+ <v-row justify="center">
6
+ <v-col cols="12" sm="11" md="10" lg="8" xl="7">
7
+ <RouterView />
8
+ </v-col>
9
+ </v-row>
10
+ </v-container>
11
+ </v-main>
12
+ </v-app>
13
+ </template>
@@ -0,0 +1,12 @@
1
+ <template>
2
+ <v-card class="mx-auto" max-width="960" rounded="xl" border elevation="1">
3
+ <v-card-item class="px-6 py-5 px-md-8 py-md-7">
4
+ <v-card-title class="text-h4">welcome</v-card-title>
5
+ <v-card-subtitle class="text-subtitle-1 mt-2">starter app</v-card-subtitle>
6
+ </v-card-item>
7
+ <v-divider />
8
+ <v-card-text class="px-6 py-5 px-md-8 py-md-7 text-body-1 text-medium-emphasis">
9
+ Start by adding packages and pages to this app.
10
+ </v-card-text>
11
+ </v-card>
12
+ </template>
@@ -0,0 +1,13 @@
1
+ <route lang="json">
2
+ {
3
+ "meta": {
4
+ "jskit": {
5
+ "surface": "home"
6
+ }
7
+ }
8
+ }
9
+ </route>
10
+
11
+ <template>
12
+ <RouterView />
13
+ </template>
@@ -32,7 +32,7 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
32
32
  <slot name="top-left" :surface="resolvedSurface">
33
33
  <div class="d-flex align-center ga-2">
34
34
  <v-chip color="primary" size="small" label>{{ resolvedSurfaceLabel }}</v-chip>
35
- <ShellOutlet host="shell-layout" position="top-left" />
35
+ <ShellOutlet target="shell-layout:top-left" />
36
36
  </div>
37
37
  </slot>
38
38
 
@@ -40,7 +40,7 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
40
40
 
41
41
  <slot name="top-right" :surface="resolvedSurface">
42
42
  <div class="d-flex align-center ga-2">
43
- <ShellOutlet host="shell-layout" position="top-right" />
43
+ <ShellOutlet target="shell-layout:top-right" />
44
44
  </div>
45
45
  </slot>
46
46
  </v-app-bar>
@@ -49,9 +49,16 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
49
49
  <slot name="menu" :surface="resolvedSurface">
50
50
  <v-list nav density="comfortable" class="pt-2">
51
51
  <v-list-subheader class="text-uppercase text-caption">{{ resolvedSurfaceLabel }}</v-list-subheader>
52
- <ShellOutlet host="shell-layout" position="primary-menu" default />
52
+ <ShellOutlet
53
+ target="shell-layout:primary-menu"
54
+ default
55
+ default-link-component-token="local.main.ui.surface-aware-menu-link-item"
56
+ />
53
57
  <v-divider class="my-2" />
54
- <ShellOutlet host="shell-layout" position="secondary-menu" />
58
+ <ShellOutlet
59
+ target="shell-layout:secondary-menu"
60
+ default-link-component-token="local.main.ui.surface-aware-menu-link-item"
61
+ />
55
62
  </v-list>
56
63
  </slot>
57
64
  </v-navigation-drawer>
@@ -0,0 +1,30 @@
1
+ <script setup>
2
+ import ShellMenuLinkItem from "@jskit-ai/shell-web/client/components/ShellMenuLinkItem";
3
+
4
+ const props = defineProps({
5
+ label: {
6
+ type: String,
7
+ default: ""
8
+ },
9
+ to: {
10
+ type: String,
11
+ default: ""
12
+ },
13
+ icon: {
14
+ type: String,
15
+ default: ""
16
+ },
17
+ disabled: {
18
+ type: Boolean,
19
+ default: false
20
+ },
21
+ exact: {
22
+ type: Boolean,
23
+ default: false
24
+ }
25
+ });
26
+ </script>
27
+
28
+ <template>
29
+ <ShellMenuLinkItem v-bind="props" />
30
+ </template>
@@ -0,0 +1,42 @@
1
+ <script setup>
2
+ import ShellSurfaceAwareMenuLinkItem from "@jskit-ai/shell-web/client/components/ShellSurfaceAwareMenuLinkItem";
3
+
4
+ const props = defineProps({
5
+ label: {
6
+ type: String,
7
+ default: ""
8
+ },
9
+ to: {
10
+ type: String,
11
+ default: ""
12
+ },
13
+ icon: {
14
+ type: String,
15
+ default: ""
16
+ },
17
+ surface: {
18
+ type: String,
19
+ default: ""
20
+ },
21
+ workspaceSuffix: {
22
+ type: String,
23
+ default: "/"
24
+ },
25
+ nonWorkspaceSuffix: {
26
+ type: String,
27
+ default: "/"
28
+ },
29
+ disabled: {
30
+ type: Boolean,
31
+ default: false
32
+ },
33
+ exact: {
34
+ type: Boolean,
35
+ default: false
36
+ }
37
+ });
38
+ </script>
39
+
40
+ <template>
41
+ <ShellSurfaceAwareMenuLinkItem v-bind="props" />
42
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup>
2
+ import ShellTabLinkItem from "@jskit-ai/shell-web/client/components/ShellTabLinkItem";
3
+
4
+ const props = defineProps({
5
+ label: {
6
+ type: String,
7
+ default: ""
8
+ },
9
+ to: {
10
+ type: String,
11
+ default: ""
12
+ },
13
+ surface: {
14
+ type: String,
15
+ default: ""
16
+ },
17
+ workspaceSuffix: {
18
+ type: String,
19
+ default: "/"
20
+ },
21
+ nonWorkspaceSuffix: {
22
+ type: String,
23
+ default: "/"
24
+ },
25
+ disabled: {
26
+ type: Boolean,
27
+ default: false
28
+ }
29
+ });
30
+ </script>
31
+
32
+ <template>
33
+ <ShellTabLinkItem v-bind="props" />
34
+ </template>
@@ -1,8 +1,8 @@
1
- <template>
2
- <v-sheet rounded="lg" border class="pa-6">
3
- <h2 class="text-h6 mb-2">Settings</h2>
4
- <p class="text-body-2 text-medium-emphasis mb-0">
5
- Select a settings section from the menu.
6
- </p>
7
- </v-sheet>
8
- </template>
1
+ <script setup>
2
+ // To redirect this settings shell to a child page, uncomment and edit the example below.
3
+ // definePage({
4
+ // redirect: (to) => `${to.path}/your_child_segment`
5
+ // });
6
+ </script>
7
+
8
+ <template />
@@ -15,7 +15,10 @@ 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 host="home-settings" position="primary-menu" />
18
+ <ShellOutlet
19
+ target="home-settings:primary-menu"
20
+ default-link-component-token="local.main.ui.surface-aware-menu-link-item"
21
+ />
19
22
  </v-list>
20
23
  </v-col>
21
24
 
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import descriptor from "../package.descriptor.mjs";
7
+
8
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
+ const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "base-shell");
11
+
12
+ function findFileMutation(id) {
13
+ const files = descriptor?.mutations?.files;
14
+ return Array.isArray(files)
15
+ ? files.find((entry) => String(entry?.id || "").trim() === id) || null
16
+ : null;
17
+ }
18
+
19
+ test("shell-web claims starter shell files as app-owned scaffolds", () => {
20
+ assert.deepEqual(findFileMutation("shell-web-app-root"), {
21
+ from: "templates/src/App.vue",
22
+ to: "src/App.vue",
23
+ ownership: "app",
24
+ expectedExistingFrom: "templates/expected-existing/src/App.vue",
25
+ reason: "Install full-width shell app root with shell-web error host and edge-to-edge layout.",
26
+ category: "shell-web",
27
+ id: "shell-web-app-root"
28
+ });
29
+ assert.deepEqual(findFileMutation("shell-web-page-home-wrapper"), {
30
+ from: "templates/src/pages/home.vue",
31
+ toSurface: "home",
32
+ toSurfaceRoot: true,
33
+ ownership: "app",
34
+ expectedExistingFrom: "templates/expected-existing/src/pages/home.vue",
35
+ reason: "Install shell-driven home wrapper page.",
36
+ category: "shell-web",
37
+ id: "shell-web-page-home-wrapper"
38
+ });
39
+ assert.deepEqual(findFileMutation("shell-web-page-home"), {
40
+ from: "templates/src/pages/home/index.vue",
41
+ toSurface: "home",
42
+ toSurfacePath: "index.vue",
43
+ ownership: "app",
44
+ expectedExistingFrom: "templates/expected-existing/src/pages/home/index.vue",
45
+ reason: "Install shell-driven home surface starter page.",
46
+ category: "shell-web",
47
+ id: "shell-web-page-home"
48
+ });
49
+ });
50
+
51
+ test("shell-web expected-existing starter files stay aligned with create-app base-shell", async () => {
52
+ const comparedFiles = [
53
+ "src/App.vue",
54
+ "src/pages/home.vue",
55
+ "src/pages/home/index.vue"
56
+ ];
57
+
58
+ for (const relativeFile of comparedFiles) {
59
+ const shellWebExpectedSource = await readFile(
60
+ path.join(PACKAGE_DIR, "templates", "expected-existing", relativeFile),
61
+ "utf8"
62
+ );
63
+ const createAppSource = await readFile(path.join(CREATE_APP_TEMPLATE_DIR, relativeFile), "utf8");
64
+ assert.equal(shellWebExpectedSource, createAppSource, relativeFile);
65
+ }
66
+ });
@@ -0,0 +1,209 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import descriptor from "../package.descriptor.mjs";
7
+ import {
8
+ LOCAL_LINK_ITEM_COMPONENT_DEFINITIONS,
9
+ findLocalLinkItemDefinition,
10
+ readLocalLinkItemComponentSource
11
+ } from "../src/server/support/localLinkItemScaffolds.js";
12
+
13
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
14
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
15
+
16
+ function findFileMutation(id) {
17
+ const files = descriptor?.mutations?.files;
18
+ return Array.isArray(files)
19
+ ? files.find((entry) => String(entry?.id || "").trim() === id) || null
20
+ : null;
21
+ }
22
+
23
+ function findTextMutation(id) {
24
+ const textMutations = descriptor?.mutations?.text;
25
+ return Array.isArray(textMutations)
26
+ ? textMutations.find((entry) => String(entry?.id || "").trim() === id) || null
27
+ : null;
28
+ }
29
+
30
+ test("shell-web exports generic link-item components for app-owned shell wrappers", async () => {
31
+ const clientIndexSource = await readFile(path.join(PACKAGE_DIR, "src", "client", "index.js"), "utf8");
32
+ assert.match(clientIndexSource, /ShellMenuLinkItem/);
33
+ assert.match(clientIndexSource, /ShellSurfaceAwareMenuLinkItem/);
34
+ assert.match(clientIndexSource, /ShellTabLinkItem/);
35
+
36
+ const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
37
+ assert.equal(
38
+ packageJson?.exports?.["./client/components/ShellMenuLinkItem"],
39
+ "./src/client/components/ShellMenuLinkItem.vue"
40
+ );
41
+ assert.equal(
42
+ packageJson?.exports?.["./client/components/ShellSurfaceAwareMenuLinkItem"],
43
+ "./src/client/components/ShellSurfaceAwareMenuLinkItem.vue"
44
+ );
45
+ assert.equal(
46
+ packageJson?.exports?.["./client/components/ShellTabLinkItem"],
47
+ "./src/client/components/ShellTabLinkItem.vue"
48
+ );
49
+ assert.equal(
50
+ packageJson?.exports?.["./server/support/localLinkItemScaffolds"],
51
+ "./src/server/support/localLinkItemScaffolds.js"
52
+ );
53
+ });
54
+
55
+ test("shell-web scaffolds app-owned local link-item wrappers under src/components/menus", async () => {
56
+ const menuWrapperSource = await readFile(
57
+ path.join(PACKAGE_DIR, "templates", "src", "components", "menus", "MenuLinkItem.vue"),
58
+ "utf8"
59
+ );
60
+ const surfaceAwareWrapperSource = await readFile(
61
+ path.join(PACKAGE_DIR, "templates", "src", "components", "menus", "SurfaceAwareMenuLinkItem.vue"),
62
+ "utf8"
63
+ );
64
+ const tabWrapperSource = await readFile(
65
+ path.join(PACKAGE_DIR, "templates", "src", "components", "menus", "TabLinkItem.vue"),
66
+ "utf8"
67
+ );
68
+
69
+ assert.match(menuWrapperSource, /@jskit-ai\/shell-web\/client\/components\/ShellMenuLinkItem/);
70
+ assert.match(surfaceAwareWrapperSource, /@jskit-ai\/shell-web\/client\/components\/ShellSurfaceAwareMenuLinkItem/);
71
+ assert.match(tabWrapperSource, /@jskit-ai\/shell-web\/client\/components\/ShellTabLinkItem/);
72
+ assert.match(menuWrapperSource, /exact:\s*\{/);
73
+ assert.match(surfaceAwareWrapperSource, /exact:\s*\{/);
74
+
75
+ assert.deepEqual(findFileMutation("shell-web-component-menu-link-item"), {
76
+ from: "templates/src/components/menus/MenuLinkItem.vue",
77
+ to: "src/components/menus/MenuLinkItem.vue",
78
+ ownership: "app",
79
+ reason: "Install app-owned shell menu link-item scaffold for local placement customization.",
80
+ category: "shell-web",
81
+ id: "shell-web-component-menu-link-item"
82
+ });
83
+ assert.deepEqual(findFileMutation("shell-web-component-surface-aware-menu-link-item"), {
84
+ from: "templates/src/components/menus/SurfaceAwareMenuLinkItem.vue",
85
+ to: "src/components/menus/SurfaceAwareMenuLinkItem.vue",
86
+ ownership: "app",
87
+ reason: "Install app-owned surface-aware shell menu link-item scaffold for local placement customization.",
88
+ category: "shell-web",
89
+ id: "shell-web-component-surface-aware-menu-link-item"
90
+ });
91
+ assert.deepEqual(findFileMutation("shell-web-component-tab-link-item"), {
92
+ from: "templates/src/components/menus/TabLinkItem.vue",
93
+ to: "src/components/menus/TabLinkItem.vue",
94
+ ownership: "app",
95
+ reason: "Install app-owned shell tab link-item scaffold for local placement customization.",
96
+ category: "shell-web",
97
+ id: "shell-web-component-tab-link-item"
98
+ });
99
+
100
+ assert.deepEqual(
101
+ LOCAL_LINK_ITEM_COMPONENT_DEFINITIONS.map((entry) => ({
102
+ token: entry.token,
103
+ componentFile: entry.componentFile,
104
+ componentName: entry.componentName,
105
+ templateFile: entry.templateFile
106
+ })),
107
+ [
108
+ {
109
+ token: "local.main.ui.menu-link-item",
110
+ componentFile: "src/components/menus/MenuLinkItem.vue",
111
+ componentName: "MenuLinkItem",
112
+ templateFile: "templates/src/components/menus/MenuLinkItem.vue"
113
+ },
114
+ {
115
+ token: "local.main.ui.surface-aware-menu-link-item",
116
+ componentFile: "src/components/menus/SurfaceAwareMenuLinkItem.vue",
117
+ componentName: "SurfaceAwareMenuLinkItem",
118
+ templateFile: "templates/src/components/menus/SurfaceAwareMenuLinkItem.vue"
119
+ },
120
+ {
121
+ token: "local.main.ui.tab-link-item",
122
+ componentFile: "src/components/menus/TabLinkItem.vue",
123
+ componentName: "TabLinkItem",
124
+ templateFile: "templates/src/components/menus/TabLinkItem.vue"
125
+ }
126
+ ]
127
+ );
128
+ assert.equal(findLocalLinkItemDefinition("local.main.ui.tab-link-item")?.componentName, "TabLinkItem");
129
+ assert.equal(await readLocalLinkItemComponentSource("local.main.ui.tab-link-item"), tabWrapperSource);
130
+ });
131
+
132
+ test("shell-web generic menu link items support exact route matching", async () => {
133
+ const shellMenuSource = await readFile(
134
+ path.join(PACKAGE_DIR, "src", "client", "components", "ShellMenuLinkItem.vue"),
135
+ "utf8"
136
+ );
137
+ const shellSurfaceAwareSource = await readFile(
138
+ path.join(PACKAGE_DIR, "src", "client", "components", "ShellSurfaceAwareMenuLinkItem.vue"),
139
+ "utf8"
140
+ );
141
+
142
+ assert.match(shellMenuSource, /exact:\s*\{/);
143
+ assert.match(shellMenuSource, /:exact="props\.exact"/);
144
+ assert.match(shellSurfaceAwareSource, /exact:\s*\{/);
145
+ assert.match(shellSurfaceAwareSource, /:exact="props\.exact"/);
146
+ });
147
+
148
+ test("shell-web binds the local link-item wrapper tokens into MainClientProvider", () => {
149
+ assert.deepEqual(findTextMutation("shell-web-main-client-provider-menu-link-item-import"), {
150
+ op: "append-text",
151
+ file: "packages/main/src/client/providers/MainClientProvider.js",
152
+ position: "top",
153
+ skipIfContains: "import MenuLinkItem from \"/src/components/menus/MenuLinkItem.vue\";",
154
+ value: "import MenuLinkItem from \"/src/components/menus/MenuLinkItem.vue\";\n",
155
+ reason: "Bind app-owned shell menu link-item scaffold into local main client provider imports.",
156
+ category: "shell-web",
157
+ id: "shell-web-main-client-provider-menu-link-item-import"
158
+ });
159
+ assert.deepEqual(findTextMutation("shell-web-main-client-provider-surface-aware-menu-link-item-import"), {
160
+ op: "append-text",
161
+ file: "packages/main/src/client/providers/MainClientProvider.js",
162
+ position: "top",
163
+ skipIfContains: "import SurfaceAwareMenuLinkItem from \"/src/components/menus/SurfaceAwareMenuLinkItem.vue\";",
164
+ value: "import SurfaceAwareMenuLinkItem from \"/src/components/menus/SurfaceAwareMenuLinkItem.vue\";\n",
165
+ reason: "Bind app-owned shell surface-aware menu link-item scaffold into local main client provider imports.",
166
+ category: "shell-web",
167
+ id: "shell-web-main-client-provider-surface-aware-menu-link-item-import"
168
+ });
169
+ assert.deepEqual(findTextMutation("shell-web-main-client-provider-tab-link-item-import"), {
170
+ op: "append-text",
171
+ file: "packages/main/src/client/providers/MainClientProvider.js",
172
+ position: "top",
173
+ skipIfContains: "import TabLinkItem from \"/src/components/menus/TabLinkItem.vue\";",
174
+ value: "import TabLinkItem from \"/src/components/menus/TabLinkItem.vue\";\n",
175
+ reason: "Bind app-owned shell tab link-item scaffold into local main client provider imports.",
176
+ category: "shell-web",
177
+ id: "shell-web-main-client-provider-tab-link-item-import"
178
+ });
179
+ assert.deepEqual(findTextMutation("shell-web-main-client-provider-menu-link-item-register"), {
180
+ op: "append-text",
181
+ file: "packages/main/src/client/providers/MainClientProvider.js",
182
+ position: "bottom",
183
+ skipIfContains: "registerMainClientComponent(\"local.main.ui.menu-link-item\", () => MenuLinkItem);",
184
+ value: "\nregisterMainClientComponent(\"local.main.ui.menu-link-item\", () => MenuLinkItem);\n",
185
+ reason: "Bind app-owned shell menu link-item token into local main client provider registry.",
186
+ category: "shell-web",
187
+ id: "shell-web-main-client-provider-menu-link-item-register"
188
+ });
189
+ assert.deepEqual(findTextMutation("shell-web-main-client-provider-surface-aware-menu-link-item-register"), {
190
+ op: "append-text",
191
+ file: "packages/main/src/client/providers/MainClientProvider.js",
192
+ position: "bottom",
193
+ skipIfContains: "registerMainClientComponent(\"local.main.ui.surface-aware-menu-link-item\", () => SurfaceAwareMenuLinkItem);",
194
+ value: "\nregisterMainClientComponent(\"local.main.ui.surface-aware-menu-link-item\", () => SurfaceAwareMenuLinkItem);\n",
195
+ reason: "Bind app-owned shell surface-aware menu link-item token into local main client provider registry.",
196
+ category: "shell-web",
197
+ id: "shell-web-main-client-provider-surface-aware-menu-link-item-register"
198
+ });
199
+ assert.deepEqual(findTextMutation("shell-web-main-client-provider-tab-link-item-register"), {
200
+ op: "append-text",
201
+ file: "packages/main/src/client/providers/MainClientProvider.js",
202
+ position: "bottom",
203
+ skipIfContains: "registerMainClientComponent(\"local.main.ui.tab-link-item\", () => TabLinkItem);",
204
+ value: "\nregisterMainClientComponent(\"local.main.ui.tab-link-item\", () => TabLinkItem);\n",
205
+ reason: "Bind app-owned shell tab link-item token into local main client provider registry.",
206
+ category: "shell-web",
207
+ id: "shell-web-main-client-provider-tab-link-item-register"
208
+ });
209
+ });
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
9
+
10
+ test("shell-web outlet menu widget exposes a configurable nested outlet", async () => {
11
+ const source = await readFile(
12
+ path.join(PACKAGE_DIR, "src", "client", "components", "ShellOutletMenuWidget.vue"),
13
+ "utf8"
14
+ );
15
+
16
+ assert.match(source, /import \{ mdiCogOutline \} from "@mdi\/js";/);
17
+ assert.match(source, /defaultLinkComponentToken: \{/);
18
+ assert.match(source, /:target="props\.target"/);
19
+ assert.match(source, /:default-link-component-token="props\.defaultLinkComponentToken"/);
20
+ assert.match(source, /default: mdiCogOutline/);
21
+ assert.doesNotMatch(source, /mdi-[a-z0-9-]+/);
22
+ });
23
+
24
+ test("shell-web exports the outlet menu widget from both client index and package exports", async () => {
25
+ const clientIndexSource = await readFile(path.join(PACKAGE_DIR, "src", "client", "index.js"), "utf8");
26
+ assert.match(clientIndexSource, /export \{ default as ShellOutletMenuWidget \} from "\.\/components\/ShellOutletMenuWidget\.vue";/);
27
+
28
+ const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
29
+ assert.equal(
30
+ packageJson?.exports?.["./client/components/ShellOutletMenuWidget"],
31
+ "./src/client/components/ShellOutletMenuWidget.vue"
32
+ );
33
+ });
@@ -7,15 +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
- host: "shell-layout",
11
- position: "top-right",
10
+ target: "shell-layout:top-right",
12
11
  surfaces: ["*"],
13
12
  componentToken: "example.profile.component"
14
13
  });
15
14
  const duplicateAdded = registry.addPlacement({
16
15
  id: "example.profile",
17
- host: "shell-layout",
18
- position: "top-right",
16
+ target: "shell-layout:top-right",
19
17
  surfaces: ["*"],
20
18
  componentToken: "example.profile.component.duplicate"
21
19
  });
@@ -35,11 +33,24 @@ test("placement registry accepts explicit non-global surface ids", () => {
35
33
 
36
34
  const added = registry.addPlacement({
37
35
  id: "example.admin",
38
- host: "shell-layout",
39
- position: "top-right",
36
+ target: "shell-layout:top-right",
40
37
  surfaces: ["admin"],
41
38
  componentToken: "example.admin.component"
42
39
  });
43
40
 
44
41
  assert.equal(added, true);
45
42
  });
43
+
44
+ test("placement registry rejects legacy split target fields", () => {
45
+ const registry = createPlacementRegistry();
46
+
47
+ assert.throws(
48
+ () => registry.addPlacement({
49
+ id: "example.legacy",
50
+ host: "shell-layout",
51
+ position: "top-right",
52
+ componentToken: "example.legacy.component"
53
+ }),
54
+ /must use "target" only/
55
+ );
56
+ });