@jskit-ai/shell-web 0.1.36 → 0.1.38

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.36",
4
+ version: "0.1.38",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
@@ -99,6 +99,14 @@ export default Object.freeze({
99
99
  order: 100,
100
100
  componentToken: "local.main.ui.surface-aware-menu-link-item",
101
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"
102
110
  }
103
111
  ]
104
112
  }
@@ -109,7 +117,7 @@ export default Object.freeze({
109
117
  runtime: {
110
118
  "@mdi/js": "^7.4.47",
111
119
  "@tanstack/vue-query": "^5.90.5",
112
- "@jskit-ai/kernel": "0.1.37",
120
+ "@jskit-ai/kernel": "0.1.39",
113
121
  "vuetify": "^4.0.0"
114
122
  },
115
123
  dev: {}
@@ -275,9 +283,18 @@ export default Object.freeze({
275
283
  toSurface: "home",
276
284
  toSurfacePath: "settings/index.vue",
277
285
  ownership: "app",
278
- reason: "Install shell-driven home settings landing page with a tiny browser-local shell preference example.",
286
+ reason: "Install shell-driven home settings redirect so the starter settings shell lands on a real child page.",
279
287
  category: "shell-web",
280
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"
281
298
  }
282
299
  ]
283
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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.37",
27
+ "@jskit-ai/kernel": "0.1.39",
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();
@@ -27,11 +27,11 @@ const props = defineProps({
27
27
  type: String,
28
28
  default: ""
29
29
  },
30
- workspaceSuffix: {
30
+ scopedSuffix: {
31
31
  type: String,
32
32
  default: "/"
33
33
  },
34
- nonWorkspaceSuffix: {
34
+ unscopedSuffix: {
35
35
  type: String,
36
36
  default: "/"
37
37
  },
@@ -61,8 +61,8 @@ const resolvedTo = computed(() => {
61
61
  surface: props.surface,
62
62
  currentSurfaceId: currentSurfaceId.value,
63
63
  placementContext: placementContext.value,
64
- workspaceSuffix: props.workspaceSuffix,
65
- nonWorkspaceSuffix: props.nonWorkspaceSuffix,
64
+ scopedSuffix: props.scopedSuffix,
65
+ unscopedSuffix: props.unscopedSuffix,
66
66
  routeParams: route.params || {},
67
67
  resolvePagePath(relativePath, options = {}) {
68
68
  return resolveShellLinkPath({
@@ -25,11 +25,11 @@ const props = defineProps({
25
25
  type: String,
26
26
  default: ""
27
27
  },
28
- workspaceSuffix: {
28
+ scopedSuffix: {
29
29
  type: String,
30
30
  default: "/"
31
31
  },
32
- nonWorkspaceSuffix: {
32
+ unscopedSuffix: {
33
33
  type: String,
34
34
  default: "/"
35
35
  },
@@ -55,8 +55,8 @@ const resolvedTo = computed(() => {
55
55
  surface: props.surface,
56
56
  currentSurfaceId: currentSurfaceId.value,
57
57
  placementContext: placementContext.value,
58
- workspaceSuffix: props.workspaceSuffix,
59
- nonWorkspaceSuffix: props.nonWorkspaceSuffix,
58
+ scopedSuffix: props.scopedSuffix,
59
+ unscopedSuffix: props.unscopedSuffix,
60
60
  routeParams: route.params || {},
61
61
  resolvePagePath(relativePath, options = {}) {
62
62
  return resolveShellLinkPath({
@@ -1,16 +1,14 @@
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,
8
10
  resolveSurfaceIdFromPlacementPathname
9
11
  } from "../placement/surfaceContext.js";
10
- import {
11
- readDrawerDefaultOpenPreference,
12
- writeDrawerDefaultOpenPreference
13
- } from "./shellLayoutDrawerPreference.js";
14
12
 
15
13
  function toSurfaceLabel(surfaceId = "") {
16
14
  const normalizedSurfaceId = String(surfaceId || "").trim().toLowerCase();
@@ -25,17 +23,9 @@ function toSurfaceLabel(surfaceId = "") {
25
23
  .join(" ");
26
24
  }
27
25
 
28
- const drawerDefaultOpen = ref(readDrawerDefaultOpenPreference());
29
- const drawerOpen = ref(drawerDefaultOpen.value);
30
-
31
- function setDrawerDefaultOpen(open) {
32
- const normalized = Boolean(open);
33
- drawerDefaultOpen.value = normalized;
34
- drawerOpen.value = normalized;
35
- writeDrawerDefaultOpenPreference(normalized);
36
- }
37
-
38
26
  function useShellLayoutState(props = {}) {
27
+ const shellLayoutStore = useShellLayoutStore();
28
+ const { drawerDefaultOpen, drawerOpen } = storeToRefs(shellLayoutStore);
39
29
  let route = null;
40
30
  try {
41
31
  route = useRoute();
@@ -46,7 +36,7 @@ function useShellLayoutState(props = {}) {
46
36
  const { context: placementContext } = useWebPlacementContext();
47
37
 
48
38
  function toggleDrawer() {
49
- drawerOpen.value = !drawerOpen.value;
39
+ shellLayoutStore.toggleDrawer();
50
40
  }
51
41
 
52
42
  const resolvedSurface = computed(() => {
@@ -93,7 +83,7 @@ function useShellLayoutState(props = {}) {
93
83
  return Object.freeze({
94
84
  drawerDefaultOpen,
95
85
  drawerOpen,
96
- setDrawerDefaultOpen,
86
+ setDrawerDefaultOpen: shellLayoutStore.setDrawerDefaultOpen,
97
87
  toggleDrawer,
98
88
  resolvedSurface,
99
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
+ });
@@ -59,15 +59,15 @@ function resolveMenuLinkTarget({
59
59
  surface = "",
60
60
  currentSurfaceId = "",
61
61
  placementContext = null,
62
- workspaceSuffix = "/",
63
- nonWorkspaceSuffix = "/",
62
+ scopedSuffix = "/",
63
+ unscopedSuffix = "/",
64
64
  routeParams = {},
65
65
  resolvePagePath = null
66
66
  } = {}) {
67
67
  const explicitTarget = normalizeText(to);
68
68
  const targetSurfaceId = resolveMenuLinkSurfaceId(surface, currentSurfaceId);
69
- const workspaceRequired = surfaceRequiresWorkspaceFromPlacementContext(placementContext, targetSurfaceId);
70
- const suffixTemplate = normalizeText(workspaceRequired ? workspaceSuffix : nonWorkspaceSuffix) || "/";
69
+ const scopedRouteRequired = surfaceRequiresWorkspaceFromPlacementContext(placementContext, targetSurfaceId);
70
+ const suffixTemplate = normalizeText(scopedRouteRequired ? scopedSuffix : unscopedSuffix) || "/";
71
71
  const interpolatedSuffix = interpolateBracketParams(suffixTemplate, routeParams);
72
72
  const resolvedSuffixTarget =
73
73
  typeof resolvePagePath === "function" &&
@@ -18,11 +18,11 @@ const props = defineProps({
18
18
  type: String,
19
19
  default: ""
20
20
  },
21
- workspaceSuffix: {
21
+ scopedSuffix: {
22
22
  type: String,
23
23
  default: "/"
24
24
  },
25
- nonWorkspaceSuffix: {
25
+ unscopedSuffix: {
26
26
  type: String,
27
27
  default: "/"
28
28
  },
@@ -14,11 +14,11 @@ const props = defineProps({
14
14
  type: String,
15
15
  default: ""
16
16
  },
17
- workspaceSuffix: {
17
+ scopedSuffix: {
18
18
  type: String,
19
19
  default: "/"
20
20
  },
21
- nonWorkspaceSuffix: {
21
+ unscopedSuffix: {
22
22
  type: String,
23
23
  default: "/"
24
24
  },
@@ -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,37 +1,5 @@
1
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
- }
2
+ definePage({
3
+ redirect: (to) => `${String(to.path || "").replace(/\/$/, "")}/general`
14
4
  });
15
5
  </script>
16
-
17
- <template>
18
- <section class="d-flex flex-column ga-4">
19
- <div>
20
- <h2 class="text-h6 mb-2">Shell settings</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>
@@ -20,8 +20,8 @@ addPlacement({
20
20
  props: {
21
21
  label: "Home",
22
22
  surface: "home",
23
- workspaceSuffix: "/",
24
- nonWorkspaceSuffix: "/",
23
+ scopedSuffix: "/",
24
+ unscopedSuffix: "/",
25
25
  exact: true
26
26
  }
27
27
  });
@@ -35,7 +35,22 @@ addPlacement({
35
35
  props: {
36
36
  label: "Settings",
37
37
  surface: "home",
38
- workspaceSuffix: "/settings",
39
- nonWorkspaceSuffix: "/settings"
38
+ scopedSuffix: "/settings",
39
+ unscopedSuffix: "/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
+ scopedSuffix: "/settings/general",
53
+ unscopedSuffix: "/settings/general",
54
+ to: "./general"
40
55
  }
41
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 () => {
@@ -39,9 +39,20 @@ test("shell-web home settings template exposes surface-derived settings outlets"
39
39
  assert.match(source, /<RouterView \/>/);
40
40
  });
41
41
 
42
- test("shell-web settings landing page exposes a tiny browser-local drawer preference", async () => {
42
+ test("shell-web settings landing page redirects to the starter child page", async () => {
43
43
  const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "settings", "index.vue"), "utf8");
44
44
 
45
+ assert.match(source, /definePage/);
46
+ assert.match(source, /redirect:/);
47
+ assert.match(source, /\/general/);
48
+ });
49
+
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
+
45
56
  assert.match(source, /useShellLayoutState/);
46
57
  assert.match(source, /drawerDefaultOpen/);
47
58
  assert.match(source, /setDrawerDefaultOpen/);
@@ -55,10 +66,15 @@ test("shell-web placement template seeds default Home and Settings drawer naviga
55
66
  assert.match(source, /id: "shell-web\.home\.menu\.home"/);
56
67
  assert.match(source, /target: "shell-layout:primary-menu"/);
57
68
  assert.match(source, /label: "Home"/);
58
- assert.match(source, /nonWorkspaceSuffix: "\/"/);
69
+ assert.match(source, /unscopedSuffix: "\/"/);
59
70
  assert.match(source, /id: "shell-web\.home\.menu\.settings"/);
60
71
  assert.match(source, /label: "Settings"/);
61
- assert.match(source, /nonWorkspaceSuffix: "\/settings"/);
72
+ assert.match(source, /unscopedSuffix: "\/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, /unscopedSuffix: "\/settings\/general"/);
77
+ assert.match(source, /to: "\.\/general"/);
62
78
  });
63
79
 
64
80
  test("shell-web descriptor metadata advertises home settings outlets, default drawer links, and installs the scaffold page", () => {
@@ -96,6 +112,20 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
96
112
  ]
97
113
  );
98
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
+
99
129
  assert.deepEqual(findFileMutation("shell-web-page-home-settings-shell"), {
100
130
  from: "templates/src/pages/home/settings.vue",
101
131
  toSurface: "home",
@@ -111,10 +141,20 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
111
141
  toSurface: "home",
112
142
  toSurfacePath: "settings/index.vue",
113
143
  ownership: "app",
114
- reason: "Install shell-driven home settings landing page with a tiny browser-local shell preference example.",
144
+ reason: "Install shell-driven home settings redirect so the starter settings shell lands on a real child page.",
115
145
  category: "shell-web",
116
146
  id: "shell-web-page-home-settings"
117
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
+ });
118
158
  });
119
159
 
120
160
  test("shell-web home starter page relies on drawer navigation instead of dead feature buttons", async () => {
@@ -1,10 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createPinia } from "pinia";
3
4
  import {
4
5
  SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY,
5
6
  readDrawerDefaultOpenPreference,
6
7
  writeDrawerDefaultOpenPreference
7
8
  } from "../src/client/composables/shellLayoutDrawerPreference.js";
9
+ import { useShellLayoutStore } from "../src/client/stores/useShellLayoutStore.js";
8
10
 
9
11
  function createStorage(initial = {}) {
10
12
  const values = new Map(Object.entries(initial));
@@ -48,3 +50,18 @@ test("writeDrawerDefaultOpenPreference persists normalized boolean strings", ()
48
50
  writeDrawerDefaultOpenPreference(true, { storage });
49
51
  assert.equal(storage.getItem(SHELL_LAYOUT_DRAWER_DEFAULT_OPEN_STORAGE_KEY), "true");
50
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
+ });