@happyvertical/smrt-svelte 0.51.39 → 0.53.0

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 (143) hide show
  1. package/AGENTS.md +3 -0
  2. package/README.md +214 -1
  3. package/agents/components.md +8 -1
  4. package/agents/workspace.md +24 -0
  5. package/dist/Provider.svelte +19 -15
  6. package/dist/Provider.svelte.d.ts +2 -2
  7. package/dist/Provider.svelte.d.ts.map +1 -1
  8. package/dist/__tests__/provider-mode-harness.svelte +14 -0
  9. package/dist/__tests__/provider-mode-harness.svelte.d.ts +11 -0
  10. package/dist/__tests__/provider-mode-harness.svelte.d.ts.map +1 -0
  11. package/dist/__tests__/provider-mode-probe.svelte +13 -0
  12. package/dist/__tests__/provider-mode-probe.svelte.d.ts +8 -0
  13. package/dist/__tests__/provider-mode-probe.svelte.d.ts.map +1 -0
  14. package/dist/browser-ai/adapters/stt/browser-speech.d.ts +11 -0
  15. package/dist/browser-ai/adapters/stt/browser-speech.d.ts.map +1 -1
  16. package/dist/browser-ai/adapters/stt/browser-speech.js +20 -4
  17. package/dist/browser-ai/adapters/stt/browser-speech.test.js +113 -0
  18. package/dist/browser-ai/adapters/stt/dictation-source.d.ts +3 -0
  19. package/dist/browser-ai/adapters/stt/dictation-source.d.ts.map +1 -0
  20. package/dist/browser-ai/adapters/stt/dictation-source.js +38 -0
  21. package/dist/browser-ai/adapters/stt/dictation-source.test.js +52 -0
  22. package/dist/browser-ai/adapters/stt/index.d.ts +2 -1
  23. package/dist/browser-ai/adapters/stt/index.d.ts.map +1 -1
  24. package/dist/browser-ai/adapters/stt/index.js +2 -1
  25. package/dist/components/forms/FileUpload.svelte +120 -16
  26. package/dist/components/forms/FileUpload.svelte.d.ts +7 -1
  27. package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/Form.svelte +118 -8
  29. package/dist/components/forms/Form.svelte.d.ts +18 -2
  30. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  31. package/dist/components/forms/FormScope.svelte +202 -0
  32. package/dist/components/forms/FormScope.svelte.d.ts +22 -0
  33. package/dist/components/forms/FormScope.svelte.d.ts.map +1 -0
  34. package/dist/components/forms/__tests__/FileUpload.native-form.test.js +249 -0
  35. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte +24 -0
  36. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts +10 -0
  37. package/dist/components/forms/__tests__/FileUploadReset.fixture.svelte.d.ts.map +1 -0
  38. package/dist/components/forms/__tests__/Form.ui-controls.test.js +119 -0
  39. package/dist/components/forms/__tests__/Form.webmcp.test.js +5 -3
  40. package/dist/components/forms/__tests__/FormScope.test.js +39 -0
  41. package/dist/components/forms/__tests__/form-scope.fixture.svelte +30 -0
  42. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +10 -0
  43. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +1 -0
  44. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +84 -0
  45. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +14 -0
  46. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +1 -0
  47. package/dist/components/forms/index.d.ts +1 -0
  48. package/dist/components/forms/index.d.ts.map +1 -1
  49. package/dist/components/forms/index.js +1 -0
  50. package/dist/components/workspace/README.md +11 -0
  51. package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +597 -0
  52. package/dist/components/workspace/__tests__/AdminShell.test.js +257 -1
  53. package/dist/components/workspace/__tests__/TenantNav.test.js +115 -0
  54. package/dist/components/workspace/__tests__/WorkspaceAccountMenu.test.js +6 -0
  55. package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +25 -0
  56. package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +217 -0
  57. package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +30 -0
  58. package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +184 -0
  59. package/dist/components/workspace/__tests__/admin-shell-state.test.js +25 -0
  60. package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +36 -0
  61. package/dist/components/workspace/__tests__/shell-chrome-components.test.js +108 -0
  62. package/dist/components/workspace/admin-shell/AdminShell.svelte +1099 -59
  63. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +44 -1
  64. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  65. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte +170 -0
  66. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts +11 -0
  67. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts.map +1 -0
  68. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte +93 -0
  69. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts +11 -0
  70. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts.map +1 -0
  71. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte +88 -0
  72. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts +14 -0
  73. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts.map +1 -0
  74. package/dist/components/workspace/admin-shell/ShellTitle.svelte +42 -0
  75. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts +11 -0
  76. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts.map +1 -0
  77. package/dist/components/workspace/admin-shell/TenantNav.svelte +116 -13
  78. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +8 -2
  79. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
  80. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +16 -2
  81. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +2 -0
  82. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
  83. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts +37 -0
  84. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts.map +1 -0
  85. package/dist/components/workspace/admin-shell/mobile-shell-dom.js +150 -0
  86. package/dist/components/workspace/admin-shell/mobile-shell.d.ts +191 -0
  87. package/dist/components/workspace/admin-shell/mobile-shell.d.ts.map +1 -0
  88. package/dist/components/workspace/admin-shell/mobile-shell.js +165 -0
  89. package/dist/components/workspace/admin-shell/settings.d.ts +27 -2
  90. package/dist/components/workspace/admin-shell/settings.d.ts.map +1 -1
  91. package/dist/components/workspace/admin-shell/settings.js +84 -2
  92. package/dist/components/workspace/admin-shell/state.svelte.d.ts +57 -1
  93. package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
  94. package/dist/components/workspace/admin-shell/state.svelte.js +156 -6
  95. package/dist/components/workspace/admin-shell/types.d.ts +115 -1
  96. package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
  97. package/dist/components/workspace/admin-shell/types.js +13 -0
  98. package/dist/components/workspace/index.d.ts +11 -4
  99. package/dist/components/workspace/index.d.ts.map +1 -1
  100. package/dist/components/workspace/index.js +12 -3
  101. package/dist/i18n/strings.forms.d.ts +3 -0
  102. package/dist/i18n/strings.forms.d.ts.map +1 -1
  103. package/dist/i18n/strings.forms.js +3 -0
  104. package/dist/i18n/strings.workspace.d.ts +14 -0
  105. package/dist/i18n/strings.workspace.d.ts.map +1 -1
  106. package/dist/i18n/strings.workspace.js +14 -0
  107. package/dist/index.d.ts +1 -1
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +1 -1
  110. package/dist/provider-mode.test.js +86 -0
  111. package/dist/state/app-state.svelte.d.ts.map +1 -1
  112. package/dist/state/app-state.svelte.js +8 -8
  113. package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +2 -2
  114. package/dist/web/__tests__/list-sort-surface.fixture.svelte +41 -0
  115. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +15 -0
  116. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +1 -0
  117. package/dist/web/__tests__/list-sort-surface.svelte.test.js +59 -0
  118. package/dist/web/__tests__/surfaces.child.fixture.svelte +69 -0
  119. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +16 -0
  120. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +1 -0
  121. package/dist/web/__tests__/surfaces.fixture.svelte +26 -0
  122. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +18 -0
  123. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +1 -0
  124. package/dist/web/__tests__/surfaces.svelte.test.js +140 -0
  125. package/dist/web/__tests__/webmcp-ui.test.js +18 -0
  126. package/dist/web/index.d.ts +4 -1
  127. package/dist/web/index.d.ts.map +1 -1
  128. package/dist/web/index.js +4 -1
  129. package/dist/web/link-surface.svelte.d.ts +14 -0
  130. package/dist/web/link-surface.svelte.d.ts.map +1 -0
  131. package/dist/web/link-surface.svelte.js +49 -0
  132. package/dist/web/list-surface.svelte.d.ts +80 -0
  133. package/dist/web/list-surface.svelte.d.ts.map +1 -0
  134. package/dist/web/list-surface.svelte.js +309 -0
  135. package/dist/web/step-surface.svelte.d.ts +17 -0
  136. package/dist/web/step-surface.svelte.d.ts.map +1 -0
  137. package/dist/web/step-surface.svelte.js +56 -0
  138. package/dist/web/webmcp-ui-context.d.ts +7 -0
  139. package/dist/web/webmcp-ui-context.d.ts.map +1 -1
  140. package/dist/web/webmcp-ui-context.js +10 -0
  141. package/dist/web/webmcp-ui.d.ts.map +1 -1
  142. package/dist/web/webmcp-ui.js +16 -3
  143. package/package.json +17 -17
@@ -2,17 +2,20 @@
2
2
  import type { ShellState as ModuleShellState } from './state.svelte.js';
3
3
  import type { PanelEdge as ModulePanelEdge } from './types.js';
4
4
 
5
+ function expandedSize(shell: ModuleShellState, edge: ModulePanelEdge): string {
6
+ const resized = shell.panelSize(edge);
7
+ return resized !== null
8
+ ? `${resized}px`
9
+ : shell.config.panels[edge].expandedSize;
10
+ }
11
+
5
12
  function trackFor(shell: ModuleShellState, edge: ModulePanelEdge): string {
6
13
  const state = shell.panels[edge];
7
14
  const config = shell.config.panels[edge];
8
15
  if (state === 'hidden') return '0rem';
9
16
  if (state === 'collapsed') return config.collapsedSize;
10
- if (config.presentation === 'overlay') return config.collapsedSize;
11
- return config.expandedSize;
12
- }
13
-
14
- function expandedSize(shell: ModuleShellState, edge: ModulePanelEdge): string {
15
- return shell.config.panels[edge].expandedSize;
17
+ if (shell.presentationFor(edge) === 'overlay') return config.collapsedSize;
18
+ return expandedSize(shell, edge);
16
19
  }
17
20
 
18
21
  function collapsedSize(shell: ModuleShellState, edge: ModulePanelEdge): string {
@@ -37,9 +40,10 @@ function buildLayoutStyle(shell: ModuleShellState): string {
37
40
  </script>
38
41
 
39
42
  <script lang="ts">
43
+ import { swipeDismiss } from '@happyvertical/smrt-ui/feedback';
40
44
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
41
45
  import { Button } from '@happyvertical/smrt-ui/ui';
42
- import { onMount, untrack } from 'svelte';
46
+ import { onMount, tick, untrack } from 'svelte';
43
47
  import type { Snippet } from 'svelte';
44
48
  import { M } from '../../../i18n/strings.workspace.js';
45
49
  import ActivityBadge from './ActivityBadge.svelte';
@@ -48,12 +52,22 @@ function buildLayoutStyle(shell: ModuleShellState): string {
48
52
  formatHotkeyBinding,
49
53
  shellActionFromKeyboardEvent,
50
54
  } from './hotkeys.js';
51
- import { resolveHotkey } from './settings.js';
55
+ import { type BottomBarMode, bottomBarMode } from './mobile-shell.js';
56
+ import {
57
+ installKeyboardWatcher,
58
+ installScrollChrome,
59
+ installShellOverlayMedia,
60
+ installShellViewport,
61
+ watchFormActionBar,
62
+ } from './mobile-shell-dom.js';
63
+ import { clampPanelSize, resolveHotkey } from './settings.js';
52
64
  import { createShellState, type ShellState } from './state.svelte.js';
53
65
  import type {
66
+ AdminShellPhoneOptions,
54
67
  AdminShellProps,
55
68
  PanelEdge,
56
69
  ShellFocusTool,
70
+ ShellViewport,
57
71
  } from './types.js';
58
72
 
59
73
  interface Props extends AdminShellProps {
@@ -61,6 +75,8 @@ function buildLayoutStyle(shell: ModuleShellState): string {
61
75
  state?: ShellState;
62
76
  /** Content for the top app bar. */
63
77
  appBar?: Snippet;
78
+ /** Account content kept in the app bar in every left-panel state. */
79
+ account?: Snippet;
64
80
  /** Content for the main app panel. */
65
81
  appPanel?: Snippet;
66
82
  /** Content for the left tenant rail. */
@@ -87,11 +103,49 @@ function buildLayoutStyle(shell: ModuleShellState): string {
87
103
  bottomRightCorner?: Snippet;
88
104
  /** Content for the keyboard shortcuts overlay. */
89
105
  shortcutsOverlay?: Snippet;
106
+ /**
107
+ * A full-width header row above every edge (`#smrt-admin-shell-header`,
108
+ * 3.5rem; override `--smrt-admin-shell-header-size`). Shown at every
109
+ * width except on phones when `phoneTopBar` is given.
110
+ */
111
+ header?: Snippet<[{ viewport: ShellViewport }]>;
112
+ /**
113
+ * Phone-only context bar over the top of the main region. Hides while
114
+ * scrolling down and returns on the first scroll up (see
115
+ * `phone.hideOnScroll`); pinned while `pinChrome` or a drawer is open.
116
+ */
117
+ phoneTopBar?: Snippet<[{ hidden: boolean }]>;
118
+ /**
119
+ * Phone-only bottom bar row. Replaced by a form's action bar
120
+ * (`[data-form-action-bar]` inside main) and hidden while the on-screen
121
+ * keyboard is open.
122
+ */
123
+ phoneBottomBar?: Snippet;
124
+ /**
125
+ * Host surfaces laid over the shell above the phone bottom bar (phone
126
+ * sheets, status strips). The layer ignores pointer events; its direct
127
+ * children receive them.
128
+ */
129
+ overlays?: Snippet<[{ viewport: ShellViewport; bottomBar: BottomBarMode }]>;
130
+ /** Phone behavior switches (scrim, swipe to close, hide on scroll). */
131
+ phone?: AdminShellPhoneOptions;
132
+ /** Keep the phone top bar shown (e.g. while a banner is visible). */
133
+ pinChrome?: boolean;
134
+ /**
135
+ * Current location. When it changes on a phone, open side drawers and
136
+ * sheets close and the phone top bar comes back.
137
+ */
138
+ path?: string;
90
139
  children: Snippet;
91
140
  }
92
141
 
93
142
  let {
94
143
  title = 'SMRT',
144
+ homeHref,
145
+ showTenantToggle = true,
146
+ logoSrc,
147
+ logoAlt = '',
148
+ brand,
95
149
  subtitle = '',
96
150
  config,
97
151
  settings,
@@ -99,6 +153,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
99
153
  storageKey = 'smrt-admin-shell',
100
154
  state: providedState,
101
155
  appBar,
156
+ account,
102
157
  appPanel,
103
158
  tenantRail,
104
159
  tenantPanel,
@@ -112,6 +167,13 @@ function buildLayoutStyle(shell: ModuleShellState): string {
112
167
  bottomLeftCorner,
113
168
  bottomRightCorner,
114
169
  shortcutsOverlay,
170
+ header,
171
+ phoneTopBar,
172
+ phoneBottomBar,
173
+ overlays,
174
+ phone,
175
+ pinChrome = false,
176
+ path,
115
177
  children,
116
178
  }: Props = $props();
117
179
 
@@ -138,8 +200,180 @@ function buildLayoutStyle(shell: ModuleShellState): string {
138
200
  }
139
201
  const shortcutEdges: PanelEdge[] = ['top', 'left', 'bottom', 'right'];
140
202
 
203
+ const SIDE_EDGES = ['left', 'right'] as const;
204
+ type SideEdge = (typeof SIDE_EDGES)[number];
205
+
206
+ let mainElement: HTMLElement | undefined = $state();
207
+ let keyboardOpen = $state(false);
208
+ let formActions = $state(false);
209
+ let chromeHidden = $state(false);
210
+ let resizing = $state<SideEdge | null>(null);
211
+ const sideElements = $state<Partial<Record<SideEdge, HTMLElement>>>({});
212
+ let scrollChrome: ReturnType<typeof installScrollChrome> | null = null;
213
+
214
+ const viewport = $derived(shell.viewport);
215
+ const isPhone = $derived(viewport === 'phone');
216
+ const narrow = $derived(shell.viewport === 'phone');
217
+ $effect(() => {
218
+ const panel = sideElements.left;
219
+ if (!narrow || !edgeExpanded('left') || !panel) return;
220
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
221
+ let cancelled = false;
222
+ void tick().then(async () => {
223
+ // Inherited visibility also transitions on primitive buttons; wait for
224
+ // the drawer and its controls to be focusable before moving focus.
225
+ const animations = panel.getAnimations?.({ subtree: true }) ?? [];
226
+ await Promise.all(animations
227
+ .filter((animation) => animation.playState !== 'paused' && Number.isFinite(animation.effect?.getComputedTiming().endTime ?? 0))
228
+ .map((animation) => animation.finished.catch(() => {})));
229
+ if (cancelled || !narrow || !edgeExpanded('left') || !panel.isConnected) return;
230
+ panel.querySelector<HTMLElement>('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')?.focus();
231
+ });
232
+ return () => { cancelled = true; queueMicrotask(() => { if (opener?.isConnected) opener.focus(); }); };
233
+ });
234
+ const showPhoneTop = $derived(isPhone && Boolean(phoneTopBar));
235
+ const showHeader = $derived(Boolean(header) && !showPhoneTop);
236
+ const bottomBar = $derived<BottomBarMode>(
237
+ isPhone && phoneBottomBar
238
+ ? bottomBarMode({ formActions, keyboardOpen })
239
+ : 'none',
240
+ );
241
+ const phoneSurfaceOpen = $derived(
242
+ isPhone &&
243
+ SIDE_EDGES.some(
244
+ (edge) => panelState(edge) === 'expanded' && shell.isEdgeShown(edge),
245
+ ),
246
+ );
247
+ const chromeStyle = $derived(
248
+ [
249
+ `--smrt-admin-shell-header-track: ${showHeader ? 'var(--smrt-admin-shell-header-size)' : '0rem'}`,
250
+ `--smrt-admin-shell-phone-bar-track: ${bottomBar === 'nav' ? 'var(--smrt-admin-shell-phone-bar-size)' : '0rem'}`,
251
+ ].join('; '),
252
+ );
253
+
254
+ /**
255
+ * Tablet/desktop: the side edge that is open as an overlay (its
256
+ * `overlayMedia` matches), laid over the page above a scrim.
257
+ */
258
+ const overlayEdge = $derived<SideEdge | null>(
259
+ isPhone
260
+ ? null
261
+ : (SIDE_EDGES.find(
262
+ (edge) =>
263
+ panelState(edge) === 'expanded' &&
264
+ shell.isEdgeShown(edge) &&
265
+ shell.presentationFor(edge) === 'overlay',
266
+ ) ?? null),
267
+ );
268
+
269
+ // An overlay takes focus when it opens (unless something inside already
270
+ // has it) and hands it back to whatever opened it when it closes.
271
+ let overlayReturnFocus: HTMLElement | null = null;
272
+ let lastOverlayEdge: SideEdge | null = null;
273
+ /**
274
+ * An overlay that was just closed and is sliding back out: it keeps its
275
+ * expanded look (inert, above a fading scrim) until the animation ends.
276
+ */
277
+ let closingEdge = $state<SideEdge | null>(null);
278
+ let closingTimer: ReturnType<typeof setTimeout> | null = null;
279
+
280
+ function endClosing(): void {
281
+ if (closingTimer) clearTimeout(closingTimer);
282
+ closingTimer = null;
283
+ closingEdge = null;
284
+ }
285
+
286
+ function prefersReducedMotion(): boolean {
287
+ return (
288
+ typeof window !== 'undefined' &&
289
+ typeof window.matchMedia === 'function' &&
290
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
291
+ );
292
+ }
293
+
294
+ /** An edge shown open: expanded, or an overlay still sliding out. */
295
+ function shownOpen(edge: PanelEdge): boolean {
296
+ return edgeExpanded(edge) || closingEdge === edge;
297
+ }
298
+
299
+ $effect(() => {
300
+ const edge = overlayEdge;
301
+ untrack(() => {
302
+ if (edge === lastOverlayEdge) return;
303
+ const previous = lastOverlayEdge;
304
+ lastOverlayEdge = edge;
305
+ if (typeof document === 'undefined') return;
306
+ if (edge) {
307
+ endClosing();
308
+ const active = document.activeElement;
309
+ overlayReturnFocus =
310
+ active instanceof HTMLElement && active !== document.body
311
+ ? active
312
+ : null;
313
+ // Effects run after the DOM update, so the panel is already shown.
314
+ const panel = sideElements[edge];
315
+ if (panel && !panel.contains(document.activeElement)) {
316
+ panel.focus({ preventScroll: true });
317
+ }
318
+ return;
319
+ }
320
+ const panel = previous ? sideElements[previous] : undefined;
321
+ const active = document.activeElement;
322
+ const lost =
323
+ !active || active === document.body || Boolean(panel?.contains(active));
324
+ if (lost && overlayReturnFocus?.isConnected) {
325
+ overlayReturnFocus.focus({ preventScroll: true });
326
+ }
327
+ overlayReturnFocus = null;
328
+ // Closed (not just docked by a wider window): slide back out.
329
+ if (
330
+ previous &&
331
+ panelState(previous) !== 'expanded' &&
332
+ shell.presentationFor(previous) === 'overlay' &&
333
+ !isPhone &&
334
+ !prefersReducedMotion()
335
+ ) {
336
+ endClosing();
337
+ closingEdge = previous;
338
+ // Fallback when animationend never fires (hidden tab, no CSS).
339
+ closingTimer = setTimeout(endClosing, 600);
340
+ }
341
+ });
342
+ });
343
+
344
+ function onEdgeAnimationEnd(event: AnimationEvent, edge: SideEdge): void {
345
+ if (closingEdge === edge && event.target === event.currentTarget) {
346
+ endClosing();
347
+ }
348
+ }
349
+
350
+ function collapsePhoneSurfaces(): void {
351
+ for (const edge of SIDE_EDGES) {
352
+ if (panelState(edge) === 'expanded' && shell.isEdgeShown(edge)) {
353
+ shell.collapsePanel(edge);
354
+ }
355
+ }
356
+ }
357
+
141
358
  onMount(() => {
142
- void shell.hydrate();
359
+ const offViewport = installShellViewport(shell);
360
+ const offOverlayMedia = installShellOverlayMedia(shell);
361
+ void shell.hydrate().then(() => shell.closeOverlaidEdges());
362
+ const offKeyboard = phoneBottomBar
363
+ ? installKeyboardWatcher((open) => (keyboardOpen = open))
364
+ : () => {};
365
+ const offFormActions =
366
+ phoneBottomBar && mainElement
367
+ ? watchFormActionBar(mainElement, (present) => (formActions = present))
368
+ : () => {};
369
+ scrollChrome =
370
+ phoneTopBar && phone?.hideOnScroll !== false && mainElement
371
+ ? installScrollChrome(mainElement, {
372
+ onChange: (hidden) => (chromeHidden = hidden),
373
+ isPinned: () =>
374
+ pinChrome || phoneSurfaceOpen || shell.viewport !== 'phone',
375
+ })
376
+ : null;
143
377
 
144
378
  function handleKeydown(event: KeyboardEvent): void {
145
379
  if (event.key === 'Escape') {
@@ -167,9 +401,150 @@ function buildLayoutStyle(shell: ModuleShellState): string {
167
401
  }
168
402
 
169
403
  window.addEventListener('keydown', handleKeydown);
170
- return () => window.removeEventListener('keydown', handleKeydown);
404
+ return () => {
405
+ window.removeEventListener('keydown', handleKeydown);
406
+ offViewport();
407
+ offOverlayMedia();
408
+ if (closingTimer) clearTimeout(closingTimer);
409
+ offKeyboard();
410
+ offFormActions();
411
+ scrollChrome?.destroy();
412
+ scrollChrome = null;
413
+ };
414
+ });
415
+
416
+ // Pinning (a banner appears, a drawer opens, leaving phone) shows the bar.
417
+ $effect(() => {
418
+ if (pinChrome || phoneSurfaceOpen || !isPhone) chromeHidden = false;
171
419
  });
172
420
 
421
+ // Navigating on a phone closes drawers/sheets and brings the top bar back.
422
+ let lastPath: string | undefined;
423
+ $effect(() => {
424
+ const current = path;
425
+ untrack(() => {
426
+ if (lastPath !== undefined && current !== lastPath) {
427
+ if (shell.viewport === 'phone') collapsePhoneSurfaces();
428
+ scrollChrome?.reset();
429
+ chromeHidden = false;
430
+ }
431
+ lastPath = current;
432
+ });
433
+ });
434
+
435
+ // ---- Resizable side edges ------------------------------------------------
436
+
437
+ function remInPx(): number {
438
+ if (typeof window === 'undefined') return 16;
439
+ const size = Number.parseFloat(
440
+ window.getComputedStyle(document.documentElement).fontSize,
441
+ );
442
+ return Number.isFinite(size) && size > 0 ? size : 16;
443
+ }
444
+
445
+ function cssLengthToPx(length: string): number | null {
446
+ const match = /^\s*(-?\d*\.?\d+)(px|rem|em)?\s*$/.exec(length);
447
+ if (!match) return null;
448
+ const value = Number.parseFloat(match[1]);
449
+ return match[2] === 'rem' || match[2] === 'em' ? value * remInPx() : value;
450
+ }
451
+
452
+ /**
453
+ * The edge's expanded width in px: the resized width, else the configured
454
+ * `expandedSize` (measured when it is not a plain px/rem length).
455
+ */
456
+ function currentSize(edge: SideEdge): number {
457
+ const limits = shell.resizeLimits(edge);
458
+ const resized = shell.panelSize(edge);
459
+ if (resized !== null) return resized;
460
+ const width =
461
+ cssLengthToPx(shell.config.panels[edge].expandedSize) ??
462
+ sideElements[edge]?.getBoundingClientRect().width ??
463
+ 0;
464
+ return limits ? clampPanelSize(width, limits) : Math.round(width);
465
+ }
466
+
467
+ function canResize(edge: SideEdge): boolean {
468
+ return (
469
+ !isPhone &&
470
+ edgeExpanded(edge) &&
471
+ shell.presentationFor(edge) === 'push' &&
472
+ shell.resizeLimits(edge) !== null
473
+ );
474
+ }
475
+
476
+ function onResizeKeydown(event: KeyboardEvent, edge: SideEdge): void {
477
+ const limits = shell.resizeLimits(edge);
478
+ if (!limits) return;
479
+ const step = limits.step * (event.shiftKey ? 4 : 1);
480
+ const grow = edge === 'left' ? 'ArrowRight' : 'ArrowLeft';
481
+ const shrink = edge === 'left' ? 'ArrowLeft' : 'ArrowRight';
482
+ let next: number | null;
483
+ if (event.key === grow) next = currentSize(edge) + step;
484
+ else if (event.key === shrink) next = currentSize(edge) - step;
485
+ else if (event.key === 'Home') next = limits.min;
486
+ else if (event.key === 'End') next = limits.max;
487
+ else if (event.key === 'Enter') next = null;
488
+ else return;
489
+ event.preventDefault();
490
+ shell.setPanelSize(edge, next);
491
+ }
492
+
493
+ /** Tears down the drag in progress, if any (see onResizePointerdown). */
494
+ let stopActiveResize: ((commit: boolean) => void) | null = null;
495
+
496
+ // Unmounting mid-drag must not leave the handle's listeners driving a shell
497
+ // state that outlives this component, nor a stale `resizing` flag.
498
+ $effect(() => () => stopActiveResize?.(false));
499
+
500
+ function onResizePointerdown(event: PointerEvent, edge: SideEdge): void {
501
+ if (event.button !== 0 || !shell.resizeLimits(edge)) return;
502
+ event.preventDefault();
503
+ stopActiveResize?.(true);
504
+ const handle = event.currentTarget as HTMLElement;
505
+ const pointerId = event.pointerId;
506
+ if (pointerId !== undefined) handle.setPointerCapture?.(pointerId);
507
+ const startX = event.clientX;
508
+ const startSize = currentSize(edge);
509
+ let latest = startSize;
510
+ resizing = edge;
511
+
512
+ function onMove(move: PointerEvent): void {
513
+ const dx = move.clientX - startX;
514
+ latest = startSize + (edge === 'left' ? dx : -dx);
515
+ shell.setPanelSize(edge, latest, { persist: false });
516
+ }
517
+
518
+ function stop(commit: boolean): void {
519
+ if (stopActiveResize !== stop) return;
520
+ stopActiveResize = null;
521
+ handle.removeEventListener('pointermove', onMove);
522
+ handle.removeEventListener('pointerup', onEnd);
523
+ handle.removeEventListener('pointercancel', onEnd);
524
+ handle.removeEventListener('lostpointercapture', onEnd);
525
+ if (pointerId !== undefined && handle.hasPointerCapture?.(pointerId)) {
526
+ handle.releasePointerCapture(pointerId);
527
+ }
528
+ resizing = null;
529
+ if (commit && latest !== startSize) shell.setPanelSize(edge, latest);
530
+ }
531
+
532
+ // pointerup/pointercancel end a drag normally; losing the capture (a
533
+ // system gesture, the element leaving the DOM) ends it too, or the shell
534
+ // would stay in its resizing state with nothing to release it.
535
+ function onEnd(): void {
536
+ stop(true);
537
+ }
538
+
539
+ stopActiveResize = stop;
540
+ handle.addEventListener('pointermove', onMove);
541
+ handle.addEventListener('pointerup', onEnd);
542
+ handle.addEventListener('pointercancel', onEnd);
543
+ handle.addEventListener('lostpointercapture', onEnd);
544
+ }
545
+
546
+ const footerInHeader = $derived(!account && !!tenantFooter && (!shell.isEdgeShown('left') || !edgeExpanded('left')));
547
+
173
548
  const layoutStyle = $derived(buildLayoutStyle(shell));
174
549
 
175
550
  // Top/bottom edges reserve grid tracks for corner snippets. When a corner
@@ -194,6 +569,18 @@ function buildLayoutStyle(shell: ModuleShellState): string {
194
569
  return panelState(edge) === 'expanded';
195
570
  }
196
571
 
572
+ /** Collapsed edge whose panel content stays mounted (`keepMounted`). */
573
+ function keepsContent(edge: PanelEdge): boolean {
574
+ return (
575
+ panelState(edge) === 'collapsed' &&
576
+ shell.config.panels[edge].keepMounted === true
577
+ );
578
+ }
579
+
580
+ function panelMounted(edge: PanelEdge): boolean {
581
+ return shownOpen(edge) || keepsContent(edge);
582
+ }
583
+
197
584
  function labelFor(edge: PanelEdge): string {
198
585
  return shell.config.panels[edge].label;
199
586
  }
@@ -261,18 +648,43 @@ function buildLayoutStyle(shell: ModuleShellState): string {
261
648
  variant="ghost"
262
649
  size="sm"
263
650
  class="smrt-admin-shell__edge-toggle"
651
+ aria-label={edge === 'left' ? t(M[edgeExpanded(edge) ? 'ui.admin_shell.collapse_panel' : 'ui.admin_shell.expand_panel'], { panel: labelFor(edge) }) : undefined}
264
652
  aria-expanded={edgeExpanded(edge)}
265
653
  aria-controls={`smrt-admin-shell-${edge}-panel`}
266
654
  onclick={() => shell.togglePanel(edge)}
267
655
  >
268
- <span>{labelFor(edge)}</span>
269
- {#if showsHotkeyFor(edge)}
656
+ {#if edge === 'left' && !edgeExpanded(edge)}<span aria-hidden="true">›</span>{:else}<span>{labelFor(edge)}</span>{/if}
657
+ {#if showsHotkeyFor(edge) && (edge !== 'left' || edgeExpanded(edge))}
270
658
  <kbd class="smrt-admin-shell__edge-toggle-kbd">{hotkeyFor(edge)}</kbd>
271
659
  {/if}
272
660
  <ActivityBadge {edge} />
273
661
  </Button>
274
662
  {/snippet}
275
663
 
664
+ {#snippet resizer(edge: SideEdge)}
665
+ {#if canResize(edge)}
666
+ {@const limits = shell.resizeLimits(edge)}
667
+ <!-- A focusable separator is the WAI-ARIA window-splitter widget; Svelte's
668
+ role table lists `separator` as non-interactive only in its static form. -->
669
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
670
+ <div
671
+ class="smrt-admin-shell__resizer smrt-admin-shell__resizer--{edge}"
672
+ role="separator"
673
+ aria-orientation="vertical"
674
+ aria-controls={`smrt-admin-shell-${edge}-panel`}
675
+ aria-label={t(M['ui.admin_shell.resize_panel'], { label: labelFor(edge) })}
676
+ aria-valuemin={limits?.min}
677
+ aria-valuemax={limits?.max}
678
+ aria-valuenow={currentSize(edge)}
679
+ tabindex="0"
680
+ data-testid={`admin-shell-resizer-${edge}`}
681
+ onpointerdown={(event) => onResizePointerdown(event, edge)}
682
+ onkeydown={(event) => onResizeKeydown(event, edge)}
683
+ ondblclick={() => shell.setPanelSize(edge, null)}
684
+ ></div>
685
+ {/if}
686
+ {/snippet}
687
+
276
688
  {#snippet focusContent(tool: ShellFocusTool | null)}
277
689
  {#if focusPanel}
278
690
  {@render focusPanel({ tool })}
@@ -288,14 +700,52 @@ function buildLayoutStyle(shell: ModuleShellState): string {
288
700
  {/if}
289
701
  {/snippet}
290
702
 
703
+ {#snippet brandContent(compact: boolean)}
704
+ {#if brand}
705
+ {@render brand({ compact })}
706
+ {:else}
707
+ {#if logoSrc}<img class="smrt-admin-shell__logo" src={logoSrc} alt={logoAlt} />{/if}
708
+ {#if compact}
709
+ {#if !logoSrc}<span aria-hidden="true">{title.charAt(0)}</span>{/if}
710
+ {:else}
711
+ <div class="smrt-admin-shell__brand-text"><strong>{title}</strong>{#if subtitle}<span>{subtitle}</span>{/if}</div>
712
+ {/if}
713
+ {/if}
714
+ {/snippet}
715
+
716
+ {#snippet shellBrand(compact = false)}
717
+ {#if homeHref}
718
+ <a class="smrt-admin-shell__brand smrt-admin-shell__brand-link" class:compact href={homeHref} aria-label={title || t(M['ui.admin_shell.home'])}>
719
+ {@render brandContent(compact)}
720
+ </a>
721
+ {:else}
722
+ <div class="smrt-admin-shell__brand" class:compact>{@render brandContent(compact)}</div>
723
+ {/if}
724
+ {/snippet}
725
+
291
726
  <div
292
727
  class="smrt-admin-shell"
293
728
  data-top-state={panelState('top')}
294
729
  data-left-state={panelState('left')}
295
730
  data-right-state={panelState('right')}
296
731
  data-bottom-state={panelState('bottom')}
297
- style={layoutStyle}
732
+ data-viewport={viewport}
733
+ data-bottom-bar={bottomBar}
734
+ data-phone-top={showPhoneTop ? '' : undefined}
735
+ data-chrome-hidden={chromeHidden ? '' : undefined}
736
+ data-resizing={resizing ?? undefined}
737
+ style={`${layoutStyle}; ${chromeStyle}`}
298
738
  >
739
+ {#if showHeader}
740
+ <div
741
+ id="smrt-admin-shell-header"
742
+ class="smrt-admin-shell__header"
743
+ data-testid="admin-shell-header"
744
+ >
745
+ {@render header?.({ viewport })}
746
+ </div>
747
+ {/if}
748
+
299
749
  {#if panelState('top') !== 'hidden'}
300
750
  <header
301
751
  id="smrt-admin-shell-top-panel"
@@ -316,24 +766,30 @@ function buildLayoutStyle(shell: ModuleShellState): string {
316
766
  {#if appBar}
317
767
  {@render appBar()}
318
768
  {:else}
319
- <div class="smrt-admin-shell__brand">
320
- <strong>{title}</strong>
321
- {#if subtitle}
322
- <span>{subtitle}</span>
323
- {/if}
324
- </div>
769
+ {@render shellBrand()}
325
770
  {@render edgeToggle('top')}
326
771
  {/if}
772
+ {#if shell.config.panels.left.initial !== 'hidden' && (!isPhone || shell.phonePresentation('left') !== 'hidden')}
773
+ <div class="smrt-admin-shell__tenant-opener" class:restore-hidden={panelState('left') === 'hidden'}>
774
+ <Button variant="ghost" size="sm" aria-label={t(M['ui.admin_shell.menu'])} aria-expanded={edgeExpanded('left')} aria-controls="smrt-admin-shell-left-panel" onclick={() => shell.setPanelState('left', edgeExpanded('left') ? 'collapsed' : 'expanded')}>{t(M['ui.admin_shell.menu'])}</Button>
775
+ </div>
776
+ {/if}
777
+ {#if account || footerInHeader}
778
+ <div class="smrt-admin-shell__account">
779
+ {#if account}{@render account()}{:else if tenantFooter}{@render tenantFooter()}{/if}
780
+ </div>
781
+ {/if}
327
782
  </div>
328
783
  {#if topRightCorner}
329
784
  <div class="smrt-admin-shell__corner smrt-admin-shell__corner--top-right">
330
785
  {@render topRightCorner()}
331
786
  </div>
332
787
  {/if}
333
- {#if edgeExpanded('top')}
788
+ {#if panelMounted('top')}
334
789
  <section
335
790
  class="smrt-admin-shell__drawer smrt-admin-shell__drawer--top"
336
791
  aria-label={labelFor('top')}
792
+ hidden={!edgeExpanded('top')}
337
793
  >
338
794
  {#if appPanel}
339
795
  {@render appPanel()}
@@ -347,54 +803,145 @@ function buildLayoutStyle(shell: ModuleShellState): string {
347
803
  </header>
348
804
  {/if}
349
805
 
350
- {#if panelState('left') !== 'hidden'}
806
+ {#if shell.isEdgeShown('left')}
807
+ <!-- tabindex -1 only while overlaid, so the panel can take focus on open -->
808
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
351
809
  <aside
352
810
  id="smrt-admin-shell-left-panel"
353
811
  class="smrt-admin-shell__edge smrt-admin-shell__edge--left"
354
- data-state={panelState('left')}
355
- data-presentation={shell.config.panels.left.presentation}
812
+ data-state={closingEdge === 'left' ? 'expanded' : panelState('left')}
813
+ data-closing={closingEdge === 'left' ? '' : undefined}
814
+ data-presentation={shell.presentationFor('left')}
356
815
  role="navigation"
357
816
  aria-label={labelFor('left')}
817
+ tabindex={overlayEdge === 'left' ? -1 : undefined}
818
+ inert={(isPhone && !edgeExpanded('left')) ||
819
+ overlayEdge === 'right' ||
820
+ closingEdge === 'left'}
821
+ onanimationend={(event) => onEdgeAnimationEnd(event, 'left')}
822
+ bind:this={sideElements.left}
823
+ use:swipeDismiss={{
824
+ direction: 'left',
825
+ onDismiss: () => shell.collapsePanel('left'),
826
+ enabled: isPhone && Boolean(phone?.swipeToClose) && edgeExpanded('left'),
827
+ }}
358
828
  >
829
+ {@render resizer('left')}
359
830
  <div class="smrt-admin-shell__rail">
360
- {#if edgeExpanded('left')}
361
- <div class="smrt-admin-shell__tenant-stack">
831
+ {#if !edgeExpanded('left') && (homeHref || logoSrc || brand)}{@render shellBrand(true)}{/if}
832
+ {#if showTenantToggle}{@render edgeToggle('left')}{/if}
833
+ {#if shownOpen('left') || (tenantPanel && keepsContent('left'))}
834
+ <div
835
+ class="smrt-admin-shell__tenant-stack"
836
+ hidden={!shownOpen('left')}
837
+ >
362
838
  <div class="smrt-admin-shell__tenant-content">
363
839
  {#if tenantPanel}
364
840
  {@render tenantPanel()}
365
841
  {:else if tenantRail}
366
842
  {@render tenantRail()}
367
- {:else}
368
- {@render edgeToggle('left')}
369
843
  {/if}
370
844
  </div>
371
- {#if tenantFooter}
845
+ {#if tenantFooter && !footerInHeader}
372
846
  <div class="smrt-admin-shell__tenant-footer">
373
847
  {@render tenantFooter()}
374
848
  </div>
375
849
  {/if}
376
850
  </div>
377
- {:else if tenantRail}
851
+ {/if}
852
+ {#if !shownOpen('left') && tenantRail}
378
853
  {@render tenantRail()}
379
- {:else}
380
- {@render edgeToggle('left')}
381
854
  {/if}
382
855
  </div>
383
856
  </aside>
384
857
  {/if}
385
858
 
386
- <main class="smrt-admin-shell__main">
859
+ <main
860
+ id="smrt-admin-shell-main"
861
+ class="smrt-admin-shell__main"
862
+ bind:this={mainElement}
863
+ inert={overlayEdge !== null}
864
+ >
387
865
  {@render children()}
388
866
  </main>
389
867
 
390
- {#if panelState('right') !== 'hidden'}
868
+ {#if showPhoneTop}
869
+ <div class="smrt-admin-shell__phone-top" data-testid="admin-shell-phone-top">
870
+ {@render phoneTopBar?.({ hidden: chromeHidden })}
871
+ </div>
872
+ {/if}
873
+
874
+ {#if isPhone && phone?.scrim && phoneSurfaceOpen}
875
+ <!-- raw-primitive-allow: click-catching backdrop behind a phone drawer -->
876
+ <button
877
+ type="button"
878
+ class="smrt-admin-shell__scrim"
879
+ tabindex="-1"
880
+ aria-label={t(M['ui.admin_shell.close_panel'], {
881
+ label: labelFor(edgeExpanded('left') && shell.isEdgeShown('left') ? 'left' : 'right'),
882
+ })}
883
+ data-testid="admin-shell-scrim"
884
+ onclick={collapsePhoneSurfaces}
885
+ ></button>
886
+ {/if}
887
+
888
+ {#if overlayEdge || closingEdge}
889
+ {@const scrimEdge = (overlayEdge ?? closingEdge) as SideEdge}
890
+ <!-- raw-primitive-allow: click-catching backdrop behind an overlaid side panel -->
891
+ <button
892
+ type="button"
893
+ class="smrt-admin-shell__overlay-scrim"
894
+ tabindex="-1"
895
+ aria-label={t(M['ui.admin_shell.close_panel'], { label: labelFor(scrimEdge) })}
896
+ data-testid="admin-shell-overlay-scrim"
897
+ data-closing={overlayEdge ? undefined : ''}
898
+ onclick={() => {
899
+ if (overlayEdge) shell.collapsePanel(scrimEdge);
900
+ }}
901
+ ></button>
902
+ {/if}
903
+
904
+ {#if shell.isEdgeShown('right')}
905
+ {@const rightPhone = isPhone ? shell.phonePresentation('right') : undefined}
906
+ <!-- tabindex -1 only while overlaid, so the panel can take focus on open -->
907
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
391
908
  <aside
392
909
  id="smrt-admin-shell-right-panel"
393
910
  class="smrt-admin-shell__edge smrt-admin-shell__edge--right"
394
- data-state={panelState('right')}
395
- data-presentation={shell.config.panels.right.presentation}
911
+ data-state={closingEdge === 'right' ? 'expanded' : panelState('right')}
912
+ data-closing={closingEdge === 'right' ? '' : undefined}
913
+ data-presentation={shell.presentationFor('right')}
914
+ data-phone={rightPhone}
396
915
  aria-label={labelFor('right')}
916
+ tabindex={overlayEdge === 'right' ? -1 : undefined}
917
+ inert={(isPhone && !edgeExpanded('right')) ||
918
+ overlayEdge === 'left' ||
919
+ closingEdge === 'right'}
920
+ onanimationend={(event) => onEdgeAnimationEnd(event, 'right')}
921
+ bind:this={sideElements.right}
922
+ use:swipeDismiss={{
923
+ direction: 'right',
924
+ onDismiss: () => shell.collapsePanel('right'),
925
+ enabled:
926
+ rightPhone === 'drawer' &&
927
+ Boolean(phone?.swipeToClose) &&
928
+ edgeExpanded('right'),
929
+ }}
397
930
  >
931
+ {@render resizer('right')}
932
+ {#if rightPhone === 'sheet'}
933
+ <div
934
+ class="smrt-admin-shell__sheet-grabber"
935
+ aria-hidden="true"
936
+ use:swipeDismiss={{
937
+ direction: 'down',
938
+ onDismiss: () => shell.collapsePanel('right'),
939
+ enabled: edgeExpanded('right'),
940
+ }}
941
+ >
942
+ <span></span>
943
+ </div>
944
+ {/if}
398
945
  <div class="smrt-admin-shell__rail">
399
946
  {#if focusRail}
400
947
  {@render focusRail()}
@@ -430,8 +977,11 @@ function buildLayoutStyle(shell: ModuleShellState): string {
430
977
  {@render edgeToggle('right')}
431
978
  {/if}
432
979
  </div>
433
- {#if edgeExpanded('right')}
434
- <div class="smrt-admin-shell__panel smrt-admin-shell__panel--right">
980
+ {#if panelMounted('right')}
981
+ <div
982
+ class="smrt-admin-shell__panel smrt-admin-shell__panel--right"
983
+ hidden={!shownOpen('right')}
984
+ >
435
985
  {#key shell.activeFocusToolId}
436
986
  {@render focusContent(resolveActiveFocusTool())}
437
987
  {/key}
@@ -457,21 +1007,19 @@ function buildLayoutStyle(shell: ModuleShellState): string {
457
1007
  class="smrt-admin-shell__band smrt-admin-shell__band--bottom"
458
1008
  style:--band-column={2}
459
1009
  >
460
- {#if systemBar}
461
- {@render systemBar()}
462
- {:else}
463
- {@render edgeToggle('bottom')}
464
- {/if}
1010
+ {#if systemBar}{@render systemBar()}{/if}
1011
+ {@render edgeToggle('bottom')}
465
1012
  </div>
466
1013
  {#if bottomRightCorner}
467
1014
  <div class="smrt-admin-shell__corner smrt-admin-shell__corner--bottom-right">
468
1015
  {@render bottomRightCorner()}
469
1016
  </div>
470
1017
  {/if}
471
- {#if edgeExpanded('bottom')}
1018
+ {#if panelMounted('bottom')}
472
1019
  <section
473
1020
  class="smrt-admin-shell__drawer smrt-admin-shell__drawer--bottom"
474
1021
  aria-label={labelFor('bottom')}
1022
+ hidden={!edgeExpanded('bottom')}
475
1023
  >
476
1024
  {#if systemPanel}
477
1025
  {@render systemPanel()}
@@ -485,6 +1033,18 @@ function buildLayoutStyle(shell: ModuleShellState): string {
485
1033
  </footer>
486
1034
  {/if}
487
1035
 
1036
+ {#if overlays}
1037
+ <div class="smrt-admin-shell__overlays">
1038
+ {@render overlays({ viewport, bottomBar })}
1039
+ </div>
1040
+ {/if}
1041
+
1042
+ {#if bottomBar === 'nav'}
1043
+ <div class="smrt-admin-shell__phone-bar" data-testid="admin-shell-phone-bar">
1044
+ {@render phoneBottomBar?.()}
1045
+ </div>
1046
+ {/if}
1047
+
488
1048
  {#if shortcutsOpen}
489
1049
  <div
490
1050
  class="smrt-admin-shell__shortcuts"
@@ -537,19 +1097,63 @@ function buildLayoutStyle(shell: ModuleShellState): string {
537
1097
  --smrt-admin-shell-right-collapsed: 4.25rem;
538
1098
  --smrt-admin-shell-left-expanded: 16rem;
539
1099
  --smrt-admin-shell-right-expanded: 20rem;
1100
+ --smrt-admin-shell-header-size: 3.5rem;
1101
+ --smrt-admin-shell-header-track: 0rem;
1102
+ --smrt-admin-shell-phone-top-size: 3.5rem;
1103
+ --smrt-admin-shell-phone-bar-size: calc(3.5rem + env(safe-area-inset-bottom));
1104
+ --smrt-admin-shell-phone-bar-track: 0rem;
1105
+ --smrt-admin-shell-chrome-duration: var(--smrt-duration-short4, 200ms);
540
1106
  position: relative;
541
1107
  display: grid;
542
1108
  grid-template-columns:
543
1109
  var(--smrt-admin-shell-left-track) minmax(0, 1fr)
544
1110
  var(--smrt-admin-shell-right-track);
1111
+ /* header · top edge · body · bottom edge · phone bottom bar. The extra
1112
+ rows are 0 unless the header / phone bar render. */
545
1113
  grid-template-rows:
1114
+ var(--smrt-admin-shell-header-track)
546
1115
  var(--smrt-admin-shell-top-track) minmax(0, 1fr)
547
- var(--smrt-admin-shell-bottom-track);
1116
+ var(--smrt-admin-shell-bottom-track)
1117
+ var(--smrt-admin-shell-phone-bar-track);
548
1118
  min-block-size: 100svh;
549
1119
  block-size: 100svh;
550
- background: var(--smrt-color-surface);
1120
+ /* Themeable: an app sets --smrt-admin-shell-background on the shell or an
1121
+ ancestor (a tinted page behind cards, for instance). */
1122
+ background: var(--smrt-admin-shell-background, var(--smrt-color-surface));
551
1123
  color: var(--smrt-color-on-surface);
552
1124
  overflow: hidden;
1125
+ /* Themed, thin scrollbars for every scroller inside the shell. Override
1126
+ --smrt-admin-shell-scrollbar-track / -thumb / -thumb-hover to retint. */
1127
+ scrollbar-color: var(--smrt-admin-shell-scrollbar-thumb, color-mix(in srgb, var(--smrt-color-on-surface) 26%, transparent))
1128
+ var(--smrt-admin-shell-scrollbar-track, color-mix(in srgb, var(--smrt-color-surface-container-low) 88%, transparent));
1129
+ }
1130
+
1131
+ .smrt-admin-shell :global(*) {
1132
+ scrollbar-width: thin;
1133
+ }
1134
+
1135
+ .smrt-admin-shell :global(*)::-webkit-scrollbar {
1136
+ width: 0.7rem;
1137
+ height: 0.7rem;
1138
+ }
1139
+
1140
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-track {
1141
+ background: var(--smrt-admin-shell-scrollbar-track, color-mix(in srgb, var(--smrt-color-surface-container-low) 88%, transparent));
1142
+ }
1143
+
1144
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-thumb {
1145
+ background-color: var(--smrt-admin-shell-scrollbar-thumb, color-mix(in srgb, var(--smrt-color-on-surface) 26%, transparent));
1146
+ background-clip: padding-box;
1147
+ border: 2px solid transparent;
1148
+ border-radius: var(--smrt-radius-full, 999px);
1149
+ }
1150
+
1151
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-thumb:hover {
1152
+ background-color: var(--smrt-admin-shell-scrollbar-thumb-hover, color-mix(in srgb, var(--smrt-color-on-surface) 38%, transparent));
1153
+ }
1154
+
1155
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-corner {
1156
+ background: transparent;
553
1157
  }
554
1158
 
555
1159
  .smrt-admin-shell__edge {
@@ -560,9 +1164,30 @@ function buildLayoutStyle(shell: ModuleShellState): string {
560
1164
  border-color: var(--smrt-color-outline-variant);
561
1165
  }
562
1166
 
563
- .smrt-admin-shell__edge--top {
1167
+ .smrt-admin-shell__header {
564
1168
  grid-column: 1 / -1;
565
1169
  grid-row: 1;
1170
+ display: flex;
1171
+ align-items: center;
1172
+ gap: var(--smrt-spacing-2);
1173
+ box-sizing: border-box;
1174
+ min-inline-size: 0;
1175
+ min-block-size: 0;
1176
+ padding: 0 var(--smrt-spacing-3) 0 var(--smrt-spacing-2);
1177
+ /* Clip long titles sideways only: menus and popovers opened from the
1178
+ header's actions (a notifications bell, an account menu) must be able
1179
+ to drop below it. `clip` (unlike `hidden`) allows a visible block axis. */
1180
+ overflow-x: clip;
1181
+ overflow-y: visible;
1182
+ border-block-end: 1px solid var(--smrt-color-outline-variant);
1183
+ background: var(--smrt-color-surface-container-low);
1184
+ color: var(--smrt-color-on-surface);
1185
+ z-index: 30;
1186
+ }
1187
+
1188
+ .smrt-admin-shell__edge--top {
1189
+ grid-column: 1 / -1;
1190
+ grid-row: 2;
566
1191
  display: grid;
567
1192
  grid-template-columns: var(
568
1193
  --edge-columns,
@@ -574,15 +1199,17 @@ function buildLayoutStyle(shell: ModuleShellState): string {
574
1199
  }
575
1200
 
576
1201
  .smrt-admin-shell__edge--left {
1202
+ position: relative;
577
1203
  grid-column: 1;
578
- grid-row: 2;
1204
+ grid-row: 3;
579
1205
  border-inline-end: 1px solid var(--smrt-color-outline-variant);
580
1206
  z-index: 20;
581
1207
  }
582
1208
 
583
1209
  .smrt-admin-shell__edge--right {
1210
+ position: relative;
584
1211
  grid-column: 3;
585
- grid-row: 2;
1212
+ grid-row: 3;
586
1213
  border-inline-start: 1px solid var(--smrt-color-outline-variant);
587
1214
  z-index: 20;
588
1215
  }
@@ -596,7 +1223,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
596
1223
 
597
1224
  .smrt-admin-shell__edge--bottom {
598
1225
  grid-column: 1 / -1;
599
- grid-row: 3;
1226
+ grid-row: 4;
600
1227
  display: grid;
601
1228
  grid-template-columns: var(
602
1229
  --edge-columns,
@@ -609,7 +1236,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
609
1236
 
610
1237
  .smrt-admin-shell__main {
611
1238
  grid-column: 2;
612
- grid-row: 2;
1239
+ grid-row: 3;
613
1240
  min-width: 0;
614
1241
  min-height: 0;
615
1242
  overflow: auto;
@@ -631,9 +1258,17 @@ function buildLayoutStyle(shell: ModuleShellState): string {
631
1258
  }
632
1259
 
633
1260
  .smrt-admin-shell__brand {
634
- display: grid;
1261
+ display: flex;
1262
+ align-items: center;
1263
+ gap: var(--smrt-spacing-2);
635
1264
  min-width: 0;
1265
+ color: inherit;
1266
+ text-decoration: none;
636
1267
  }
1268
+ .smrt-admin-shell__brand-text { display: grid; min-width: 0; }
1269
+ .smrt-admin-shell__brand.compact { justify-content: center; margin-block-end: var(--smrt-spacing-2); }
1270
+ .smrt-admin-shell__brand-link:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 2px; }
1271
+ .smrt-admin-shell__logo { inline-size: 2rem; block-size: 2rem; object-fit: contain; flex-shrink: 0; }
637
1272
 
638
1273
  .smrt-admin-shell__brand strong,
639
1274
  .smrt-admin-shell__brand span {
@@ -656,6 +1291,15 @@ function buildLayoutStyle(shell: ModuleShellState): string {
656
1291
  padding: var(--smrt-spacing-3);
657
1292
  }
658
1293
 
1294
+ /* A right rail never pads wider than its own track: a rail collapsed to 0
1295
+ (an app that shows nothing there) paints no padding. */
1296
+ .smrt-admin-shell__edge--right .smrt-admin-shell__rail {
1297
+ padding-inline: min(
1298
+ var(--smrt-spacing-3),
1299
+ calc(var(--smrt-admin-shell-right-collapsed) / 4)
1300
+ );
1301
+ }
1302
+
659
1303
  .smrt-admin-shell__focus-rail {
660
1304
  display: grid;
661
1305
  /* Center tracks, not just items: buttons may be wider than the
@@ -704,13 +1348,43 @@ function buildLayoutStyle(shell: ModuleShellState): string {
704
1348
  overflow: hidden;
705
1349
  }
706
1350
 
1351
+ .smrt-admin-shell__account {
1352
+ min-inline-size: 0;
1353
+ max-inline-size: min(20rem, 45vw);
1354
+ flex: 1 1 12rem;
1355
+ }
1356
+ .smrt-admin-shell__account :global(.smrt-workspace-account-menu .dropdown__menu) {
1357
+ top: 100%; bottom: auto; right: 0; left: auto;
1358
+ inline-size: max-content;
1359
+ max-inline-size: calc(100vw - 2 * var(--smrt-spacing-4));
1360
+ }
1361
+
1362
+ .smrt-admin-shell__tenant-opener { display: none; flex-shrink: 0; }
1363
+ .smrt-admin-shell__tenant-opener.restore-hidden { display: block; }
1364
+ .smrt-admin-shell__edge--left[data-state='collapsed'] .smrt-admin-shell__rail { padding-inline: var(--smrt-spacing-2); }
1365
+
1366
+ .smrt-admin-shell__edge--left .smrt-admin-shell__rail {
1367
+ display: flex;
1368
+ flex-direction: column;
1369
+ gap: var(--smrt-spacing-2);
1370
+ }
1371
+
1372
+ /* A collapsed `keepMounted` edge keeps its panel in the DOM but hidden;
1373
+ the panel classes set `display`, which would otherwise win over the
1374
+ `hidden` attribute. */
1375
+ .smrt-admin-shell__tenant-stack[hidden],
1376
+ .smrt-admin-shell__panel[hidden],
1377
+ .smrt-admin-shell__drawer[hidden] {
1378
+ display: none !important;
1379
+ }
1380
+
707
1381
  .smrt-admin-shell__tenant-stack {
1382
+ flex: 1;
708
1383
  display: grid;
709
1384
  grid-template-rows: minmax(0, 1fr) auto;
710
1385
  gap: var(--smrt-spacing-3);
711
1386
  min-width: 0;
712
1387
  min-height: 0;
713
- block-size: 100%;
714
1388
  }
715
1389
 
716
1390
  .smrt-admin-shell__tenant-content {
@@ -764,14 +1438,19 @@ function buildLayoutStyle(shell: ModuleShellState): string {
764
1438
  }
765
1439
 
766
1440
  .smrt-admin-shell__drawer--top {
767
- inset-block-start: var(--smrt-admin-shell-top-track);
1441
+ inset-block-start: calc(
1442
+ var(--smrt-admin-shell-header-track) + var(--smrt-admin-shell-top-track)
1443
+ );
768
1444
  inset-inline: var(--smrt-admin-shell-left-track)
769
1445
  var(--smrt-admin-shell-right-track);
770
1446
  max-block-size: var(--smrt-admin-shell-top-expanded);
771
1447
  }
772
1448
 
773
1449
  .smrt-admin-shell__drawer--bottom {
774
- inset-block-end: var(--smrt-admin-shell-bottom-track);
1450
+ inset-block-end: calc(
1451
+ var(--smrt-admin-shell-bottom-track) +
1452
+ var(--smrt-admin-shell-phone-bar-track)
1453
+ );
775
1454
  /* The system scope is the application footer. Its expanded drawer must
776
1455
  remain footer-wide while it rises above every desktop pane. */
777
1456
  inset-inline: 0;
@@ -782,7 +1461,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
782
1461
 
783
1462
  @keyframes smrt-admin-shell-bottom-drawer-in {
784
1463
  from {
785
- transform: translateY(100%);
1464
+ clip-path: inset(100% 0 0);
786
1465
  }
787
1466
  }
788
1467
 
@@ -848,20 +1527,335 @@ function buildLayoutStyle(shell: ModuleShellState): string {
848
1527
  font-family: var(--smrt-font-family-mono);
849
1528
  }
850
1529
 
1530
+ /* ---- Resizable side edges ------------------------------------------ */
1531
+
1532
+ .smrt-admin-shell__resizer {
1533
+ position: absolute;
1534
+ inset-block: 0;
1535
+ z-index: 2;
1536
+ inline-size: 0.75rem;
1537
+ cursor: col-resize;
1538
+ touch-action: none;
1539
+ }
1540
+
1541
+ .smrt-admin-shell__resizer--left {
1542
+ inset-inline-end: -0.375rem;
1543
+ }
1544
+
1545
+ .smrt-admin-shell__resizer--right {
1546
+ inset-inline-start: -0.375rem;
1547
+ }
1548
+
1549
+ .smrt-admin-shell__resizer::after {
1550
+ content: '';
1551
+ position: absolute;
1552
+ inset-block: 0;
1553
+ inset-inline-start: calc(50% - 1px);
1554
+ inline-size: 2px;
1555
+ background: var(--smrt-color-primary);
1556
+ opacity: 0;
1557
+ transition: opacity var(--smrt-duration-short2, 100ms)
1558
+ var(--smrt-easing-standard, ease);
1559
+ }
1560
+
1561
+ .smrt-admin-shell__resizer:hover::after,
1562
+ .smrt-admin-shell__resizer:focus-visible::after,
1563
+ .smrt-admin-shell[data-resizing] .smrt-admin-shell__resizer::after {
1564
+ opacity: 1;
1565
+ }
1566
+
1567
+ .smrt-admin-shell__resizer:focus-visible {
1568
+ outline: none;
1569
+ }
1570
+
1571
+ .smrt-admin-shell[data-resizing] {
1572
+ cursor: col-resize;
1573
+ user-select: none;
1574
+ }
1575
+
1576
+ /* ---- Phone chrome ----------------------------------------------------- */
1577
+
1578
+ .smrt-admin-shell__phone-top {
1579
+ grid-column: 1 / -1;
1580
+ grid-row: 3;
1581
+ align-self: start;
1582
+ z-index: 10;
1583
+ min-inline-size: 0;
1584
+ transition: transform var(--smrt-admin-shell-chrome-duration)
1585
+ var(--smrt-easing-standard, ease);
1586
+ }
1587
+
1588
+ .smrt-admin-shell[data-chrome-hidden] .smrt-admin-shell__phone-top {
1589
+ transform: translateY(-100%);
1590
+ }
1591
+
1592
+ .smrt-admin-shell[data-phone-top] .smrt-admin-shell__main {
1593
+ padding-block-start: var(--smrt-admin-shell-phone-top-size);
1594
+ scroll-padding-block-start: var(--smrt-admin-shell-phone-top-size);
1595
+ }
1596
+
1597
+ .smrt-admin-shell__phone-bar {
1598
+ grid-column: 1 / -1;
1599
+ grid-row: 5;
1600
+ z-index: 30;
1601
+ min-inline-size: 0;
1602
+ }
1603
+
1604
+ .smrt-admin-shell__overlays {
1605
+ position: relative;
1606
+ grid-column: 1 / -1;
1607
+ grid-row: 1 / 5;
1608
+ z-index: 35;
1609
+ min-inline-size: 0;
1610
+ min-block-size: 0;
1611
+ pointer-events: none;
1612
+ }
1613
+
1614
+ .smrt-admin-shell__overlays > :global(*) {
1615
+ pointer-events: auto;
1616
+ }
1617
+
1618
+ .smrt-admin-shell__scrim {
1619
+ grid-column: 1 / -1;
1620
+ grid-row: 1 / 5;
1621
+ z-index: 19;
1622
+ margin: 0;
1623
+ padding: 0;
1624
+ border: 0;
1625
+ background: var(--smrt-color-scrim);
1626
+ opacity: 0.36;
1627
+ cursor: pointer;
1628
+ }
1629
+
1630
+ /* Tablet/desktop overlay: an expanded side edge whose `overlayMedia`
1631
+ matches slides over the page instead of taking a grid track. */
1632
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-state='expanded'],
1633
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-state='expanded'] {
1634
+ grid-column: 1 / -1;
1635
+ z-index: 25;
1636
+ box-shadow: var(
1637
+ --smrt-elevation-3,
1638
+ 0 0.5rem 1.5rem color-mix(in srgb, var(--smrt-color-shadow, #000) 24%, transparent)
1639
+ );
1640
+ outline: none;
1641
+ }
1642
+
1643
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-state='expanded'] {
1644
+ justify-self: start;
1645
+ inline-size: min(var(--smrt-admin-shell-left-expanded), 100%);
1646
+ animation: smrt-admin-shell-slide-from-left
1647
+ var(--smrt-admin-shell-chrome-duration) ease-out;
1648
+ }
1649
+
1650
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-state='expanded'] {
1651
+ justify-self: end;
1652
+ inline-size: min(var(--smrt-admin-shell-right-expanded), 100%);
1653
+ animation: smrt-admin-shell-slide-from-right
1654
+ var(--smrt-admin-shell-chrome-duration) ease-out;
1655
+ }
1656
+
1657
+ .smrt-admin-shell__overlay-scrim {
1658
+ grid-column: 1 / -1;
1659
+ grid-row: 3;
1660
+ z-index: 22;
1661
+ margin: 0;
1662
+ padding: 0;
1663
+ border: 0;
1664
+ background: var(--smrt-color-scrim);
1665
+ opacity: 0.24;
1666
+ cursor: pointer;
1667
+ animation: smrt-admin-shell-scrim-in var(--smrt-admin-shell-chrome-duration)
1668
+ ease-out;
1669
+ }
1670
+
1671
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-closing] {
1672
+ animation: smrt-admin-shell-slide-to-left
1673
+ var(--smrt-admin-shell-chrome-duration) ease-in forwards;
1674
+ }
1675
+
1676
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-closing] {
1677
+ animation: smrt-admin-shell-slide-to-right
1678
+ var(--smrt-admin-shell-chrome-duration) ease-in forwards;
1679
+ }
1680
+
1681
+ .smrt-admin-shell__overlay-scrim[data-closing] {
1682
+ pointer-events: none;
1683
+ animation: smrt-admin-shell-scrim-out
1684
+ var(--smrt-admin-shell-chrome-duration) ease-in forwards;
1685
+ }
1686
+
1687
+ @keyframes smrt-admin-shell-slide-to-right {
1688
+ to {
1689
+ transform: translateX(100%);
1690
+ }
1691
+ }
1692
+
1693
+ @keyframes smrt-admin-shell-slide-to-left {
1694
+ to {
1695
+ transform: translateX(-100%);
1696
+ }
1697
+ }
1698
+
1699
+ @keyframes smrt-admin-shell-scrim-out {
1700
+ to {
1701
+ opacity: 0;
1702
+ }
1703
+ }
1704
+
1705
+ @keyframes smrt-admin-shell-slide-from-right {
1706
+ from {
1707
+ transform: translateX(100%);
1708
+ }
1709
+ }
1710
+
1711
+ @keyframes smrt-admin-shell-slide-from-left {
1712
+ from {
1713
+ transform: translateX(-100%);
1714
+ }
1715
+ }
1716
+
1717
+ @keyframes smrt-admin-shell-scrim-in {
1718
+ from {
1719
+ opacity: 0;
1720
+ }
1721
+ }
1722
+
1723
+ @media (prefers-reduced-motion: reduce) {
1724
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-state='expanded'],
1725
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-state='expanded'],
1726
+ .smrt-admin-shell__edge[data-presentation='overlay'][data-closing],
1727
+ .smrt-admin-shell__overlay-scrim {
1728
+ animation: none;
1729
+ }
1730
+ }
1731
+
1732
+ .smrt-admin-shell__sheet-grabber {
1733
+ display: grid;
1734
+ place-items: center;
1735
+ block-size: 1.5rem;
1736
+ touch-action: pan-x;
1737
+ }
1738
+
1739
+ .smrt-admin-shell__sheet-grabber span {
1740
+ inline-size: 2.5rem;
1741
+ block-size: 0.25rem;
1742
+ border-radius: var(--smrt-radius-full);
1743
+ background: var(--smrt-color-outline-variant);
1744
+ }
1745
+
1746
+ /* Phone page contracts: breadcrumbs give way to the top bar, and so does
1747
+ the page title (visually hidden: screen readers and the outline keep
1748
+ it); page tabs stick under it and slide away with it; a form's action
1749
+ row is fixed to the bottom and replaces the bottom bar. */
1750
+ .smrt-admin-shell[data-viewport='phone'] :global([data-shell-breadcrumbs]) {
1751
+ display: none;
1752
+ }
1753
+
1754
+ .smrt-admin-shell[data-phone-top] :global([data-shell-page-title]) {
1755
+ position: absolute;
1756
+ inline-size: 1px;
1757
+ block-size: 1px;
1758
+ margin: -1px;
1759
+ padding: 0;
1760
+ overflow: hidden;
1761
+ clip-path: inset(50%);
1762
+ white-space: nowrap;
1763
+ border: 0;
1764
+ }
1765
+
1766
+ /* Tabs inside a page header (a content item's tabs under its title) stick
1767
+ like any page tabs: the header's boxes step aside on phones so the tabs'
1768
+ sticky range is the page, not the header. */
1769
+ .smrt-admin-shell[data-phone-top]
1770
+ :global([data-page-header]:has([data-shell-tabs])),
1771
+ .smrt-admin-shell[data-phone-top]
1772
+ :global([data-page-header-extra]:has([data-shell-tabs])) {
1773
+ display: contents;
1774
+ }
1775
+
1776
+ .smrt-admin-shell[data-phone-top] :global([data-shell-tabs]) {
1777
+ position: sticky;
1778
+ inset-block-start: var(--smrt-admin-shell-phone-top-size);
1779
+ z-index: 6;
1780
+ background: var(--smrt-color-surface);
1781
+ transition: transform var(--smrt-admin-shell-chrome-duration)
1782
+ var(--smrt-easing-standard, ease);
1783
+ }
1784
+
1785
+ .smrt-admin-shell[data-phone-top][data-chrome-hidden]
1786
+ :global([data-shell-tabs]) {
1787
+ transform: translateY(
1788
+ calc(-100% - var(--smrt-admin-shell-phone-top-size))
1789
+ );
1790
+ }
1791
+
1792
+ .smrt-admin-shell[data-viewport='phone'] :global([data-form-action-bar]) {
1793
+ position: fixed;
1794
+ inset: auto 0 0;
1795
+ z-index: 12;
1796
+ display: flex;
1797
+ flex-flow: row nowrap;
1798
+ align-items: center;
1799
+ gap: var(--smrt-spacing-2);
1800
+ margin: 0;
1801
+ padding: var(--smrt-spacing-2) var(--smrt-spacing-4)
1802
+ calc(var(--smrt-spacing-2) + env(safe-area-inset-bottom));
1803
+ border-block-start: 1px solid var(--smrt-color-outline-variant);
1804
+ background: var(--smrt-color-surface);
1805
+ }
1806
+
1807
+ .smrt-admin-shell[data-viewport='phone'] :global([data-form-action-bar] > *) {
1808
+ flex: 1 1 0;
1809
+ min-block-size: 2.75rem;
1810
+ }
1811
+
1812
+ :global(:root[data-keyboard-open])
1813
+ .smrt-admin-shell[data-viewport='phone']
1814
+ :global([data-form-action-bar]) {
1815
+ display: none;
1816
+ }
1817
+
1818
+ .smrt-admin-shell[data-bottom-bar='form'] .smrt-admin-shell__main {
1819
+ padding-block-end: calc(5rem + env(safe-area-inset-bottom));
1820
+ }
1821
+
1822
+ /* The phone bottom bar sits at the bottom of the dynamic viewport so the
1823
+ browser's collapsing toolbar never hides it. */
1824
+ .smrt-admin-shell[data-bottom-bar='nav'],
1825
+ .smrt-admin-shell[data-bottom-bar='form'] {
1826
+ min-block-size: 100dvh;
1827
+ block-size: 100dvh;
1828
+ }
1829
+
851
1830
  @media (max-width: 48rem) {
852
1831
  .smrt-admin-shell {
853
1832
  grid-template-columns: 0 minmax(0, 1fr) 0;
854
1833
  grid-template-rows:
1834
+ var(--smrt-admin-shell-header-track)
855
1835
  var(--smrt-admin-shell-top-track) minmax(0, 1fr)
856
- var(--smrt-admin-shell-bottom-track);
1836
+ var(--smrt-admin-shell-bottom-track)
1837
+ var(--smrt-admin-shell-phone-bar-track);
857
1838
  }
858
1839
 
1840
+ .smrt-admin-shell__resizer {
1841
+ display: none;
1842
+ }
1843
+
1844
+ .smrt-admin-shell__tenant-opener { display: block; }
1845
+ .smrt-admin-shell__edge--left:not([data-state='expanded']),
1846
+ .smrt-admin-shell__edge--right:not([data-state='expanded']) { visibility: hidden; }
859
1847
  .smrt-admin-shell__edge--left,
860
1848
  .smrt-admin-shell__edge--right {
861
1849
  position: absolute;
862
1850
  grid-area: auto;
863
- inset-block: var(--smrt-admin-shell-top-track)
864
- var(--smrt-admin-shell-bottom-track);
1851
+ inset-block: calc(
1852
+ var(--smrt-admin-shell-header-track) +
1853
+ var(--smrt-admin-shell-top-track)
1854
+ )
1855
+ calc(
1856
+ var(--smrt-admin-shell-bottom-track) +
1857
+ var(--smrt-admin-shell-phone-bar-track)
1858
+ );
865
1859
  inline-size: min(22rem, 86vw);
866
1860
  transform: translateX(-100%);
867
1861
  transition: transform var(--smrt-duration-short2)
@@ -882,6 +1876,46 @@ function buildLayoutStyle(shell: ModuleShellState): string {
882
1876
  transform: translateX(0);
883
1877
  }
884
1878
 
1879
+ /* Right edge as a bottom sheet. */
1880
+ .smrt-admin-shell__edge--right[data-phone='sheet'] {
1881
+ display: grid;
1882
+ grid-template-columns: minmax(0, 1fr);
1883
+ grid-template-rows: auto minmax(0, 1fr);
1884
+ inset-inline: 0;
1885
+ inset-block-start: calc(
1886
+ var(--smrt-admin-shell-header-track) +
1887
+ var(--smrt-admin-shell-top-track) + var(--smrt-spacing-8)
1888
+ );
1889
+ inline-size: auto;
1890
+ border-inline-start: 0;
1891
+ border-start-start-radius: var(--smrt-radius-lg);
1892
+ border-start-end-radius: var(--smrt-radius-lg);
1893
+ background: var(--smrt-color-surface);
1894
+ box-shadow: var(--smrt-elevation-3);
1895
+ transform: translateY(100%);
1896
+ visibility: hidden;
1897
+ transition:
1898
+ transform var(--smrt-duration-short2) var(--smrt-easing-standard),
1899
+ visibility 0s linear var(--smrt-duration-short2);
1900
+ }
1901
+
1902
+ .smrt-admin-shell__edge--right[data-phone='sheet'][data-state='expanded'] {
1903
+ grid-template-columns: minmax(0, 1fr);
1904
+ transform: none;
1905
+ visibility: visible;
1906
+ transition: transform var(--smrt-duration-short2)
1907
+ var(--smrt-easing-standard);
1908
+ }
1909
+
1910
+ .smrt-admin-shell__edge--right[data-phone='sheet'] .smrt-admin-shell__rail {
1911
+ display: none;
1912
+ }
1913
+
1914
+ .smrt-admin-shell__edge--right[data-phone='sheet'] .smrt-admin-shell__panel--right {
1915
+ grid-column: 1;
1916
+ grid-row: 2;
1917
+ }
1918
+
885
1919
  .smrt-admin-shell__edge--top,
886
1920
  .smrt-admin-shell__edge--bottom {
887
1921
  grid-template-columns: minmax(0, 1fr);
@@ -917,8 +1951,14 @@ function buildLayoutStyle(shell: ModuleShellState): string {
917
1951
  }
918
1952
 
919
1953
  @media (prefers-reduced-motion: reduce) {
1954
+ .smrt-admin-shell {
1955
+ --smrt-admin-shell-chrome-duration: 0ms;
1956
+ }
1957
+
920
1958
  .smrt-admin-shell__edge--left,
921
- .smrt-admin-shell__edge--right {
1959
+ .smrt-admin-shell__edge--right,
1960
+ .smrt-admin-shell__edge--right[data-phone='sheet'],
1961
+ .smrt-admin-shell__edge--right[data-phone='sheet'][data-state='expanded'] {
922
1962
  transition: none;
923
1963
  }
924
1964