@jskit-ai/shell-web 0.1.64 → 0.1.66

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 (37) hide show
  1. package/package.descriptor.mjs +200 -16
  2. package/package.json +8 -7
  3. package/src/client/components/ShellErrorHost.vue +88 -15
  4. package/src/client/components/ShellLayout.vue +551 -50
  5. package/src/client/components/ShellOutlet.vue +34 -4
  6. package/src/client/components/ShellOutletMenuWidget.vue +1 -8
  7. package/src/client/components/ShellRouteTransition.vue +480 -0
  8. package/src/client/components/ShellTabLinkItem.vue +22 -6
  9. package/src/client/composables/useShellLayoutState.js +12 -1
  10. package/src/client/error/normalize.js +17 -0
  11. package/src/client/error/policy.js +25 -11
  12. package/src/client/error/runtime.js +2 -0
  13. package/src/client/index.js +1 -0
  14. package/src/client/placement/index.js +5 -0
  15. package/src/client/placement/runtime.js +149 -16
  16. package/src/client/placement/validators.js +36 -8
  17. package/src/client/providers/ShellWebClientProvider.js +189 -24
  18. package/src/client/stores/useShellLayoutStore.js +21 -1
  19. package/src/test/adaptiveShellSmoke.js +121 -0
  20. package/templates/expected-existing/src/pages/home/index.vue +40 -10
  21. package/templates/src/components/ShellLayout.vue +10 -90
  22. package/templates/src/components/menus/TabLinkItem.vue +4 -0
  23. package/templates/src/error.js +7 -1
  24. package/templates/src/pages/home/index.vue +64 -23
  25. package/templates/src/pages/home/settings/general/index.vue +12 -9
  26. package/templates/src/pages/home/settings.vue +68 -24
  27. package/templates/src/placement.js +7 -6
  28. package/templates/src/placementTopology.js +149 -0
  29. package/templates/tests/e2e/adaptive-shell.spec.ts +4 -0
  30. package/test/errorRuntime.test.js +42 -0
  31. package/test/linkItemScaffoldContract.test.js +9 -2
  32. package/test/outletMenuWidgetContract.test.js +2 -2
  33. package/test/placementRegistry.test.js +3 -3
  34. package/test/placementRuntime.test.js +144 -14
  35. package/test/provider.test.js +97 -5
  36. package/test/settingsPlacementContract.test.js +234 -20
  37. package/test/useShellLayoutState.test.js +19 -0
@@ -0,0 +1,149 @@
1
+ const placements = [];
2
+
3
+ function addPlacementTopology(value = {}) {
4
+ placements.push(value);
5
+ }
6
+
7
+ export { addPlacementTopology };
8
+ export default { placements };
9
+
10
+ const menuLinkRenderers = Object.freeze({
11
+ link: "local.main.ui.surface-aware-menu-link-item"
12
+ });
13
+
14
+ const bottomNavLinkRenderers = Object.freeze({
15
+ link: "local.main.ui.tab-link-item"
16
+ });
17
+
18
+ addPlacementTopology({
19
+ id: "shell.primary-nav",
20
+ description: "Primary top-level navigation for the current surface.",
21
+ surfaces: ["*"],
22
+ default: true,
23
+ variants: {
24
+ compact: {
25
+ outlet: "shell-layout:primary-bottom-nav",
26
+ renderers: bottomNavLinkRenderers
27
+ },
28
+ medium: {
29
+ outlet: "shell-layout:primary-menu",
30
+ renderers: menuLinkRenderers
31
+ },
32
+ expanded: {
33
+ outlet: "shell-layout:primary-menu",
34
+ renderers: menuLinkRenderers
35
+ }
36
+ }
37
+ });
38
+
39
+ addPlacementTopology({
40
+ id: "shell.secondary-nav",
41
+ description: "Secondary navigation for the current surface.",
42
+ surfaces: ["*"],
43
+ variants: {
44
+ compact: {
45
+ outlet: "shell-layout:secondary-menu",
46
+ renderers: menuLinkRenderers
47
+ },
48
+ medium: {
49
+ outlet: "shell-layout:secondary-menu",
50
+ renderers: menuLinkRenderers
51
+ },
52
+ expanded: {
53
+ outlet: "shell-layout:secondary-menu",
54
+ renderers: menuLinkRenderers
55
+ }
56
+ }
57
+ });
58
+
59
+ addPlacementTopology({
60
+ id: "shell.identity",
61
+ description: "Current surface identity and switcher controls.",
62
+ surfaces: ["*"],
63
+ variants: {
64
+ compact: {
65
+ outlet: "shell-layout:top-left"
66
+ },
67
+ medium: {
68
+ outlet: "shell-layout:top-left"
69
+ },
70
+ expanded: {
71
+ outlet: "shell-layout:top-left"
72
+ }
73
+ }
74
+ });
75
+
76
+ addPlacementTopology({
77
+ id: "shell.status",
78
+ description: "Surface status, connection, and utility indicators.",
79
+ surfaces: ["*"],
80
+ variants: {
81
+ compact: {
82
+ outlet: "shell-layout:top-right"
83
+ },
84
+ medium: {
85
+ outlet: "shell-layout:top-right"
86
+ },
87
+ expanded: {
88
+ outlet: "shell-layout:top-right"
89
+ }
90
+ }
91
+ });
92
+
93
+ addPlacementTopology({
94
+ id: "shell.global-actions",
95
+ description: "Global surface actions that should stay outside primary navigation.",
96
+ surfaces: ["*"],
97
+ variants: {
98
+ compact: {
99
+ outlet: "shell-layout:top-right",
100
+ renderers: menuLinkRenderers
101
+ },
102
+ medium: {
103
+ outlet: "shell-layout:top-right",
104
+ renderers: menuLinkRenderers
105
+ },
106
+ expanded: {
107
+ outlet: "shell-layout:top-right",
108
+ renderers: menuLinkRenderers
109
+ }
110
+ }
111
+ });
112
+
113
+ addPlacementTopology({
114
+ id: "page.supporting-content",
115
+ description: "Supporting page content that opens as a bottom sheet on compact layouts and a side panel on wider layouts.",
116
+ surfaces: ["*"],
117
+ variants: {
118
+ compact: {
119
+ outlet: "shell-layout:supporting-bottom-sheet"
120
+ },
121
+ medium: {
122
+ outlet: "shell-layout:supporting-side-panel"
123
+ },
124
+ expanded: {
125
+ outlet: "shell-layout:supporting-side-panel"
126
+ }
127
+ }
128
+ });
129
+
130
+ addPlacementTopology({
131
+ id: "page.section-nav",
132
+ owner: "home-settings",
133
+ description: "Navigation between child pages in the home settings section.",
134
+ surfaces: ["home"],
135
+ variants: {
136
+ compact: {
137
+ outlet: "home-settings:primary-menu",
138
+ renderers: menuLinkRenderers
139
+ },
140
+ medium: {
141
+ outlet: "home-settings:primary-menu",
142
+ renderers: menuLinkRenderers
143
+ },
144
+ expanded: {
145
+ outlet: "home-settings:primary-menu",
146
+ renderers: menuLinkRenderers
147
+ }
148
+ }
149
+ });
@@ -0,0 +1,4 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { runAdaptiveShellSmoke } from "@jskit-ai/shell-web/test/adaptiveShellSmoke";
3
+
4
+ runAdaptiveShellSmoke({ test, expect });
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createDefaultErrorPolicy } from "../src/client/error/policy.js";
3
4
  import { createErrorRuntime } from "../src/client/error/runtime.js";
4
5
 
5
6
  function createPresenter(id, {
@@ -25,6 +26,47 @@ function createPresenter(id, {
25
26
  });
26
27
  }
27
28
 
29
+ test("default error policy maps intent to presentation instead of status alone", () => {
30
+ const policy = createDefaultErrorPolicy();
31
+
32
+ assert.equal(policy({ intent: "resource-load", message: "Load failed" }).channel, "silent");
33
+ assert.equal(policy({ intent: "action-feedback", message: "Save failed" }).channel, "snackbar");
34
+ assert.equal(policy({ intent: "app-recoverable", message: "Offline" }).channel, "banner");
35
+ assert.equal(policy({ intent: "blocking", message: "Fatal" }).channel, "dialog");
36
+ assert.equal(policy({ blocking: true, message: "Fatal" }).channel, "dialog");
37
+ assert.equal(policy({ status: 500, message: "Server failed" }).channel, "snackbar");
38
+ assert.equal(
39
+ policy({ intent: "resource-load", channel: "banner", message: "Load failed" }).channel,
40
+ "banner"
41
+ );
42
+ });
43
+
44
+ test("error runtime treats resource load errors as silent by default", () => {
45
+ const calls = [];
46
+ const runtime = createErrorRuntime({
47
+ presenters: [
48
+ createPresenter("module.presenter", { calls })
49
+ ],
50
+ moduleDefaultPresenterId: "module.presenter"
51
+ });
52
+
53
+ const result = runtime.report({
54
+ kind: "resource-load",
55
+ message: "Unable to load records.",
56
+ status: 500,
57
+ action: {
58
+ label: "Retry",
59
+ handler() {}
60
+ }
61
+ });
62
+
63
+ assert.equal(result.skipped, true);
64
+ assert.equal(result.reason, "silent");
65
+ assert.equal(result.event.intent, "resource-load");
66
+ assert.equal(result.decision.channel, "silent");
67
+ assert.equal(calls.length, 0);
68
+ });
69
+
28
70
  test("error runtime prefers policy presenter over app and module defaults", () => {
29
71
  const calls = [];
30
72
  const runtime = createErrorRuntime({
@@ -32,8 +32,13 @@ test("shell-web exports generic link-item components for app-owned shell wrapper
32
32
  assert.match(clientIndexSource, /ShellMenuLinkItem/);
33
33
  assert.match(clientIndexSource, /ShellSurfaceAwareMenuLinkItem/);
34
34
  assert.match(clientIndexSource, /ShellTabLinkItem/);
35
+ assert.match(clientIndexSource, /ShellRouteTransition/);
35
36
 
36
37
  const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
38
+ assert.equal(
39
+ packageJson?.exports?.["./client/components/ShellRouteTransition"],
40
+ "./src/client/components/ShellRouteTransition.vue"
41
+ );
37
42
  assert.equal(
38
43
  packageJson?.exports?.["./client/components/ShellMenuLinkItem"],
39
44
  "./src/client/components/ShellMenuLinkItem.vue"
@@ -150,8 +155,10 @@ test("shell-web generic link items support the expected shared route and icon be
150
155
  assert.match(shellSurfaceAwareSource, /:exact="props\.exact"/);
151
156
  assert.match(shellTabSource, /icon:\s*\{/);
152
157
  assert.match(shellTabSource, /resolveMenuLinkIcon/);
153
- assert.match(shellTabSource, /<v-list-item/);
154
- assert.match(shellTabSource, /:prepend-icon="resolvedIcon \|\| undefined"/);
158
+ assert.match(shellTabSource, /<v-btn/);
159
+ assert.match(shellTabSource, /stacked/);
160
+ assert.match(shellTabSource, /min-height:\s*48px/);
161
+ assert.match(shellTabSource, /<v-icon v-if="resolvedIcon" :icon="resolvedIcon" \/>/);
155
162
  });
156
163
 
157
164
  test("shell-web binds the local link-item wrapper tokens into MainClientProvider", () => {
@@ -14,9 +14,9 @@ test("shell-web outlet menu widget exposes a configurable nested outlet", async
14
14
  );
15
15
 
16
16
  assert.match(source, /import \{ mdiCogOutline \} from "@mdi\/js";/);
17
- assert.match(source, /defaultLinkComponentToken: \{/);
18
17
  assert.match(source, /:target="props\.target"/);
19
- assert.match(source, /:default-link-component-token="props\.defaultLinkComponentToken"/);
18
+ assert.doesNotMatch(source, /defaultLinkComponentToken/);
19
+ assert.doesNotMatch(source, /default-link-component-token/);
20
20
  assert.match(source, /default: mdiCogOutline/);
21
21
  assert.doesNotMatch(source, /mdi-[a-z0-9-]+/);
22
22
  });
@@ -7,13 +7,13 @@ test("placement registry stores unique entries and builds immutable array", () =
7
7
 
8
8
  const firstAdded = registry.addPlacement({
9
9
  id: "example.profile",
10
- target: "shell-layout:top-right",
10
+ target: "shell.status",
11
11
  surfaces: ["*"],
12
12
  componentToken: "example.profile.component"
13
13
  });
14
14
  const duplicateAdded = registry.addPlacement({
15
15
  id: "example.profile",
16
- target: "shell-layout:top-right",
16
+ target: "shell.status",
17
17
  surfaces: ["*"],
18
18
  componentToken: "example.profile.component.duplicate"
19
19
  });
@@ -33,7 +33,7 @@ test("placement registry accepts explicit non-global surface ids", () => {
33
33
 
34
34
  const added = registry.addPlacement({
35
35
  id: "example.admin",
36
- target: "shell-layout:top-right",
36
+ target: "shell.status",
37
37
  surfaces: ["admin"],
38
38
  componentToken: "example.admin.component"
39
39
  });
@@ -32,6 +32,119 @@ function createPlacementContext() {
32
32
  };
33
33
  }
34
34
 
35
+ function semanticTopologyEntry({
36
+ id = "shell.primary-nav",
37
+ owner = "",
38
+ surfaces = ["*"],
39
+ compact = "shell-layout:primary-menu",
40
+ medium = "shell-layout:primary-menu",
41
+ expanded = "shell-layout:primary-menu",
42
+ compactRenderer = "component.menu",
43
+ mediumRenderer = "component.menu",
44
+ expandedRenderer = "component.menu"
45
+ } = {}) {
46
+ const createVariant = (outlet, renderer) => ({
47
+ outlet,
48
+ renderers: renderer ? { link: renderer } : {}
49
+ });
50
+ return {
51
+ id,
52
+ owner,
53
+ surfaces,
54
+ variants: {
55
+ compact: createVariant(compact, compactRenderer),
56
+ medium: createVariant(medium, mediumRenderer),
57
+ expanded: createVariant(expanded, expandedRenderer)
58
+ }
59
+ };
60
+ }
61
+
62
+ test("web placement runtime resolves semantic targets through topology variants", () => {
63
+ const app = createAppStub({
64
+ tokens: {
65
+ "component.bottom": () => null,
66
+ "component.menu": () => null
67
+ }
68
+ });
69
+
70
+ const runtime = createWebPlacementRuntime({ app });
71
+ runtime.replacePlacementTopology([
72
+ semanticTopologyEntry({
73
+ id: "shell.primary-nav",
74
+ compact: "shell-layout:bottom-nav",
75
+ medium: "shell-layout:primary-menu",
76
+ expanded: "shell-layout:primary-menu",
77
+ compactRenderer: "component.bottom",
78
+ mediumRenderer: "component.menu",
79
+ expandedRenderer: "component.menu"
80
+ })
81
+ ]);
82
+ runtime.replacePlacements([
83
+ definePlacement({
84
+ id: "test.home",
85
+ target: "shell.primary-nav",
86
+ kind: "link",
87
+ surfaces: ["app"],
88
+ order: 10
89
+ })
90
+ ]);
91
+ runtime.setContext(createPlacementContext());
92
+
93
+ const compactEntries = runtime.getPlacements({
94
+ surface: "app",
95
+ target: "shell-layout:bottom-nav",
96
+ layoutClass: "compact"
97
+ });
98
+ assert.deepEqual(compactEntries.map((entry) => entry.id), ["test.home"]);
99
+ assert.equal(compactEntries[0].semanticTarget, "shell.primary-nav");
100
+ assert.equal(compactEntries[0].componentToken, "component.bottom");
101
+
102
+ const mediumEntries = runtime.getPlacements({
103
+ surface: "app",
104
+ target: "shell-layout:primary-menu",
105
+ layoutClass: "medium"
106
+ });
107
+ assert.deepEqual(mediumEntries.map((entry) => entry.id), ["test.home"]);
108
+ assert.equal(mediumEntries[0].componentToken, "component.menu");
109
+ });
110
+
111
+ test("web placement runtime accepts append-only topology objects", () => {
112
+ const app = createAppStub({
113
+ tokens: {
114
+ "component.menu": () => null
115
+ }
116
+ });
117
+
118
+ const runtime = createWebPlacementRuntime({ app });
119
+ runtime.replacePlacementTopology({
120
+ placements: [
121
+ semanticTopologyEntry({
122
+ id: "shell.primary-nav",
123
+ compactRenderer: "component.menu",
124
+ mediumRenderer: "component.menu",
125
+ expandedRenderer: "component.menu"
126
+ })
127
+ ]
128
+ });
129
+ runtime.replacePlacements([
130
+ definePlacement({
131
+ id: "test.home",
132
+ target: "shell.primary-nav",
133
+ kind: "link",
134
+ surfaces: ["app"],
135
+ order: 10
136
+ })
137
+ ]);
138
+ runtime.setContext(createPlacementContext());
139
+
140
+ const entries = runtime.getPlacements({
141
+ surface: "app",
142
+ target: "shell-layout:primary-menu",
143
+ layoutClass: "expanded"
144
+ });
145
+ assert.deepEqual(entries.map((entry) => entry.id), ["test.home"]);
146
+ });
147
+
35
148
  test("web placement runtime filters by surface/host/position, resolves component tokens, and sorts by order", () => {
36
149
  const app = createAppStub({
37
150
  tokens: {
@@ -48,21 +161,24 @@ test("web placement runtime filters by surface/host/position, resolves component
48
161
  target: "shell-layout:primary-menu",
49
162
  surfaces: ["app"],
50
163
  order: 30,
51
- componentToken: "component.menu"
164
+ componentToken: "component.menu",
165
+ internal: true
52
166
  }),
53
167
  definePlacement({
54
168
  id: "test.profile",
55
169
  target: "shell-layout:top-right",
56
170
  surfaces: ["*"],
57
171
  order: 20,
58
- componentToken: "component.profile"
172
+ componentToken: "component.profile",
173
+ internal: true
59
174
  }),
60
175
  definePlacement({
61
176
  id: "test.alerts",
62
177
  target: "shell-layout:top-right",
63
178
  surfaces: ["app"],
64
179
  order: 10,
65
- componentToken: "component.alerts"
180
+ componentToken: "component.alerts",
181
+ internal: true
66
182
  })
67
183
  ]);
68
184
  runtime.setContext(createPlacementContext());
@@ -93,14 +209,16 @@ test("web placement runtime preserves source order when placements share the sam
93
209
  target: "home-settings:primary-menu",
94
210
  surfaces: ["app"],
95
211
  order: 155,
96
- componentToken: "component.beta"
212
+ componentToken: "component.beta",
213
+ internal: true
97
214
  }),
98
215
  definePlacement({
99
216
  id: "test.alpha",
100
217
  target: "home-settings:primary-menu",
101
218
  surfaces: ["app"],
102
219
  order: 155,
103
- componentToken: "component.alpha"
220
+ componentToken: "component.alpha",
221
+ internal: true
104
222
  })
105
223
  ]);
106
224
  runtime.setContext(createPlacementContext());
@@ -129,6 +247,7 @@ test("web placement runtime applies context contributors and placement when() pr
129
247
  target: "auth-profile-menu:primary-menu",
130
248
  surfaces: ["*"],
131
249
  componentToken: "component.guest",
250
+ internal: true,
132
251
  when: ({ auth }) => !Boolean(auth?.authenticated)
133
252
  }),
134
253
  definePlacement({
@@ -136,6 +255,7 @@ test("web placement runtime applies context contributors and placement when() pr
136
255
  target: "auth-profile-menu:primary-menu",
137
256
  surfaces: ["*"],
138
257
  componentToken: "component.authenticated",
258
+ internal: true,
139
259
  when: ({ auth }) => Boolean(auth?.authenticated)
140
260
  })
141
261
  ]);
@@ -165,6 +285,7 @@ test("web placement runtime uses runtime context and local context overrides con
165
285
  target: "auth-profile-menu:primary-menu",
166
286
  surfaces: ["*"],
167
287
  componentToken: "component.allowed",
288
+ internal: true,
168
289
  when: ({ auth }) => Boolean(auth?.authenticated)
169
290
  })
170
291
  ]);
@@ -224,13 +345,15 @@ test("web placement runtime rejects duplicate placement ids", () => {
224
345
  id: "dup.entry",
225
346
  target: "shell-layout:top-right",
226
347
  surfaces: ["*"],
227
- componentToken: "component.a"
348
+ componentToken: "component.a",
349
+ internal: true
228
350
  }),
229
351
  definePlacement({
230
352
  id: "dup.entry",
231
353
  target: "shell-layout:primary-menu",
232
354
  surfaces: ["*"],
233
- componentToken: "component.b"
355
+ componentToken: "component.b",
356
+ internal: true
234
357
  })
235
358
  ]);
236
359
  }, /Duplicate placement id/);
@@ -271,13 +394,15 @@ test("web placement runtime skips throwing component tokens and logs resolution
271
394
  id: "bad",
272
395
  target: "shell-layout:top-right",
273
396
  surfaces: ["*"],
274
- componentToken: "component.bad"
397
+ componentToken: "component.bad",
398
+ internal: true
275
399
  }),
276
400
  definePlacement({
277
401
  id: "good",
278
402
  target: "shell-layout:top-right",
279
403
  surfaces: ["*"],
280
- componentToken: "component.good"
404
+ componentToken: "component.good",
405
+ internal: true
281
406
  })
282
407
  ]);
283
408
 
@@ -323,7 +448,8 @@ test("web placement runtime clears failed token cache when placements are replac
323
448
  id: "toggle",
324
449
  target: "shell-layout:top-right",
325
450
  surfaces: ["*"],
326
- componentToken: "component.toggle"
451
+ componentToken: "component.toggle",
452
+ internal: true
327
453
  })
328
454
  ]);
329
455
 
@@ -339,7 +465,8 @@ test("web placement runtime clears failed token cache when placements are replac
339
465
  id: "toggle",
340
466
  target: "shell-layout:top-right",
341
467
  surfaces: ["*"],
342
- componentToken: "component.toggle"
468
+ componentToken: "component.toggle",
469
+ internal: true
343
470
  })
344
471
  ]);
345
472
 
@@ -362,21 +489,24 @@ test("web placement runtime follows explicit surface targeting without role indi
362
489
  target: "shell-layout:top-right",
363
490
  surfaces: ["*"],
364
491
  order: 10,
365
- componentToken: "component.global"
492
+ componentToken: "component.global",
493
+ internal: true
366
494
  }),
367
495
  definePlacement({
368
496
  id: "app.link",
369
497
  target: "shell-layout:top-right",
370
498
  surfaces: ["app"],
371
499
  order: 20,
372
- componentToken: "component.app"
500
+ componentToken: "component.app",
501
+ internal: true
373
502
  }),
374
503
  definePlacement({
375
504
  id: "admin.link",
376
505
  target: "shell-layout:top-right",
377
506
  surfaces: ["admin"],
378
507
  order: 30,
379
- componentToken: "component.admin"
508
+ componentToken: "component.admin",
509
+ internal: true
380
510
  })
381
511
  ]);
382
512
  runtime.setContext(createPlacementContext());