@jskit-ai/shell-web 0.1.35 → 0.1.37

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.35",
4
+ version: "0.1.37",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
@@ -83,7 +83,32 @@ export default Object.freeze({
83
83
  source: "templates/src/pages/home/settings.vue"
84
84
  }
85
85
  ],
86
- contributions: []
86
+ contributions: [
87
+ {
88
+ id: "shell-web.home.menu.home",
89
+ target: "shell-layout:primary-menu",
90
+ surfaces: ["*"],
91
+ order: 50,
92
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
93
+ source: "templates/src/placement.js"
94
+ },
95
+ {
96
+ id: "shell-web.home.menu.settings",
97
+ target: "shell-layout:primary-menu",
98
+ surfaces: ["home"],
99
+ order: 100,
100
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
101
+ source: "templates/src/placement.js"
102
+ },
103
+ {
104
+ id: "shell-web.home.settings.general",
105
+ target: "home-settings:primary-menu",
106
+ surfaces: ["home"],
107
+ order: 100,
108
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
109
+ source: "templates/src/placement.js"
110
+ }
111
+ ]
87
112
  }
88
113
  }
89
114
  },
@@ -92,7 +117,7 @@ export default Object.freeze({
92
117
  runtime: {
93
118
  "@mdi/js": "^7.4.47",
94
119
  "@tanstack/vue-query": "^5.90.5",
95
- "@jskit-ai/kernel": "0.1.36",
120
+ "@jskit-ai/kernel": "0.1.38",
96
121
  "vuetify": "^4.0.0"
97
122
  },
98
123
  dev: {}
@@ -258,9 +283,18 @@ export default Object.freeze({
258
283
  toSurface: "home",
259
284
  toSurfacePath: "settings/index.vue",
260
285
  ownership: "app",
261
- reason: "Install shell-driven home settings index stub scaffold for app-owned landing or redirect behavior.",
286
+ reason: "Install shell-driven home settings redirect so the starter settings shell lands on a real child page.",
262
287
  category: "shell-web",
263
288
  id: "shell-web-page-home-settings"
289
+ },
290
+ {
291
+ from: "templates/src/pages/home/settings/general/index.vue",
292
+ toSurface: "home",
293
+ toSurfacePath: "settings/general/index.vue",
294
+ ownership: "app",
295
+ reason: "Install shell-driven general settings child page with a tiny browser-local shell preference example.",
296
+ category: "shell-web",
297
+ id: "shell-web-page-home-settings-general"
264
298
  }
265
299
  ]
266
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -24,7 +24,8 @@
24
24
  "dependencies": {
25
25
  "@mdi/js": "^7.4.47",
26
26
  "@tanstack/vue-query": "^5.90.5",
27
- "@jskit-ai/kernel": "0.1.36",
27
+ "@jskit-ai/kernel": "0.1.38",
28
+ "pinia": "^3.0.4",
28
29
  "vuetify": "^4.0.0"
29
30
  }
30
31
  }
@@ -1,19 +1,16 @@
1
1
  <script setup>
2
2
  import { computed } from "vue";
3
3
  import {
4
- useShellWebErrorPresentationState,
5
4
  useShellWebErrorRuntime
6
5
  } from "../error/inject.js";
6
+ import { useShellErrorPresentationStore } from "../stores/useShellErrorPresentationStore.js";
7
7
 
8
8
  const runtime = useShellWebErrorRuntime();
9
- const {
10
- state,
11
- store
12
- } = useShellWebErrorPresentationState();
13
-
14
- const snackbarEntry = computed(() => state.value.channels.snackbar[0] || null);
15
- const bannerEntries = computed(() => state.value.channels.banner || []);
16
- const dialogEntry = computed(() => state.value.channels.dialog[0] || null);
9
+ const store = useShellErrorPresentationStore();
10
+
11
+ const snackbarEntry = computed(() => store.channels.snackbar[0] || null);
12
+ const bannerEntries = computed(() => store.channels.banner || []);
13
+ const dialogEntry = computed(() => store.channels.dialog[0] || null);
17
14
 
18
15
  function resolveSeverityColor(severity = "error") {
19
16
  const normalized = String(severity || "error").trim().toLowerCase();
@@ -0,0 +1,43 @@
1
+ const SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY = "jskit.shell-web.drawer-default-open";
2
+
3
+ function readDrawerDefaultOpenPreference({
4
+ storage = typeof window === "object" ? window?.localStorage : null
5
+ } = {}) {
6
+ if (!storage || typeof storage.getItem !== "function") {
7
+ return true;
8
+ }
9
+
10
+ try {
11
+ const storedValue = String(storage.getItem(SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY) || "").trim().toLowerCase();
12
+ if (storedValue === "false") {
13
+ return false;
14
+ }
15
+ if (storedValue === "true") {
16
+ return true;
17
+ }
18
+ } catch {
19
+ return true;
20
+ }
21
+
22
+ return true;
23
+ }
24
+
25
+ function writeDrawerDefaultOpenPreference(open, {
26
+ storage = typeof window === "object" ? window?.localStorage : null
27
+ } = {}) {
28
+ if (!storage || typeof storage.setItem !== "function") {
29
+ return;
30
+ }
31
+
32
+ try {
33
+ storage.setItem(SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY, open ? "true" : "false");
34
+ } catch {
35
+ // Ignore localStorage write failures in unsupported or locked-down environments.
36
+ }
37
+ }
38
+
39
+ export {
40
+ SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY,
41
+ readDrawerDefaultOpenPreference,
42
+ writeDrawerDefaultOpenPreference
43
+ };
@@ -1,7 +1,9 @@
1
- import { computed, ref } from "vue";
1
+ import { computed } from "vue";
2
+ import { storeToRefs } from "pinia";
2
3
  import { useRoute } from "vue-router";
3
4
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface";
4
5
  import { useWebPlacementContext } from "../placement/inject.js";
6
+ import { useShellLayoutStore } from "../stores/useShellLayoutStore.js";
5
7
  import {
6
8
  readPlacementSurfaceConfig,
7
9
  resolveSurfaceDefinitionFromPlacementContext,
@@ -22,6 +24,8 @@ function toSurfaceLabel(surfaceId = "") {
22
24
  }
23
25
 
24
26
  function useShellLayoutState(props = {}) {
27
+ const shellLayoutStore = useShellLayoutStore();
28
+ const { drawerDefaultOpen, drawerOpen } = storeToRefs(shellLayoutStore);
25
29
  let route = null;
26
30
  try {
27
31
  route = useRoute();
@@ -30,10 +34,9 @@ function useShellLayoutState(props = {}) {
30
34
  }
31
35
 
32
36
  const { context: placementContext } = useWebPlacementContext();
33
- const drawerOpen = ref(true);
34
37
 
35
38
  function toggleDrawer() {
36
- drawerOpen.value = !drawerOpen.value;
39
+ shellLayoutStore.toggleDrawer();
37
40
  }
38
41
 
39
42
  const resolvedSurface = computed(() => {
@@ -78,7 +81,9 @@ function useShellLayoutState(props = {}) {
78
81
  });
79
82
 
80
83
  return Object.freeze({
84
+ drawerDefaultOpen,
81
85
  drawerOpen,
86
+ setDrawerDefaultOpen: shellLayoutStore.setDrawerDefaultOpen,
82
87
  toggleDrawer,
83
88
  resolvedSurface,
84
89
  resolvedSurfaceLabel
@@ -1,2 +1,3 @@
1
1
  export { createDefaultErrorPolicy } from "./policy.js";
2
2
  export { useShellWebErrorRuntime } from "./inject.js";
3
+ export { useShellErrorPresentationStore } from "../stores/useShellErrorPresentationStore.js";
@@ -1,19 +1,7 @@
1
1
  import {
2
- inject,
3
- onBeforeUnmount,
4
- onMounted,
5
- shallowRef
2
+ inject
6
3
  } from "vue";
7
4
 
8
- const EMPTY_PRESENTATION_STATE = Object.freeze({
9
- revision: 0,
10
- channels: Object.freeze({
11
- snackbar: Object.freeze([]),
12
- banner: Object.freeze([]),
13
- dialog: Object.freeze([])
14
- })
15
- });
16
-
17
5
  const EMPTY_ERROR_RUNTIME = Object.freeze({
18
6
  report() {
19
7
  return Object.freeze({
@@ -60,24 +48,6 @@ const EMPTY_ERROR_RUNTIME = Object.freeze({
60
48
  }
61
49
  });
62
50
 
63
- const EMPTY_PRESENTATION_STORE = Object.freeze({
64
- getState() {
65
- return EMPTY_PRESENTATION_STATE;
66
- },
67
- subscribe() {
68
- return () => {};
69
- },
70
- present() {
71
- throw new Error("Shell web error presentation store is not available.");
72
- },
73
- dismiss() {
74
- return 0;
75
- },
76
- clear() {
77
- return 0;
78
- }
79
- });
80
-
81
51
  function useShellWebErrorRuntime({ required = false } = {}) {
82
52
  const runtime = inject("jskit.shell-web.runtime.web-error.client", null);
83
53
  if (runtime && typeof runtime.report === "function") {
@@ -91,48 +61,7 @@ function useShellWebErrorRuntime({ required = false } = {}) {
91
61
  return EMPTY_ERROR_RUNTIME;
92
62
  }
93
63
 
94
- function useShellWebErrorPresentationStore({ required = false } = {}) {
95
- const store = inject("jskit.shell-web.runtime.web-error.presentation-store.client", null);
96
- if (store && typeof store.getState === "function" && typeof store.subscribe === "function") {
97
- return store;
98
- }
99
-
100
- if (required) {
101
- throw new Error("Shell web error presentation store is not available in Vue injection context.");
102
- }
103
-
104
- return EMPTY_PRESENTATION_STORE;
105
- }
106
-
107
- function useShellWebErrorPresentationState({ required = false } = {}) {
108
- const store = useShellWebErrorPresentationStore({ required });
109
- const state = shallowRef(store.getState());
110
- let unsubscribe = null;
111
-
112
- onMounted(() => {
113
- unsubscribe = store.subscribe((nextState) => {
114
- state.value = nextState;
115
- });
116
- });
117
-
118
- onBeforeUnmount(() => {
119
- if (typeof unsubscribe === "function") {
120
- unsubscribe();
121
- unsubscribe = null;
122
- }
123
- });
124
-
125
- return Object.freeze({
126
- state,
127
- store
128
- });
129
- }
130
-
131
64
  export {
132
65
  EMPTY_ERROR_RUNTIME,
133
- EMPTY_PRESENTATION_STORE,
134
- EMPTY_PRESENTATION_STATE,
135
- useShellWebErrorRuntime,
136
- useShellWebErrorPresentationStore,
137
- useShellWebErrorPresentationState
66
+ useShellWebErrorRuntime
138
67
  };
@@ -0,0 +1,31 @@
1
+ const EMPTY_PRESENTATION_STATE = Object.freeze({
2
+ revision: 0,
3
+ channels: Object.freeze({
4
+ snackbar: Object.freeze([]),
5
+ banner: Object.freeze([]),
6
+ dialog: Object.freeze([])
7
+ })
8
+ });
9
+
10
+ const EMPTY_PRESENTATION_STORE = Object.freeze({
11
+ getState() {
12
+ return EMPTY_PRESENTATION_STATE;
13
+ },
14
+ subscribe() {
15
+ return () => {};
16
+ },
17
+ present() {
18
+ throw new Error("Shell web error presentation store is not available.");
19
+ },
20
+ dismiss() {
21
+ return 0;
22
+ },
23
+ clear() {
24
+ return 0;
25
+ }
26
+ });
27
+
28
+ export {
29
+ EMPTY_PRESENTATION_STATE,
30
+ EMPTY_PRESENTATION_STORE
31
+ };
@@ -14,6 +14,8 @@ export { default as ShellMenuLinkItem } from "./components/ShellMenuLinkItem.vue
14
14
  export { default as ShellSurfaceAwareMenuLinkItem } from "./components/ShellSurfaceAwareMenuLinkItem.vue";
15
15
  export { default as ShellTabLinkItem } from "./components/ShellTabLinkItem.vue";
16
16
  export { useShellLayoutState } from "./composables/useShellLayoutState.js";
17
+ export { useShellLayoutStore } from "./stores/useShellLayoutStore.js";
18
+ export { useShellErrorPresentationStore } from "./stores/useShellErrorPresentationStore.js";
17
19
 
18
20
  const clientProviders = Object.freeze([ShellWebClientProvider]);
19
21
 
@@ -22,6 +22,7 @@ import {
22
22
  createErrorPresentationStore
23
23
  } from "../error/store.js";
24
24
  import { createWebPlacementRuntime } from "../placement/runtime.js";
25
+ import { useShellErrorPresentationStore } from "../stores/useShellErrorPresentationStore.js";
25
26
  import { buildSurfaceConfigContext } from "../placement/surfaceContext.js";
26
27
 
27
28
  // Keep this constant for diagnostics, but keep import() below as a literal string so Vite can statically analyze it.
@@ -292,6 +293,12 @@ class ShellWebClientProvider {
292
293
  if (!vueApp || typeof vueApp.provide !== "function" || typeof vueApp.use !== "function") {
293
294
  return;
294
295
  }
296
+ const pinia = app.make("jskit.client.pinia");
297
+ if (!pinia) {
298
+ throw new Error("ShellWebClientProvider requires Pinia installed in the client app.");
299
+ }
300
+ const errorPresentationStore = app.make("runtime.web-error.presentation-store.client");
301
+ useShellErrorPresentationStore(pinia).attachRuntimeStore(errorPresentationStore);
295
302
 
296
303
  vueApp.use(VueQueryPlugin, {
297
304
  queryClient: app.make("shell.web.query-client")
@@ -300,7 +307,7 @@ class ShellWebClientProvider {
300
307
  vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
301
308
  vueApp.provide(
302
309
  "jskit.shell-web.runtime.web-error.presentation-store.client",
303
- app.make("runtime.web-error.presentation-store.client")
310
+ errorPresentationStore
304
311
  );
305
312
 
306
313
  installVueErrorBridge(vueApp, errorRuntime, logger);
@@ -0,0 +1,96 @@
1
+ import { computed, markRaw, shallowRef } from "vue";
2
+ import { defineStore } from "pinia";
3
+ import { EMPTY_PRESENTATION_STATE, EMPTY_PRESENTATION_STORE } from "../error/presentationDefaults.js";
4
+
5
+ function normalizePresentationState(nextState) {
6
+ if (!nextState || typeof nextState !== "object") {
7
+ return EMPTY_PRESENTATION_STATE;
8
+ }
9
+
10
+ return nextState;
11
+ }
12
+
13
+ function isPresentationRuntimeStore(value) {
14
+ return Boolean(
15
+ value &&
16
+ typeof value.getState === "function" &&
17
+ typeof value.subscribe === "function" &&
18
+ typeof value.present === "function" &&
19
+ typeof value.dismiss === "function" &&
20
+ typeof value.clear === "function"
21
+ );
22
+ }
23
+
24
+ export const useShellErrorPresentationStore = defineStore("jskit.shell-web.error-presentation", () => {
25
+ const runtimeStore = shallowRef(markRaw(EMPTY_PRESENTATION_STORE));
26
+ const presentationState = shallowRef(EMPTY_PRESENTATION_STATE);
27
+ let unsubscribe = null;
28
+
29
+ function setPresentationState(nextState) {
30
+ presentationState.value = normalizePresentationState(nextState);
31
+ return presentationState.value;
32
+ }
33
+
34
+ function detachRuntimeStore() {
35
+ if (typeof unsubscribe === "function") {
36
+ unsubscribe();
37
+ unsubscribe = null;
38
+ }
39
+ }
40
+
41
+ function attachRuntimeStore(nextRuntimeStore = EMPTY_PRESENTATION_STORE) {
42
+ if (!isPresentationRuntimeStore(nextRuntimeStore)) {
43
+ throw new TypeError("useShellErrorPresentationStore.attachRuntimeStore requires an error presentation store.");
44
+ }
45
+
46
+ if (runtimeStore.value === nextRuntimeStore) {
47
+ setPresentationState(nextRuntimeStore.getState());
48
+ return runtimeStore.value;
49
+ }
50
+
51
+ detachRuntimeStore();
52
+ runtimeStore.value = markRaw(nextRuntimeStore);
53
+ setPresentationState(nextRuntimeStore.getState());
54
+ unsubscribe = nextRuntimeStore.subscribe((nextState) => {
55
+ setPresentationState(nextState);
56
+ });
57
+
58
+ return runtimeStore.value;
59
+ }
60
+
61
+ function getState() {
62
+ return presentationState.value;
63
+ }
64
+
65
+ function subscribe(listener) {
66
+ return runtimeStore.value.subscribe(listener);
67
+ }
68
+
69
+ function present(channel, payload = {}) {
70
+ return runtimeStore.value.present(channel, payload);
71
+ }
72
+
73
+ function dismiss(channel, presentationId = "") {
74
+ return runtimeStore.value.dismiss(channel, presentationId);
75
+ }
76
+
77
+ function clear(channel = "") {
78
+ return runtimeStore.value.clear(channel);
79
+ }
80
+
81
+ const revision = computed(() => Number(presentationState.value.revision || 0));
82
+ const channels = computed(() => presentationState.value.channels || EMPTY_PRESENTATION_STATE.channels);
83
+
84
+ return {
85
+ runtimeStore,
86
+ presentationState,
87
+ revision,
88
+ channels,
89
+ attachRuntimeStore,
90
+ getState,
91
+ subscribe,
92
+ present,
93
+ dismiss,
94
+ clear
95
+ };
96
+ });
@@ -0,0 +1,34 @@
1
+ import { ref } from "vue";
2
+ import { defineStore } from "pinia";
3
+ import {
4
+ readDrawerDefaultOpenPreference,
5
+ writeDrawerDefaultOpenPreference
6
+ } from "../composables/shellLayoutDrawerPreference.js";
7
+
8
+ export const useShellLayoutStore = defineStore("jskit.shell-web.layout", () => {
9
+ const drawerDefaultOpen = ref(readDrawerDefaultOpenPreference());
10
+ const drawerOpen = ref(drawerDefaultOpen.value);
11
+
12
+ function setDrawerDefaultOpen(open) {
13
+ const normalized = Boolean(open);
14
+ drawerDefaultOpen.value = normalized;
15
+ drawerOpen.value = normalized;
16
+ writeDrawerDefaultOpenPreference(normalized);
17
+ }
18
+
19
+ function setDrawerOpen(open) {
20
+ drawerOpen.value = Boolean(open);
21
+ }
22
+
23
+ function toggleDrawer() {
24
+ drawerOpen.value = !drawerOpen.value;
25
+ }
26
+
27
+ return {
28
+ drawerDefaultOpen,
29
+ drawerOpen,
30
+ setDrawerDefaultOpen,
31
+ setDrawerOpen,
32
+ toggleDrawer
33
+ };
34
+ });
@@ -48,7 +48,7 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
48
48
  <v-navigation-drawer v-model="drawerOpen" border class="bg-surface" :width="248">
49
49
  <slot name="menu" :surface="resolvedSurface">
50
50
  <v-list nav density="comfortable" class="pt-2">
51
- <v-list-subheader class="text-uppercase text-caption">{{ resolvedSurfaceLabel }}</v-list-subheader>
51
+ <v-list-subheader class="text-uppercase text-caption">Navigation</v-list-subheader>
52
52
  <ShellOutlet
53
53
  target="shell-layout:primary-menu"
54
54
  default
@@ -45,11 +45,7 @@ const health = computed(() => {
45
45
  <p class="text-medium-emphasis mb-0">
46
46
  This is your primary landing page. Replace this content with your actual product home.
47
47
  </p>
48
- <div class="d-flex flex-wrap ga-3">
49
- <v-btn color="primary" variant="flat" to="/home/settings">Open settings</v-btn>
50
- <v-btn color="primary" variant="flat" to="/console">Open console surface</v-btn>
51
- <v-btn color="secondary" variant="outlined" to="/auth/signout">Sign out</v-btn>
52
- </div>
48
+ <p class="text-body-2 text-medium-emphasis mb-0">Use the navigation drawer to move around the shell.</p>
53
49
  </v-card-text>
54
50
  </v-card>
55
51
  </template>
@@ -0,0 +1,37 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { useShellLayoutState } from "@jskit-ai/shell-web/client/composables/useShellLayoutState";
4
+
5
+ const { drawerDefaultOpen, setDrawerDefaultOpen } = useShellLayoutState();
6
+
7
+ const drawerDefaultOpenModel = computed({
8
+ get() {
9
+ return drawerDefaultOpen.value;
10
+ },
11
+ set(value) {
12
+ setDrawerDefaultOpen(Boolean(value));
13
+ }
14
+ });
15
+ </script>
16
+
17
+ <template>
18
+ <section class="d-flex flex-column ga-4">
19
+ <div>
20
+ <h2 class="text-h6 mb-2">General</h2>
21
+ <p class="text-body-2 text-medium-emphasis mb-0">These starter settings live in this browser only.</p>
22
+ </div>
23
+
24
+ <v-switch
25
+ v-model="drawerDefaultOpenModel"
26
+ color="primary"
27
+ inset
28
+ hide-details="auto"
29
+ label="Open navigation drawer by default"
30
+ />
31
+
32
+ <p class="text-body-2 text-medium-emphasis mb-0">
33
+ This tiny example exists to prove that shell-level settings can work without auth or a database. Real apps will
34
+ usually replace it.
35
+ </p>
36
+ </section>
37
+ </template>
@@ -1,8 +1,5 @@
1
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
- // });
2
+ definePage({
3
+ redirect: (to) => `${String(to.path || "").replace(/\/$/, "")}/general`
4
+ });
6
5
  </script>
7
-
8
- <template />
@@ -10,3 +10,47 @@ export { addPlacement };
10
10
  export default function getPlacements() {
11
11
  return registry.build();
12
12
  }
13
+
14
+ addPlacement({
15
+ id: "shell-web.home.menu.home",
16
+ target: "shell-layout:primary-menu",
17
+ surfaces: ["*"],
18
+ order: 50,
19
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
20
+ props: {
21
+ label: "Home",
22
+ surface: "home",
23
+ workspaceSuffix: "/",
24
+ nonWorkspaceSuffix: "/",
25
+ exact: true
26
+ }
27
+ });
28
+
29
+ addPlacement({
30
+ id: "shell-web.home.menu.settings",
31
+ target: "shell-layout:primary-menu",
32
+ surfaces: ["home"],
33
+ order: 100,
34
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
35
+ props: {
36
+ label: "Settings",
37
+ surface: "home",
38
+ workspaceSuffix: "/settings",
39
+ nonWorkspaceSuffix: "/settings"
40
+ }
41
+ });
42
+
43
+ addPlacement({
44
+ id: "shell-web.home.settings.general",
45
+ target: "home-settings:primary-menu",
46
+ surfaces: ["home"],
47
+ order: 100,
48
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
49
+ props: {
50
+ label: "General",
51
+ surface: "home",
52
+ workspaceSuffix: "/settings/general",
53
+ nonWorkspaceSuffix: "/settings/general",
54
+ to: "./general"
55
+ }
56
+ });
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createPinia } from "pinia";
3
4
  import { createErrorPresentationStore } from "../src/client/error/store.js";
5
+ import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
4
6
 
5
7
  test("error presentation store keeps banner channel singleton", () => {
6
8
  const store = createErrorPresentationStore({ now: () => 1000 });
@@ -24,3 +26,16 @@ test("error presentation store still queues snackbar channel entries", () => {
24
26
  assert.equal(state.channels.snackbar[0].message, "One");
25
27
  assert.equal(state.channels.snackbar[1].message, "Two");
26
28
  });
29
+
30
+ test("shell error presentation Pinia store mirrors runtime presentation state", () => {
31
+ const pinia = createPinia();
32
+ const runtimeStore = createErrorPresentationStore({ now: () => 1000 });
33
+ const store = useShellErrorPresentationStore(pinia);
34
+
35
+ store.attachRuntimeStore(runtimeStore);
36
+ runtimeStore.present("snackbar", { message: "Hello" });
37
+
38
+ assert.equal(store.channels.snackbar.length, 1);
39
+ assert.equal(store.channels.snackbar[0].message, "Hello");
40
+ assert.equal(store.getState().channels.snackbar[0].message, "Hello");
41
+ });
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createPinia } from "pinia";
3
4
  import { ShellWebClientProvider } from "../src/client/providers/ShellWebClientProvider.js";
5
+ import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
4
6
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
5
7
 
6
8
  function setClientAppConfig(source = {}) {
@@ -17,9 +19,14 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
17
19
  const singletonInstances = new Map();
18
20
  const provided = [];
19
21
  const plugins = [];
22
+ const pinia = createPinia();
20
23
 
21
24
  const vueApp = {
22
- config: {},
25
+ config: {
26
+ globalProperties: {
27
+ $pinia: pinia
28
+ }
29
+ },
23
30
  use(plugin, options) {
24
31
  plugins.push({ plugin, options });
25
32
  return this;
@@ -33,6 +40,7 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
33
40
  singletons,
34
41
  provided,
35
42
  plugins,
43
+ pinia,
36
44
  vueApp,
37
45
  singleton(token, factory) {
38
46
  singletons.set(token, factory);
@@ -41,6 +49,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
41
49
  if (token === "jskit.client.vue.app") {
42
50
  return true;
43
51
  }
52
+ if (token === "jskit.client.pinia") {
53
+ return true;
54
+ }
44
55
  if (token === "jskit.client.surface.runtime") {
45
56
  return Boolean(surfaceRuntime);
46
57
  }
@@ -50,6 +61,9 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
50
61
  if (token === "jskit.client.vue.app") {
51
62
  return vueApp;
52
63
  }
64
+ if (token === "jskit.client.pinia") {
65
+ return pinia;
66
+ }
53
67
  if (token === "jskit.client.surface.runtime" && surfaceRuntime) {
54
68
  return surfaceRuntime;
55
69
  }
@@ -103,6 +117,12 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
103
117
  const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
104
118
  assert.equal(typeof errorStore.getState, "function");
105
119
  assert.equal(typeof errorStore.present, "function");
120
+
121
+ const errorPresentationStore = useShellErrorPresentationStore(app.pinia);
122
+ assert.equal(errorPresentationStore.revision, 0);
123
+ assert.equal(typeof errorPresentationStore.present, "function");
124
+ errorStore.present("banner", { message: "Hello" });
125
+ assert.equal(errorPresentationStore.channels.banner[0].message, "Hello");
106
126
  });
107
127
 
108
128
  test("shell web client provider resolves surface config from client app config", async () => {
@@ -8,10 +8,19 @@ import descriptor from "../package.descriptor.mjs";
8
8
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
9
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
10
 
11
- function readSettingsOutlets() {
11
+ function readOutlets(target = "") {
12
12
  const outlets = descriptor?.metadata?.ui?.placements?.outlets;
13
+ const normalizedTarget = String(target || "").trim();
13
14
  return Array.isArray(outlets)
14
- ? outlets.filter((entry) => String(entry?.target || "").trim() === "home-settings:primary-menu")
15
+ ? outlets.filter((entry) => String(entry?.target || "").trim() === normalizedTarget)
16
+ : [];
17
+ }
18
+
19
+ function readContributions(target = "") {
20
+ const contributions = descriptor?.metadata?.ui?.placements?.contributions;
21
+ const normalizedTarget = String(target || "").trim();
22
+ return Array.isArray(contributions)
23
+ ? contributions.filter((entry) => String(entry?.target || "").trim() === normalizedTarget)
15
24
  : [];
16
25
  }
17
26
 
@@ -30,16 +39,47 @@ test("shell-web home settings template exposes surface-derived settings outlets"
30
39
  assert.match(source, /<RouterView \/>/);
31
40
  });
32
41
 
33
- test("shell-web home settings index template is a simple developer-owned stub", async () => {
42
+ test("shell-web settings landing page redirects to the starter child page", async () => {
34
43
  const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "settings", "index.vue"), "utf8");
35
44
 
36
45
  assert.match(source, /definePage/);
37
- assert.match(source, /your_child_segment/);
46
+ assert.match(source, /redirect:/);
47
+ assert.match(source, /\/general/);
38
48
  });
39
49
 
40
- test("shell-web descriptor metadata advertises home settings outlets and installs the scaffold page", () => {
50
+ test("shell-web settings general child page exposes a tiny browser-local drawer preference", async () => {
51
+ const source = await readFile(
52
+ path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "settings", "general", "index.vue"),
53
+ "utf8"
54
+ );
55
+
56
+ assert.match(source, /useShellLayoutState/);
57
+ assert.match(source, /drawerDefaultOpen/);
58
+ assert.match(source, /setDrawerDefaultOpen/);
59
+ assert.match(source, /Open navigation drawer by default/);
60
+ assert.match(source, /live in this browser only/);
61
+ });
62
+
63
+ test("shell-web placement template seeds default Home and Settings drawer navigation", async () => {
64
+ const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "placement.js"), "utf8");
65
+
66
+ assert.match(source, /id: "shell-web\.home\.menu\.home"/);
67
+ assert.match(source, /target: "shell-layout:primary-menu"/);
68
+ assert.match(source, /label: "Home"/);
69
+ assert.match(source, /nonWorkspaceSuffix: "\/"/);
70
+ assert.match(source, /id: "shell-web\.home\.menu\.settings"/);
71
+ assert.match(source, /label: "Settings"/);
72
+ assert.match(source, /nonWorkspaceSuffix: "\/settings"/);
73
+ assert.match(source, /id: "shell-web\.home\.settings\.general"/);
74
+ assert.match(source, /target: "home-settings:primary-menu"/);
75
+ assert.match(source, /label: "General"/);
76
+ assert.match(source, /nonWorkspaceSuffix: "\/settings\/general"/);
77
+ assert.match(source, /to: "\.\/general"/);
78
+ });
79
+
80
+ test("shell-web descriptor metadata advertises home settings outlets, default drawer links, and installs the scaffold page", () => {
41
81
  assert.deepEqual(
42
- readSettingsOutlets(),
82
+ readOutlets("home-settings:primary-menu"),
43
83
  [
44
84
  {
45
85
  target: "home-settings:primary-menu",
@@ -50,6 +90,42 @@ test("shell-web descriptor metadata advertises home settings outlets and install
50
90
  ]
51
91
  );
52
92
 
93
+ assert.deepEqual(
94
+ readContributions("shell-layout:primary-menu"),
95
+ [
96
+ {
97
+ id: "shell-web.home.menu.home",
98
+ target: "shell-layout:primary-menu",
99
+ surfaces: ["*"],
100
+ order: 50,
101
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
102
+ source: "templates/src/placement.js"
103
+ },
104
+ {
105
+ id: "shell-web.home.menu.settings",
106
+ target: "shell-layout:primary-menu",
107
+ surfaces: ["home"],
108
+ order: 100,
109
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
110
+ source: "templates/src/placement.js"
111
+ }
112
+ ]
113
+ );
114
+
115
+ assert.deepEqual(
116
+ readContributions("home-settings:primary-menu"),
117
+ [
118
+ {
119
+ id: "shell-web.home.settings.general",
120
+ target: "home-settings:primary-menu",
121
+ surfaces: ["home"],
122
+ order: 100,
123
+ componentToken: "local.main.ui.surface-aware-menu-link-item",
124
+ source: "templates/src/placement.js"
125
+ }
126
+ ]
127
+ );
128
+
53
129
  assert.deepEqual(findFileMutation("shell-web-page-home-settings-shell"), {
54
130
  from: "templates/src/pages/home/settings.vue",
55
131
  toSurface: "home",
@@ -65,14 +141,27 @@ test("shell-web descriptor metadata advertises home settings outlets and install
65
141
  toSurface: "home",
66
142
  toSurfacePath: "settings/index.vue",
67
143
  ownership: "app",
68
- reason: "Install shell-driven home settings index stub scaffold for app-owned landing or redirect behavior.",
144
+ reason: "Install shell-driven home settings redirect so the starter settings shell lands on a real child page.",
69
145
  category: "shell-web",
70
146
  id: "shell-web-page-home-settings"
71
147
  });
148
+
149
+ assert.deepEqual(findFileMutation("shell-web-page-home-settings-general"), {
150
+ from: "templates/src/pages/home/settings/general/index.vue",
151
+ toSurface: "home",
152
+ toSurfacePath: "settings/general/index.vue",
153
+ ownership: "app",
154
+ reason: "Install shell-driven general settings child page with a tiny browser-local shell preference example.",
155
+ category: "shell-web",
156
+ id: "shell-web-page-home-settings-general"
157
+ });
72
158
  });
73
159
 
74
- test("shell-web home starter page links to the home settings scaffold", async () => {
160
+ test("shell-web home starter page relies on drawer navigation instead of dead feature buttons", async () => {
75
161
  const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "index.vue"), "utf8");
76
162
 
77
- assert.match(source, /to="\/home\/settings"/);
163
+ assert.match(source, /Use the navigation drawer to move around the shell\./);
164
+ assert.doesNotMatch(source, /\/home\/settings/);
165
+ assert.doesNotMatch(source, /\/console/);
166
+ assert.doesNotMatch(source, /\/auth\/signout/);
78
167
  });
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createPinia } from "pinia";
4
+ import {
5
+ SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY,
6
+ readDrawerDefaultOpenPreference,
7
+ writeDrawerDefaultOpenPreference
8
+ } from "../src/client/composables/shellLayoutDrawerPreference.js";
9
+ import { useShellLayoutStore } from "../src/client/stores/useShellLayoutStore.js";
10
+
11
+ function createStorage(initial = {}) {
12
+ const values = new Map(Object.entries(initial));
13
+ return {
14
+ getItem(key) {
15
+ return values.has(key) ? values.get(key) : null;
16
+ },
17
+ setItem(key, value) {
18
+ values.set(key, String(value));
19
+ }
20
+ };
21
+ }
22
+
23
+ test("readDrawerDefaultOpenPreference defaults to true when storage is missing or empty", () => {
24
+ assert.equal(readDrawerDefaultOpenPreference({ storage: null }), true);
25
+ assert.equal(readDrawerDefaultOpenPreference({ storage: createStorage() }), true);
26
+ });
27
+
28
+ test("readDrawerDefaultOpenPreference reads explicit stored booleans", () => {
29
+ assert.equal(
30
+ readDrawerDefaultOpenPreference({
31
+ storage: createStorage({ [SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY]: "false" })
32
+ }),
33
+ false
34
+ );
35
+
36
+ assert.equal(
37
+ readDrawerDefaultOpenPreference({
38
+ storage: createStorage({ [SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY]: "true" })
39
+ }),
40
+ true
41
+ );
42
+ });
43
+
44
+ test("writeDrawerDefaultOpenPreference persists normalized boolean strings", () => {
45
+ const storage = createStorage();
46
+
47
+ writeDrawerDefaultOpenPreference(false, { storage });
48
+ assert.equal(storage.getItem(SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY), "false");
49
+
50
+ writeDrawerDefaultOpenPreference(true, { storage });
51
+ assert.equal(storage.getItem(SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY), "true");
52
+ });
53
+
54
+ test("shell layout store keeps drawer state and default preference in sync", () => {
55
+ const pinia = createPinia();
56
+ const store = useShellLayoutStore(pinia);
57
+
58
+ store.setDrawerDefaultOpen(false);
59
+ assert.equal(store.drawerDefaultOpen, false);
60
+ assert.equal(store.drawerOpen, false);
61
+
62
+ store.toggleDrawer();
63
+ assert.equal(store.drawerOpen, true);
64
+
65
+ store.setDrawerOpen(false);
66
+ assert.equal(store.drawerOpen, false);
67
+ });