@jskit-ai/shell-web 0.1.4

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 (41) hide show
  1. package/package.descriptor.mjs +165 -0
  2. package/package.json +23 -0
  3. package/src/client/components/ShellErrorHost.vue +208 -0
  4. package/src/client/components/ShellLayout.vue +191 -0
  5. package/src/client/components/ShellOutlet.vue +95 -0
  6. package/src/client/components/useShellLayout.js +93 -0
  7. package/src/client/error/index.js +2 -0
  8. package/src/client/error/inject.js +142 -0
  9. package/src/client/error/normalize.js +75 -0
  10. package/src/client/error/policy.js +50 -0
  11. package/src/client/error/presenters.js +89 -0
  12. package/src/client/error/runtime.js +418 -0
  13. package/src/client/error/store.js +176 -0
  14. package/src/client/error/tokens.js +14 -0
  15. package/src/client/index.js +17 -0
  16. package/src/client/navigation/linkResolver.js +117 -0
  17. package/src/client/placement/debug.js +52 -0
  18. package/src/client/placement/index.js +26 -0
  19. package/src/client/placement/inject.js +104 -0
  20. package/src/client/placement/pathname.js +14 -0
  21. package/src/client/placement/registry.js +41 -0
  22. package/src/client/placement/runtime.js +435 -0
  23. package/src/client/placement/surfaceContext.js +290 -0
  24. package/src/client/placement/tokens.js +29 -0
  25. package/src/client/placement/validators.js +210 -0
  26. package/src/client/providers/ShellWebClientProvider.js +352 -0
  27. package/templates/src/App.vue +11 -0
  28. package/templates/src/components/ShellLayout.vue +247 -0
  29. package/templates/src/error.js +13 -0
  30. package/templates/src/pages/console/index.vue +24 -0
  31. package/templates/src/pages/console.vue +20 -0
  32. package/templates/src/pages/home/index.vue +54 -0
  33. package/templates/src/pages/home.vue +20 -0
  34. package/templates/src/placement.js +12 -0
  35. package/test/errorRuntime.test.js +191 -0
  36. package/test/errorStore.test.js +26 -0
  37. package/test/linkResolver.test.js +112 -0
  38. package/test/placementRegistry.test.js +45 -0
  39. package/test/placementRuntime.test.js +374 -0
  40. package/test/provider.test.js +163 -0
  41. package/test/surfaceContext.test.js +184 -0
@@ -0,0 +1,165 @@
1
+ export default Object.freeze({
2
+ packageVersion: 1,
3
+ packageId: "@jskit-ai/shell-web",
4
+ version: "0.1.4",
5
+ description: "Web shell layout runtime with outlet-based placement contributions.",
6
+ dependsOn: [],
7
+ capabilities: {
8
+ provides: [
9
+ "runtime.web-placement",
10
+ "runtime.web-error"
11
+ ],
12
+ requires: []
13
+ },
14
+ runtime: {
15
+ server: {
16
+ providers: []
17
+ },
18
+ client: {
19
+ providers: [
20
+ {
21
+ entrypoint: "src/client/providers/ShellWebClientProvider.js",
22
+ export: "ShellWebClientProvider"
23
+ }
24
+ ]
25
+ }
26
+ },
27
+ metadata: {
28
+ apiSummary: {
29
+ surfaces: [
30
+ {
31
+ subpath: "./client",
32
+ summary: "Exports shell layout/outlet/error-host components and ShellWebClientProvider."
33
+ },
34
+ {
35
+ subpath: "./client/placement",
36
+ summary: "Exports placement registry, placement context access, runtime token, and surface path helpers."
37
+ },
38
+ {
39
+ subpath: "./client/error",
40
+ summary: "Exports default error policy and runtime error reporter hook."
41
+ }
42
+ ],
43
+ containerTokens: {
44
+ server: [],
45
+ client: [
46
+ "runtime.web-placement.client",
47
+ "runtime.web-error.client",
48
+ "runtime.web-error.presentation-store.client"
49
+ ]
50
+ }
51
+ },
52
+ ui: {
53
+ placements: {
54
+ outlets: [
55
+ {
56
+ host: "shell-layout",
57
+ position: "top-left",
58
+ surfaces: ["*"],
59
+ source: "src/client/components/ShellLayout.vue"
60
+ },
61
+ {
62
+ host: "shell-layout",
63
+ position: "top-right",
64
+ surfaces: ["*"],
65
+ source: "src/client/components/ShellLayout.vue"
66
+ },
67
+ {
68
+ host: "shell-layout",
69
+ position: "primary-menu",
70
+ surfaces: ["*"],
71
+ source: "src/client/components/ShellLayout.vue"
72
+ },
73
+ {
74
+ host: "shell-layout",
75
+ position: "secondary-menu",
76
+ surfaces: ["*"],
77
+ source: "src/client/components/ShellLayout.vue"
78
+ }
79
+ ],
80
+ contributions: []
81
+ }
82
+ }
83
+ },
84
+ mutations: {
85
+ dependencies: {
86
+ runtime: {
87
+ "@tanstack/vue-query": "^5.90.5",
88
+ "@jskit-ai/kernel": "0.1.4",
89
+ "vuetify": "^4.0.0"
90
+ },
91
+ dev: {}
92
+ },
93
+ packageJson: {
94
+ scripts: {
95
+ "dev:all": "vite",
96
+ "dev:home": "VITE_SURFACE=home vite",
97
+ "dev:console": "VITE_SURFACE=console vite"
98
+ }
99
+ },
100
+ procfile: {},
101
+ text: [],
102
+ files: [
103
+ {
104
+ from: "templates/src/App.vue",
105
+ to: "src/App.vue",
106
+ reason: "Install full-width shell app root with shell-web error host and edge-to-edge layout.",
107
+ category: "shell-web",
108
+ id: "shell-web-app-root"
109
+ },
110
+ {
111
+ from: "templates/src/components/ShellLayout.vue",
112
+ to: "src/components/ShellLayout.vue",
113
+ reason: "Install app-owned shell layout component so apps can customize structure and slots.",
114
+ category: "shell-web",
115
+ id: "shell-web-component-shell-layout"
116
+ },
117
+ {
118
+ from: "templates/src/error.js",
119
+ to: "src/error.js",
120
+ reason: "Install app-owned error runtime policy and presenter config scaffold.",
121
+ category: "shell-web",
122
+ id: "shell-web-error-config"
123
+ },
124
+ {
125
+ from: "templates/src/placement.js",
126
+ to: "src/placement.js",
127
+ reason: "Install app-owned placement registry scaffold used by shell-web placement runtime.",
128
+ category: "shell-web",
129
+ id: "shell-web-placement-registry"
130
+ },
131
+ {
132
+ from: "templates/src/pages/home.vue",
133
+ toSurface: "home",
134
+ toSurfaceRoot: true,
135
+ reason: "Install shell-driven home wrapper page.",
136
+ category: "shell-web",
137
+ id: "shell-web-page-home-wrapper"
138
+ },
139
+ {
140
+ from: "templates/src/pages/home/index.vue",
141
+ toSurface: "home",
142
+ toSurfacePath: "index.vue",
143
+ reason: "Install shell-driven home surface starter page.",
144
+ category: "shell-web",
145
+ id: "shell-web-page-home"
146
+ },
147
+ {
148
+ from: "templates/src/pages/console.vue",
149
+ toSurface: "console",
150
+ toSurfaceRoot: true,
151
+ reason: "Install shell-driven console wrapper page.",
152
+ category: "shell-web",
153
+ id: "shell-web-page-console-wrapper"
154
+ },
155
+ {
156
+ from: "templates/src/pages/console/index.vue",
157
+ toSurface: "console",
158
+ toSurfacePath: "index.vue",
159
+ reason: "Install shell-driven console page starter.",
160
+ category: "shell-web",
161
+ id: "shell-web-page-console"
162
+ }
163
+ ]
164
+ }
165
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@jskit-ai/shell-web",
3
+ "version": "0.1.4",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
8
+ "exports": {
9
+ "./client": "./src/client/index.js",
10
+ "./client/error": "./src/client/error/index.js",
11
+ "./client/placement": "./src/client/placement/index.js",
12
+ "./client/navigation/linkResolver": "./src/client/navigation/linkResolver.js",
13
+ "./client/components/ShellLayout": "./src/client/components/ShellLayout.vue",
14
+ "./client/components/ShellOutlet": "./src/client/components/ShellOutlet.vue",
15
+ "./client/components/ShellErrorHost": "./src/client/components/ShellErrorHost.vue",
16
+ "./client/providers/ShellWebClientProvider": "./src/client/providers/ShellWebClientProvider.js"
17
+ },
18
+ "dependencies": {
19
+ "@tanstack/vue-query": "^5.90.5",
20
+ "@jskit-ai/kernel": "0.1.4",
21
+ "vuetify": "^4.0.0"
22
+ }
23
+ }
@@ -0,0 +1,208 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import {
4
+ useShellWebErrorPresentationState,
5
+ useShellWebErrorRuntime
6
+ } from "../error/inject.js";
7
+
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);
17
+
18
+ function resolveSeverityColor(severity = "error") {
19
+ const normalized = String(severity || "error").trim().toLowerCase();
20
+ if (normalized === "info") {
21
+ return "info";
22
+ }
23
+ if (normalized === "success") {
24
+ return "success";
25
+ }
26
+ if (normalized === "warning") {
27
+ return "warning";
28
+ }
29
+ return "error";
30
+ }
31
+
32
+ function resolveTimeout(entry) {
33
+ if (!entry) {
34
+ return -1;
35
+ }
36
+ if (entry.persist) {
37
+ return -1;
38
+ }
39
+ return 5000;
40
+ }
41
+
42
+ function dismiss(entry) {
43
+ if (!entry || !entry.channel || !entry.id) {
44
+ return;
45
+ }
46
+
47
+ store.dismiss(entry.channel, entry.id);
48
+ }
49
+
50
+ function runAction(entry) {
51
+ if (!entry || !entry.action || typeof entry.action.handler !== "function") {
52
+ return;
53
+ }
54
+
55
+ try {
56
+ entry.action.handler(entry);
57
+ } catch (error) {
58
+ runtime.report({
59
+ source: "shell-web.error-host.action",
60
+ message: "Error action failed.",
61
+ cause: error,
62
+ severity: "error",
63
+ channel: "dialog"
64
+ });
65
+ }
66
+
67
+ if (entry.action.dismissOnRun !== false) {
68
+ dismiss(entry);
69
+ }
70
+ }
71
+
72
+ function onSnackbarModelValue(nextValue) {
73
+ if (nextValue === false && snackbarEntry.value) {
74
+ dismiss(snackbarEntry.value);
75
+ }
76
+ }
77
+
78
+ function onDialogModelValue(nextValue) {
79
+ if (nextValue === false && dialogEntry.value && dialogEntry.value.persist !== true) {
80
+ dismiss(dialogEntry.value);
81
+ }
82
+ }
83
+ </script>
84
+
85
+ <template>
86
+ <div class="shell-error-host" aria-live="polite">
87
+ <div v-if="bannerEntries.length > 0" class="shell-error-host__banners">
88
+ <div class="shell-error-host__banner-stack">
89
+ <v-alert
90
+ v-for="entry in bannerEntries"
91
+ :key="entry.id"
92
+ :type="resolveSeverityColor(entry.severity)"
93
+ variant="elevated"
94
+ density="comfortable"
95
+ rounded="lg"
96
+ border="start"
97
+ class="shell-error-host__banner"
98
+ closable
99
+ @click:close="dismiss(entry)"
100
+ >
101
+ <div class="d-flex align-center ga-3 flex-wrap">
102
+ <span>{{ entry.message }}</span>
103
+ <v-spacer />
104
+ <v-btn
105
+ v-if="entry.action"
106
+ variant="text"
107
+ size="small"
108
+ class="text-none"
109
+ @click="runAction(entry)"
110
+ >
111
+ {{ entry.action.label }}
112
+ </v-btn>
113
+ </div>
114
+ </v-alert>
115
+ </div>
116
+ </div>
117
+
118
+ <v-snackbar
119
+ :model-value="Boolean(snackbarEntry)"
120
+ location="bottom end"
121
+ :timeout="resolveTimeout(snackbarEntry)"
122
+ :color="resolveSeverityColor(snackbarEntry?.severity)"
123
+ @update:model-value="onSnackbarModelValue"
124
+ >
125
+ <span v-if="snackbarEntry">{{ snackbarEntry.message }}</span>
126
+
127
+ <template #actions>
128
+ <v-btn
129
+ v-if="snackbarEntry?.action"
130
+ variant="text"
131
+ size="small"
132
+ @click="runAction(snackbarEntry)"
133
+ >
134
+ {{ snackbarEntry.action.label }}
135
+ </v-btn>
136
+ <v-btn
137
+ v-if="snackbarEntry"
138
+ variant="text"
139
+ size="small"
140
+ @click="dismiss(snackbarEntry)"
141
+ >
142
+ Dismiss
143
+ </v-btn>
144
+ </template>
145
+ </v-snackbar>
146
+
147
+ <v-dialog
148
+ :model-value="Boolean(dialogEntry)"
149
+ max-width="560"
150
+ :persistent="Boolean(dialogEntry?.persist)"
151
+ @update:model-value="onDialogModelValue"
152
+ >
153
+ <v-card v-if="dialogEntry">
154
+ <v-card-title class="text-subtitle-1">Attention required</v-card-title>
155
+ <v-card-text>{{ dialogEntry.message }}</v-card-text>
156
+ <v-card-actions>
157
+ <v-spacer />
158
+ <v-btn
159
+ v-if="dialogEntry.action"
160
+ variant="text"
161
+ @click="runAction(dialogEntry)"
162
+ >
163
+ {{ dialogEntry.action.label }}
164
+ </v-btn>
165
+ <v-btn
166
+ color="primary"
167
+ variant="tonal"
168
+ @click="dismiss(dialogEntry)"
169
+ >
170
+ Close
171
+ </v-btn>
172
+ </v-card-actions>
173
+ </v-card>
174
+ </v-dialog>
175
+ </div>
176
+ </template>
177
+
178
+ <style scoped>
179
+ .shell-error-host__banners {
180
+ position: fixed;
181
+ top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset, 64px));
182
+ left: 0;
183
+ right: 0;
184
+ z-index: 2600;
185
+ pointer-events: none;
186
+ padding: 10px 12px;
187
+ }
188
+
189
+ .shell-error-host__banner-stack {
190
+ margin: 0 auto;
191
+ width: min(1120px, 100%);
192
+ display: flex;
193
+ flex-direction: column;
194
+ gap: 8px;
195
+ }
196
+
197
+ .shell-error-host__banner {
198
+ pointer-events: auto;
199
+ box-shadow: var(--v-shadow-4);
200
+ }
201
+
202
+ @media (max-width: 600px) {
203
+ .shell-error-host__banners {
204
+ top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset-mobile, 56px));
205
+ padding: 8px;
206
+ }
207
+ }
208
+ </style>
@@ -0,0 +1,191 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { useRoute } from "vue-router";
4
+ import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface";
5
+ import { useWebPlacementContext } from "../placement/inject.js";
6
+ import {
7
+ readPlacementSurfaceConfig,
8
+ resolveSurfaceDefinitionFromPlacementContext,
9
+ resolveSurfaceIdFromPlacementPathname
10
+ } from "../placement/surfaceContext.js";
11
+ import { useShellLayout } from "./useShellLayout.js";
12
+ import ShellOutlet from "./ShellOutlet.vue";
13
+
14
+ const props = defineProps({
15
+ surface: {
16
+ type: String,
17
+ default: ""
18
+ },
19
+ surfaceLabel: {
20
+ type: String,
21
+ default: ""
22
+ },
23
+ title: {
24
+ type: String,
25
+ default: ""
26
+ },
27
+ subtitle: {
28
+ type: String,
29
+ default: ""
30
+ },
31
+ topLeftActions: {
32
+ type: Array,
33
+ default: () => []
34
+ },
35
+ topRightActions: {
36
+ type: Array,
37
+ default: () => []
38
+ },
39
+ menuItems: {
40
+ type: Array,
41
+ default: () => []
42
+ }
43
+ });
44
+
45
+ let route = null;
46
+ try {
47
+ route = useRoute();
48
+ } catch {
49
+ route = null;
50
+ }
51
+ const { context: placementContext } = useWebPlacementContext();
52
+
53
+ function toSurfaceLabel(surfaceId = "") {
54
+ const normalizedSurfaceId = String(surfaceId || "").trim().toLowerCase();
55
+ if (!normalizedSurfaceId) {
56
+ return "Surface";
57
+ }
58
+
59
+ return normalizedSurfaceId
60
+ .split(/[^a-z0-9]+/g)
61
+ .filter(Boolean)
62
+ .map((segment) => `${segment.slice(0, 1).toUpperCase()}${segment.slice(1)}`)
63
+ .join(" ");
64
+ }
65
+
66
+ const resolvedSurface = computed(() => {
67
+ const explicitSurface = normalizeSurfaceId(props.surface);
68
+ if (explicitSurface) {
69
+ return explicitSurface;
70
+ }
71
+
72
+ const pathname =
73
+ String(route?.path || "").trim() ||
74
+ (typeof window === "object" && window?.location?.pathname ? String(window.location.pathname).trim() : "/");
75
+ const contextValue = placementContext?.value || null;
76
+ const resolvedSurfaceFromPath = resolveSurfaceIdFromPlacementPathname(contextValue, pathname);
77
+ if (resolvedSurfaceFromPath) {
78
+ return resolvedSurfaceFromPath;
79
+ }
80
+
81
+ const surfaceConfig = readPlacementSurfaceConfig(contextValue);
82
+ if (surfaceConfig.defaultSurfaceId) {
83
+ return surfaceConfig.defaultSurfaceId;
84
+ }
85
+
86
+ return "surface";
87
+ });
88
+
89
+ const resolvedSurfaceLabel = computed(() => {
90
+ const explicitLabel = String(props.surfaceLabel || "").trim();
91
+ if (explicitLabel) {
92
+ return explicitLabel;
93
+ }
94
+
95
+ const surfaceDefinition = resolveSurfaceDefinitionFromPlacementContext(
96
+ placementContext?.value || null,
97
+ resolvedSurface.value
98
+ );
99
+ const configuredLabel = String(surfaceDefinition?.label || "").trim();
100
+ if (configuredLabel) {
101
+ return configuredLabel;
102
+ }
103
+
104
+ return toSurfaceLabel(resolvedSurface.value);
105
+ });
106
+
107
+ const { drawerOpen, resolvedTopLeftActions, resolvedTopRightActions, resolvedMenuItems, toggleDrawer } = useShellLayout({
108
+ topLeftActions: computed(() => props.topLeftActions),
109
+ topRightActions: computed(() => props.topRightActions),
110
+ menuItems: computed(() => props.menuItems)
111
+ });
112
+ </script>
113
+
114
+ <template>
115
+ <v-layout class="shell-layout border rounded-lg overflow-hidden">
116
+ <v-app-bar border density="comfortable" elevation="0" class="bg-surface">
117
+ <v-app-bar-nav-icon aria-label="Toggle navigation menu" @click="toggleDrawer" />
118
+
119
+ <slot name="top-left" :actions="resolvedTopLeftActions" :surface="resolvedSurface">
120
+ <div class="d-flex align-center ga-2">
121
+ <v-btn
122
+ v-for="action in resolvedTopLeftActions"
123
+ :key="`top-left-${action.label}`"
124
+ :to="action.to || undefined"
125
+ :variant="action.variant"
126
+ :color="action.color"
127
+ size="small"
128
+ class="text-none"
129
+ >
130
+ {{ action.label }}
131
+ </v-btn>
132
+ <v-chip color="primary" size="small" label>{{ resolvedSurfaceLabel }}</v-chip>
133
+ <ShellOutlet host="shell-layout" position="top-left" />
134
+ </div>
135
+ </slot>
136
+
137
+ <v-spacer />
138
+
139
+ <slot name="top-right" :actions="resolvedTopRightActions" :surface="resolvedSurface">
140
+ <div class="d-flex align-center ga-2">
141
+ <v-btn
142
+ v-for="action in resolvedTopRightActions"
143
+ :key="`top-right-${action.label}`"
144
+ :to="action.to || undefined"
145
+ :variant="action.variant"
146
+ :color="action.color"
147
+ size="small"
148
+ class="text-none"
149
+ >
150
+ {{ action.label }}
151
+ </v-btn>
152
+ <ShellOutlet host="shell-layout" position="top-right" />
153
+ </div>
154
+ </slot>
155
+ </v-app-bar>
156
+
157
+ <v-navigation-drawer v-model="drawerOpen" border class="bg-surface" :width="248">
158
+ <slot name="menu" :items="resolvedMenuItems" :surface="resolvedSurface">
159
+ <v-list nav density="comfortable" class="pt-2">
160
+ <v-list-subheader class="text-uppercase text-caption">{{ resolvedSurfaceLabel }}</v-list-subheader>
161
+ <v-list-item
162
+ v-for="item in resolvedMenuItems"
163
+ :key="`menu-${item.label}`"
164
+ :title="item.label"
165
+ :to="item.to"
166
+ :prepend-icon="item.icon"
167
+ rounded="lg"
168
+ class="mb-1"
169
+ />
170
+ <ShellOutlet host="shell-layout" position="primary-menu" />
171
+ <v-divider class="my-2" />
172
+ <ShellOutlet host="shell-layout" position="secondary-menu" />
173
+ </v-list>
174
+ </slot>
175
+ </v-navigation-drawer>
176
+
177
+ <v-main class="bg-background">
178
+ <v-container fluid class="pa-4">
179
+ <h1 class="text-h5 mb-2">{{ title }}</h1>
180
+ <p class="text-body-2 text-medium-emphasis mb-4">{{ subtitle }}</p>
181
+ <slot />
182
+ </v-container>
183
+ </v-main>
184
+ </v-layout>
185
+ </template>
186
+
187
+ <style scoped>
188
+ .shell-layout {
189
+ min-height: 72vh;
190
+ }
191
+ </style>
@@ -0,0 +1,95 @@
1
+ <script setup>
2
+ import {
3
+ computed,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ ref
7
+ } from "vue";
8
+ import { useRoute } from "vue-router";
9
+ import { useWebPlacementContext, useWebPlacementRuntime } from "../placement/inject.js";
10
+ import { resolveRuntimePathname } from "../placement/pathname.js";
11
+ import {
12
+ readPlacementSurfaceConfig,
13
+ resolveSurfaceIdFromPlacementPathname
14
+ } from "../placement/surfaceContext.js";
15
+
16
+ const props = defineProps({
17
+ host: {
18
+ type: String,
19
+ default: ""
20
+ },
21
+ position: {
22
+ type: String,
23
+ default: ""
24
+ },
25
+ context: {
26
+ type: Object,
27
+ default: () => ({})
28
+ }
29
+ });
30
+
31
+ let route = null;
32
+ try {
33
+ route = useRoute();
34
+ } catch {
35
+ route = null;
36
+ }
37
+
38
+ const placementRuntime = useWebPlacementRuntime();
39
+ const { context: placementContext } = useWebPlacementContext();
40
+ const revision = ref(
41
+ typeof placementRuntime.getRevision === "function" ? placementRuntime.getRevision() : 0
42
+ );
43
+ let unsubscribe = null;
44
+
45
+ onMounted(() => {
46
+ if (typeof placementRuntime.subscribe !== "function") {
47
+ return;
48
+ }
49
+ unsubscribe = placementRuntime.subscribe((event) => {
50
+ const next = Number(event?.revision);
51
+ revision.value = Number.isInteger(next) ? next : revision.value + 1;
52
+ });
53
+ });
54
+
55
+ onBeforeUnmount(() => {
56
+ if (typeof unsubscribe === "function") {
57
+ unsubscribe();
58
+ unsubscribe = null;
59
+ }
60
+ });
61
+
62
+ const resolvedSurface = computed(() => {
63
+ const contextValue = placementContext?.value || null;
64
+ const pathname = resolveRuntimePathname(route?.path);
65
+ const surfaceFromPathname = resolveSurfaceIdFromPlacementPathname(contextValue, pathname);
66
+ if (surfaceFromPathname) {
67
+ return surfaceFromPathname;
68
+ }
69
+
70
+ const surfaceConfig = readPlacementSurfaceConfig(contextValue);
71
+ if (surfaceConfig.defaultSurfaceId) {
72
+ return surfaceConfig.defaultSurfaceId;
73
+ }
74
+ return "*";
75
+ });
76
+
77
+ const placements = computed(() => {
78
+ void revision.value;
79
+ return placementRuntime.getPlacements({
80
+ surface: resolvedSurface.value,
81
+ host: props.host,
82
+ position: props.position,
83
+ context: props.context
84
+ });
85
+ });
86
+ </script>
87
+
88
+ <template>
89
+ <component
90
+ :is="entry.component"
91
+ v-for="entry in placements"
92
+ :key="entry.id"
93
+ v-bind="entry.props"
94
+ />
95
+ </template>