@jskit-ai/shell-web 0.1.249 → 0.1.251

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.
@@ -3,9 +3,11 @@ import { computed, watchEffect } from "vue";
3
3
  import { RouterView, useRoute } from "vue-router";
4
4
  import { useTheme } from "vuetify";
5
5
  import ShellLayout from "@jskit-ai/shell-web/client/components/ShellLayout";
6
+ import ShellErrorHost from "@jskit-ai/shell-web/client/components/ShellErrorHost";
6
7
 
7
8
  const route = useRoute();
8
9
  const theme = useTheme();
10
+ const bare = computed(() => route.meta.bare === true);
9
11
  const surface = computed(() => route.path.startsWith("/w/") ? "admin" : "home");
10
12
  const surfaceLabel = computed(() => surface.value === "admin" ? "Admin" : "Home");
11
13
  const themeMode = computed(() => route.query.theme === "dark" ? "dark" : "light");
@@ -22,7 +24,9 @@ watchEffect(function applyFixtureTheme() {
22
24
 
23
25
  <template>
24
26
  <v-app>
27
+ <RouterView v-if="bare" />
25
28
  <ShellLayout
29
+ v-else
26
30
  :surface="surface"
27
31
  :surface-label="surfaceLabel"
28
32
  :rail-width="railWidth"
@@ -30,5 +34,6 @@ watchEffect(function applyFixtureTheme() {
30
34
  >
31
35
  <RouterView />
32
36
  </ShellLayout>
37
+ <ShellErrorHost />
33
38
  </v-app>
34
39
  </template>
@@ -13,6 +13,10 @@ import ShellSurfaceAwareMenuLinkItem from "@jskit-ai/shell-web/client/components
13
13
  import ShellTabLinkItem from "@jskit-ai/shell-web/client/components/ShellTabLinkItem";
14
14
  import { useShellLayoutStore } from "../../../src/client/stores/useShellLayoutStore.js";
15
15
  import { createWebPlacementRuntime } from "../../../src/client/placement/runtime.js";
16
+ import { createErrorRuntime } from "../../../src/client/error/runtime.js";
17
+ import { createDefaultMaterialErrorPresenters } from "../../../src/client/error/presenters.js";
18
+ import { createErrorPresentationStore } from "../../../src/client/error/store.js";
19
+ import { useShellErrorPresentationStore } from "../../../src/client/stores/useShellErrorPresentationStore.js";
16
20
 
17
21
  const COMPONENTS = new Map([
18
22
  ["fixture.menu-link", ShellMenuLinkItem],
@@ -111,7 +115,8 @@ const routes = [
111
115
  { path: "/home/settings/general", component: ScreenPage, meta: { title: "General settings" } },
112
116
  { path: "/home/:section?", component: ScreenPage, meta: { title: "Home surface" } },
113
117
  { path: "/w/:workspaceSlug/admin/:section?", component: ScreenPage, meta: { title: "Admin surface" } },
114
- { path: "/help", component: ScreenPage, meta: { title: "Help and support" } }
118
+ { path: "/help", component: ScreenPage, meta: { title: "Help and support" } },
119
+ { path: "/sign-in", component: ScreenPage, meta: { title: "Sign in", bare: true } }
115
120
  ];
116
121
  const router = createRouter({ history: createWebHistory(), routes });
117
122
  const pinia = createPinia();
@@ -179,6 +184,19 @@ app.use(pinia);
179
184
  app.use(router);
180
185
  app.use(vuetify);
181
186
  app.provide("jskit.shell-web.runtime.web-placement.client", placementRuntime);
187
+ const errorStore = createErrorPresentationStore();
188
+ const errorRuntime = createErrorRuntime({
189
+ presenters: createDefaultMaterialErrorPresenters({ store: errorStore }),
190
+ defaultPresenterId: "material.snackbar"
191
+ });
192
+ useShellErrorPresentationStore(pinia).attachRuntimeStore(errorStore);
193
+ app.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
194
+ window.__shellFixtureReportBanner = (message) => errorRuntime.report({
195
+ source: "shell-web.fixture",
196
+ message,
197
+ severity: "error",
198
+ channel: "banner"
199
+ });
182
200
  app.provide("jskit.shell-web.runtime.web-refresh.client", {
183
201
  async refresh() {
184
202
  await fetch("/api/bootstrap?reason=pull-to-refresh");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.249",
3
+ "version": "0.1.251",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -34,7 +34,7 @@
34
34
  "@vue/compiler-sfc": "3.5.44"
35
35
  },
36
36
  "peerDependencies": {
37
- "@jskit-ai/kernel": "0.1.244",
37
+ "@jskit-ai/kernel": "0.1.245",
38
38
  "pinia": "^4.0.3",
39
39
  "vue": "^3.5.44",
40
40
  "vue-router": "^5.3.1",
@@ -38,6 +38,29 @@ placement registry, topology, and adaptive smoke helper exported by
38
38
  - Cached route/resource state hydrates writable UI state synchronously or with
39
39
  immediate watchers.
40
40
  - Loading uses stable skeletons, not spinners or shifting status banners.
41
+ - `ShellErrorHost` stays directly inside `v-app`. Its banners float below a top
42
+ app bar; on a surface without one (such as sign-in) they join the page flow
43
+ above the content so they never cover it. A full-viewport fixed screen on
44
+ such a surface starts at `var(--shell-error-banners-inline-height, 0px)`, as
45
+ `@jskit-ai/auth-web`'s login and reset views do.
46
+ - Restyle shell chrome through its custom properties, not by overriding
47
+ Vuetify internals: `--shell-chrome-border-width` and
48
+ `--shell-chrome-border-color` for the shared dividers,
49
+ `--shell-app-bar-border-width`, `--shell-navigation-border-width` and
50
+ `--shell-bottom-navigation-border-width` per edge, and
51
+ `--shell-app-bar-background`, `--shell-navigation-background` and
52
+ `--shell-bottom-navigation-background` for the surfaces. The defaults keep
53
+ one thin divider on each separating edge, because Vuetify's default themes
54
+ define no Material 3 surface-container roles. A Material 3 restyle sets the
55
+ border widths to `0` and gives navigation a tonal surface, for example
56
+ `rgb(var(--v-theme-surface-container-low))` when the theme defines it.
57
+ - `createShellRouter()` from `@jskit-ai/kernel/client` installs
58
+ `shellScrollBehavior`: a new screen opens at the top (or its hash target),
59
+ Back/Forward restore the saved position, and query, hash, tab or sub-view
60
+ changes within the same page keep it. A tab or sub-view is the same page when
61
+ both locations share a parent page record below the surface root. Pass
62
+ `scrollBehavior` to replace it, or `scrollBehavior: false` to disable it; do
63
+ not wrap the router or reset scroll in page components.
41
64
  - The adaptive browser check closes navigation through its public close control.
42
65
 
43
66
  ## Example files
@@ -1,5 +1,7 @@
1
1
  <script setup>
2
- import { computed, ref, watch } from "vue";
2
+ import { computed, onBeforeUnmount, ref, watch } from "vue";
3
+ import { useLayout } from "vuetify";
4
+ import { mdiAlertOutline, mdiCheckCircleOutline, mdiInformationOutline } from "@mdi/js";
3
5
  import {
4
6
  useShellWebErrorRuntime
5
7
  } from "../error/inject.js";
@@ -12,6 +14,75 @@ const snackbarEntries = computed(() => store.channels.snackbar || []);
12
14
  const bannerEntries = computed(() => store.channels.banner || []);
13
15
  const dialogEntry = computed(() => store.channels.dialog[0] || null);
14
16
  const displayedSnackbarEntry = ref(null);
17
+ const layoutRect = readLayoutRect();
18
+
19
+ function readLayoutRect() {
20
+ try {
21
+ return useLayout().mainRect;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ // Banners float just below a top app bar. A surface without one (sign-in pages)
28
+ // has nothing to float under, so the banners join the page flow above the
29
+ // content instead of covering it, and publish their height so full-viewport
30
+ // surfaces can start below them.
31
+ const BANNER_INLINE_HEIGHT_PROPERTY = "--shell-error-banners-inline-height";
32
+ const bannersElement = ref(null);
33
+ const bannerPlacement = computed(() => {
34
+ const rect = layoutRect?.value || {};
35
+ const top = Number(rect.top) || 0;
36
+ if (top <= 0) {
37
+ return { inline: true, style: undefined };
38
+ }
39
+
40
+ return {
41
+ inline: false,
42
+ style: {
43
+ top: `calc(env(safe-area-inset-top, 0px) + ${top}px)`,
44
+ left: `${Number(rect.left) || 0}px`,
45
+ right: `${Number(rect.right) || 0}px`
46
+ }
47
+ };
48
+ });
49
+ let bannerResizeObserver = null;
50
+
51
+ function publishInlineBannerHeight(height = 0) {
52
+ const rootStyle = globalThis.document?.documentElement?.style;
53
+ if (!rootStyle) {
54
+ return;
55
+ }
56
+ if (height > 0) {
57
+ rootStyle.setProperty(BANNER_INLINE_HEIGHT_PROPERTY, `${Math.ceil(height)}px`);
58
+ } else {
59
+ rootStyle.removeProperty(BANNER_INLINE_HEIGHT_PROPERTY);
60
+ }
61
+ }
62
+
63
+ function stopObservingBanners() {
64
+ bannerResizeObserver?.disconnect();
65
+ bannerResizeObserver = null;
66
+ publishInlineBannerHeight(0);
67
+ }
68
+
69
+ watch(
70
+ [bannersElement, () => bannerPlacement.value.inline],
71
+ ([element, inline]) => {
72
+ stopObservingBanners();
73
+ if (!element || !inline || typeof globalThis.ResizeObserver !== "function") {
74
+ return;
75
+ }
76
+ bannerResizeObserver = new globalThis.ResizeObserver(() => {
77
+ publishInlineBannerHeight(element.getBoundingClientRect().height);
78
+ });
79
+ bannerResizeObserver.observe(element);
80
+ publishInlineBannerHeight(element.getBoundingClientRect().height);
81
+ },
82
+ { flush: "post" }
83
+ );
84
+
85
+ onBeforeUnmount(stopObservingBanners);
15
86
  const snackbarOpen = ref(false);
16
87
 
17
88
  function isSameSnackbarEntry(left = null, right = null) {
@@ -77,15 +148,12 @@ function resolveSeverityColor(severity = "error") {
77
148
  function resolveSeverityIcon(severity = "error") {
78
149
  const normalized = String(severity || "error").trim().toLowerCase();
79
150
  if (normalized === "info") {
80
- return "mdi-information-outline";
151
+ return mdiInformationOutline;
81
152
  }
82
153
  if (normalized === "success") {
83
- return "mdi-check-circle-outline";
154
+ return mdiCheckCircleOutline;
84
155
  }
85
- if (normalized === "warning") {
86
- return "mdi-alert-outline";
87
- }
88
- return "mdi-alert-outline";
156
+ return mdiAlertOutline;
89
157
  }
90
158
 
91
159
  function resolveTimeout(entry) {
@@ -155,8 +223,19 @@ function onSnackbarAfterLeave() {
155
223
  </script>
156
224
 
157
225
  <template>
158
- <div class="shell-error-host" aria-live="polite">
159
- <div v-if="bannerEntries.length > 0" class="shell-error-host__banners">
226
+ <div
227
+ class="shell-error-host"
228
+ :class="{ 'shell-error-host--inline-banners': bannerPlacement.inline }"
229
+ aria-live="polite"
230
+ >
231
+ <div
232
+ v-if="bannerEntries.length > 0"
233
+ ref="bannersElement"
234
+ class="shell-error-host__banners"
235
+ :class="{ 'shell-error-host__banners--inline': bannerPlacement.inline }"
236
+ :style="bannerPlacement.style"
237
+ data-testid="shell-error-host-banners"
238
+ >
160
239
  <div class="shell-error-host__banner-stack">
161
240
  <v-alert
162
241
  v-for="entry in bannerEntries"
@@ -254,16 +333,23 @@ function onSnackbarAfterLeave() {
254
333
  </template>
255
334
 
256
335
  <style scoped>
336
+ .shell-error-host--inline-banners {
337
+ order: -1;
338
+ }
339
+
257
340
  .shell-error-host__banners {
258
341
  position: fixed;
259
- top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset, 64px));
260
- left: 0;
261
- right: 0;
262
342
  z-index: 2600;
263
343
  pointer-events: none;
264
344
  padding: 10px 12px;
265
345
  }
266
346
 
347
+ .shell-error-host__banners--inline {
348
+ position: relative;
349
+ z-index: auto;
350
+ padding-top: calc(env(safe-area-inset-top, 0px) + 10px);
351
+ }
352
+
267
353
  .shell-error-host__banner-stack {
268
354
  margin: 0 auto;
269
355
  width: min(1120px, 100%);
@@ -277,10 +363,28 @@ function onSnackbarAfterLeave() {
277
363
  box-shadow: var(--v-shadow-4);
278
364
  }
279
365
 
366
+ /*
367
+ * The close button always uses the banner's own on-color (the message colour) on
368
+ * a tonal disc of that colour, so a themed button default can never turn it into
369
+ * a low-contrast red-on-red mark, in light or dark themes.
370
+ */
371
+ .shell-error-host__banner :deep(.v-alert__close .v-btn) {
372
+ color: inherit !important;
373
+ opacity: 1;
374
+ background-color: color-mix(in srgb, currentColor 18%, transparent);
375
+ }
376
+
377
+ .shell-error-host__banner :deep(.v-alert__close .v-btn .v-icon) {
378
+ font-size: 20px;
379
+ }
380
+
280
381
  @media (max-width: 600px) {
281
382
  .shell-error-host__banners {
282
- top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset-mobile, 56px));
283
383
  padding: 8px;
284
384
  }
385
+
386
+ .shell-error-host__banners--inline {
387
+ padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
388
+ }
285
389
  }
286
390
  </style>
@@ -563,10 +563,9 @@ function touchListIncludesActiveTouch(touchList) {
563
563
 
564
564
  <template>
565
565
  <v-app-bar
566
- border
567
566
  :density="isCompactLayout ? 'compact' : 'comfortable'"
568
567
  elevation="0"
569
- class="shell-layout__app-bar bg-surface"
568
+ class="shell-layout__app-bar"
570
569
  data-testid="jskit-shell-app-bar"
571
570
  >
572
571
  <v-app-bar-nav-icon
@@ -619,8 +618,7 @@ function touchListIncludesActiveTouch(touchList) {
619
618
  ref="navigationDrawer"
620
619
  id="jskit-shell-drawer"
621
620
  :model-value="drawerPresentation.visible"
622
- border
623
- class="shell-layout__drawer bg-surface"
621
+ class="shell-layout__drawer"
624
622
  data-testid="jskit-shell-drawer"
625
623
  :data-layout="layoutClass"
626
624
  :data-presentation="drawerPresentation.kind"
@@ -670,7 +668,6 @@ function touchListIncludesActiveTouch(touchList) {
670
668
  v-if="isCompactLayout"
671
669
  class="shell-layout__bottom-nav"
672
670
  data-testid="jskit-shell-bottom-nav"
673
- bg-color="surface"
674
671
  color="primary"
675
672
  density="comfortable"
676
673
  grow
@@ -716,6 +713,33 @@ function touchListIncludesActiveTouch(touchList) {
716
713
  </template>
717
714
 
718
715
  <style scoped>
716
+ /*
717
+ * Shell chrome follows the app's Vuetify theme and is restyled through these
718
+ * custom properties (set them on :root or any ancestor):
719
+ * --shell-chrome-border-width / --shell-chrome-border-color shared dividers
720
+ * --shell-app-bar-border-width, --shell-navigation-border-width,
721
+ * --shell-bottom-navigation-border-width per-edge overrides
722
+ * --shell-app-bar-background, --shell-navigation-background,
723
+ * --shell-bottom-navigation-background surfaces
724
+ * A Material 3 restyle sets the border widths to 0 and separates surfaces
725
+ * tonally, e.g. --shell-navigation-background: rgb(var(--v-theme-surface-container-low)).
726
+ */
727
+ .shell-layout__app-bar {
728
+ border-style: solid;
729
+ border-color: var(--shell-chrome-border-color, rgba(var(--v-border-color), var(--v-border-opacity)));
730
+ border-width: 0 0 var(--shell-app-bar-border-width, var(--shell-chrome-border-width, thin));
731
+ background: var(--shell-app-bar-background, rgb(var(--v-theme-surface)));
732
+ color: rgb(var(--v-theme-on-surface));
733
+ }
734
+
735
+ .shell-layout__drawer {
736
+ border-style: solid;
737
+ border-color: var(--shell-chrome-border-color, rgba(var(--v-border-color), var(--v-border-opacity)));
738
+ border-width: 0;
739
+ border-inline-end-width: var(--shell-navigation-border-width, var(--shell-chrome-border-width, thin));
740
+ background: var(--shell-navigation-background, rgb(var(--v-theme-surface)));
741
+ }
742
+
719
743
  .shell-layout__content {
720
744
  padding: 0.75rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
721
745
  }
@@ -824,7 +848,9 @@ function touchListIncludesActiveTouch(touchList) {
824
848
  }
825
849
 
826
850
  .shell-layout__bottom-nav {
827
- border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
851
+ border-top: var(--shell-bottom-navigation-border-width, var(--shell-chrome-border-width, thin)) solid
852
+ var(--shell-chrome-border-color, rgba(var(--v-border-color), var(--v-border-opacity)));
853
+ background: var(--shell-bottom-navigation-background, rgb(var(--v-theme-surface)));
828
854
  padding-bottom: env(safe-area-inset-bottom, 0px);
829
855
  }
830
856
 
@@ -361,3 +361,118 @@ test("shell-web preserves the active form across responsive changes", {
361
361
  await stopProcess(vite);
362
362
  }
363
363
  });
364
+
365
+ function overlaps(first, second) {
366
+ return first.bottom > second.top && first.top < second.bottom &&
367
+ first.right > second.left && first.left < second.right;
368
+ }
369
+
370
+ async function reportBannerAndMeasure(page) {
371
+ await page.evaluate(() => window.__shellFixtureReportBanner("Invalid email or password."));
372
+ const banner = page.locator(".shell-error-host__banner").first();
373
+ await expect(banner).toBeVisible();
374
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
375
+ return banner.evaluate((element) => {
376
+ const box = (node) => {
377
+ const rect = node.getBoundingClientRect();
378
+ return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right };
379
+ };
380
+ const close = element.querySelector(".v-alert__close .v-btn");
381
+ const closeColor = window.getComputedStyle(close).color;
382
+ // A themed button default must not recolour the close mark into the banner colour.
383
+ close.classList.add("text-error");
384
+ const closeColorWithThemedDefault = window.getComputedStyle(close).color;
385
+ close.classList.remove("text-error");
386
+ const appBar = document.querySelector("[data-testid='jskit-shell-app-bar']");
387
+ return {
388
+ banner: box(element),
389
+ background: window.getComputedStyle(element).backgroundColor,
390
+ closeColor,
391
+ closeColorWithThemedDefault,
392
+ appBar: appBar ? box(appBar) : null,
393
+ content: Array.from(document.querySelectorAll("main h1, main input, main button"))
394
+ .map((node) => ({ name: node.textContent.trim() || node.name, rect: box(node) }))
395
+ };
396
+ });
397
+ }
398
+
399
+ test("shell-web error banners never cover content and keep a readable close button", {
400
+ skip: RUN_BROWSER_TEST ? false : "set JSKIT_SHELL_WEB_BROWSER_INTEGRATION=1",
401
+ timeout: 120_000
402
+ }, async () => {
403
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
404
+ let browser;
405
+ try {
406
+ browser = await chromium.launch(createChromiumLaunchOptions());
407
+ for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) {
408
+ for (const theme of ["light", "dark"]) {
409
+ const page = await browser.newPage({ baseURL: vite.baseURL, viewport });
410
+ await page.goto(`/sign-in?theme=${theme}`);
411
+ await expect(page.getByRole("heading", { level: 1, name: "Sign in" })).toBeVisible();
412
+ await expect(page.getByTestId("jskit-shell-app-bar")).toHaveCount(0);
413
+ const bare = await reportBannerAndMeasure(page);
414
+ for (const item of bare.content) {
415
+ assert.equal(
416
+ overlaps(bare.banner, item.rect),
417
+ false,
418
+ `${viewport.width}px ${theme}: the banner covers "${item.name}" on a surface without an app bar.`
419
+ );
420
+ }
421
+ const background = parseRgb(bare.background);
422
+ for (const color of [bare.closeColor, bare.closeColorWithThemedDefault]) {
423
+ const ratio = contrastRatio(parseRgb(color), background);
424
+ assert.ok(ratio >= 3, `${viewport.width}px ${theme}: banner close contrast was ${ratio.toFixed(2)}:1.`);
425
+ }
426
+
427
+ await page.goto(`/home?theme=${theme}`);
428
+ await expect(page.getByTestId("jskit-shell-app-bar")).toBeVisible();
429
+ const shell = await reportBannerAndMeasure(page);
430
+ assert.ok(
431
+ shell.banner.top >= shell.appBar.bottom - 1,
432
+ `${viewport.width}px ${theme}: the banner overlaps the top app bar.`
433
+ );
434
+ await page.close();
435
+ }
436
+ }
437
+ } finally {
438
+ await browser?.close();
439
+ await stopProcess(vite);
440
+ }
441
+ });
442
+
443
+ test("shell-web chrome borders and surfaces follow app-supplied tokens", {
444
+ skip: RUN_BROWSER_TEST ? false : "set JSKIT_SHELL_WEB_BROWSER_INTEGRATION=1",
445
+ timeout: 90_000
446
+ }, async () => {
447
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
448
+ let browser;
449
+ try {
450
+ browser = await chromium.launch(createChromiumLaunchOptions());
451
+ const page = await browser.newPage({ baseURL: vite.baseURL, viewport: { width: 1280, height: 900 } });
452
+ await page.goto("/home?theme=light");
453
+ await expect(page.getByTestId("jskit-shell-drawer")).toBeVisible();
454
+ const read = () => page.evaluate(() => {
455
+ const edges = (testId) => {
456
+ const style = window.getComputedStyle(document.querySelector(`[data-testid='${testId}']`));
457
+ return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth,
458
+ style.backgroundColor];
459
+ };
460
+ return { appBar: edges("jskit-shell-app-bar"), drawer: edges("jskit-shell-drawer") };
461
+ });
462
+ const defaults = await read();
463
+ // Only the separating edges carry a divider by default.
464
+ assert.deepEqual(defaults.appBar.slice(0, 4), ["0px", "0px", "1px", "0px"]);
465
+ assert.deepEqual(defaults.drawer.slice(0, 4), ["0px", "1px", "0px", "0px"]);
466
+
467
+ await page.addStyleTag({
468
+ content: ":root { --shell-chrome-border-width: 0; --shell-app-bar-background: rgb(1, 2, 3); --shell-navigation-background: rgb(4, 5, 6); }"
469
+ });
470
+ const restyled = await read();
471
+ assert.deepEqual(restyled.appBar, ["0px", "0px", "0px", "0px", "rgb(1, 2, 3)"]);
472
+ assert.deepEqual(restyled.drawer, ["0px", "0px", "0px", "0px", "rgb(4, 5, 6)"]);
473
+ await page.close();
474
+ } finally {
475
+ await browser?.close();
476
+ await stopProcess(vite);
477
+ }
478
+ });
@@ -142,7 +142,7 @@ test("shell-web error host uses one explicit close affordance for banner errors"
142
142
  const source = await readFile(path.join(PACKAGE_DIR, "src", "client", "components", "ShellErrorHost.vue"), "utf8");
143
143
 
144
144
  assert.match(source, /function resolveSeverityIcon/);
145
- assert.match(source, /mdi-alert-outline/);
145
+ assert.match(source, /mdiAlertOutline/);
146
146
  assert.match(source, /:icon="resolveSeverityIcon\(entry\.severity\)"/);
147
147
  assert.match(source, /closable[\s\S]*@click:close="dismiss\(entry\)"/);
148
148
  assert.doesNotMatch(source, /mdi-close/);