@happyvertical/smrt-svelte 0.51.39 → 0.52.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 (114) hide show
  1. package/AGENTS.md +3 -0
  2. package/README.md +167 -1
  3. package/agents/workspace.md +24 -0
  4. package/dist/browser-ai/adapters/stt/browser-speech.d.ts +11 -0
  5. package/dist/browser-ai/adapters/stt/browser-speech.d.ts.map +1 -1
  6. package/dist/browser-ai/adapters/stt/browser-speech.js +20 -4
  7. package/dist/browser-ai/adapters/stt/browser-speech.test.js +113 -0
  8. package/dist/browser-ai/adapters/stt/dictation-source.d.ts +3 -0
  9. package/dist/browser-ai/adapters/stt/dictation-source.d.ts.map +1 -0
  10. package/dist/browser-ai/adapters/stt/dictation-source.js +38 -0
  11. package/dist/browser-ai/adapters/stt/dictation-source.test.js +52 -0
  12. package/dist/browser-ai/adapters/stt/index.d.ts +2 -1
  13. package/dist/browser-ai/adapters/stt/index.d.ts.map +1 -1
  14. package/dist/browser-ai/adapters/stt/index.js +2 -1
  15. package/dist/components/forms/Form.svelte +118 -8
  16. package/dist/components/forms/Form.svelte.d.ts +18 -2
  17. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  18. package/dist/components/forms/FormScope.svelte +202 -0
  19. package/dist/components/forms/FormScope.svelte.d.ts +22 -0
  20. package/dist/components/forms/FormScope.svelte.d.ts.map +1 -0
  21. package/dist/components/forms/__tests__/Form.ui-controls.test.js +119 -0
  22. package/dist/components/forms/__tests__/Form.webmcp.test.js +5 -3
  23. package/dist/components/forms/__tests__/FormScope.test.js +39 -0
  24. package/dist/components/forms/__tests__/form-scope.fixture.svelte +30 -0
  25. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts +10 -0
  26. package/dist/components/forms/__tests__/form-scope.fixture.svelte.d.ts.map +1 -0
  27. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte +84 -0
  28. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts +14 -0
  29. package/dist/components/forms/__tests__/form-with-ui-controls.fixture.svelte.d.ts.map +1 -0
  30. package/dist/components/forms/index.d.ts +1 -0
  31. package/dist/components/forms/index.d.ts.map +1 -1
  32. package/dist/components/forms/index.js +1 -0
  33. package/dist/components/workspace/README.md +11 -0
  34. package/dist/components/workspace/__tests__/AdminShell.responsive.test.js +586 -0
  35. package/dist/components/workspace/__tests__/AdminShell.test.js +85 -0
  36. package/dist/components/workspace/__tests__/TenantNav.test.js +49 -0
  37. package/dist/components/workspace/__tests__/admin-shell-header-overflow.test.js +25 -0
  38. package/dist/components/workspace/__tests__/admin-shell-mobile.test.js +217 -0
  39. package/dist/components/workspace/__tests__/admin-shell-page-contracts.test.js +30 -0
  40. package/dist/components/workspace/__tests__/admin-shell-responsive-state.test.js +184 -0
  41. package/dist/components/workspace/__tests__/admin-shell-theme-hooks.test.js +36 -0
  42. package/dist/components/workspace/__tests__/shell-chrome-components.test.js +108 -0
  43. package/dist/components/workspace/admin-shell/AdminShell.svelte +998 -40
  44. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +42 -1
  45. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  46. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte +170 -0
  47. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts +11 -0
  48. package/dist/components/workspace/admin-shell/PhoneBottomBar.svelte.d.ts.map +1 -0
  49. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte +93 -0
  50. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts +11 -0
  51. package/dist/components/workspace/admin-shell/PhoneTopBar.svelte.d.ts.map +1 -0
  52. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte +88 -0
  53. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts +14 -0
  54. package/dist/components/workspace/admin-shell/ShellNavToggle.svelte.d.ts.map +1 -0
  55. package/dist/components/workspace/admin-shell/ShellTitle.svelte +42 -0
  56. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts +11 -0
  57. package/dist/components/workspace/admin-shell/ShellTitle.svelte.d.ts.map +1 -0
  58. package/dist/components/workspace/admin-shell/TenantNav.svelte +46 -1
  59. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
  60. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts +37 -0
  61. package/dist/components/workspace/admin-shell/mobile-shell-dom.d.ts.map +1 -0
  62. package/dist/components/workspace/admin-shell/mobile-shell-dom.js +150 -0
  63. package/dist/components/workspace/admin-shell/mobile-shell.d.ts +191 -0
  64. package/dist/components/workspace/admin-shell/mobile-shell.d.ts.map +1 -0
  65. package/dist/components/workspace/admin-shell/mobile-shell.js +165 -0
  66. package/dist/components/workspace/admin-shell/settings.d.ts +27 -2
  67. package/dist/components/workspace/admin-shell/settings.d.ts.map +1 -1
  68. package/dist/components/workspace/admin-shell/settings.js +84 -2
  69. package/dist/components/workspace/admin-shell/state.svelte.d.ts +55 -1
  70. package/dist/components/workspace/admin-shell/state.svelte.d.ts.map +1 -1
  71. package/dist/components/workspace/admin-shell/state.svelte.js +149 -6
  72. package/dist/components/workspace/admin-shell/types.d.ts +97 -0
  73. package/dist/components/workspace/admin-shell/types.d.ts.map +1 -1
  74. package/dist/components/workspace/admin-shell/types.js +13 -0
  75. package/dist/components/workspace/index.d.ts +11 -4
  76. package/dist/components/workspace/index.d.ts.map +1 -1
  77. package/dist/components/workspace/index.js +12 -3
  78. package/dist/i18n/strings.workspace.d.ts +10 -0
  79. package/dist/i18n/strings.workspace.d.ts.map +1 -1
  80. package/dist/i18n/strings.workspace.js +10 -0
  81. package/dist/index.d.ts +1 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +2 -2
  85. package/dist/web/__tests__/list-sort-surface.fixture.svelte +41 -0
  86. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts +15 -0
  87. package/dist/web/__tests__/list-sort-surface.fixture.svelte.d.ts.map +1 -0
  88. package/dist/web/__tests__/list-sort-surface.svelte.test.js +59 -0
  89. package/dist/web/__tests__/surfaces.child.fixture.svelte +69 -0
  90. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts +16 -0
  91. package/dist/web/__tests__/surfaces.child.fixture.svelte.d.ts.map +1 -0
  92. package/dist/web/__tests__/surfaces.fixture.svelte +26 -0
  93. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts +18 -0
  94. package/dist/web/__tests__/surfaces.fixture.svelte.d.ts.map +1 -0
  95. package/dist/web/__tests__/surfaces.svelte.test.js +140 -0
  96. package/dist/web/__tests__/webmcp-ui.test.js +18 -0
  97. package/dist/web/index.d.ts +4 -1
  98. package/dist/web/index.d.ts.map +1 -1
  99. package/dist/web/index.js +4 -1
  100. package/dist/web/link-surface.svelte.d.ts +14 -0
  101. package/dist/web/link-surface.svelte.d.ts.map +1 -0
  102. package/dist/web/link-surface.svelte.js +49 -0
  103. package/dist/web/list-surface.svelte.d.ts +80 -0
  104. package/dist/web/list-surface.svelte.d.ts.map +1 -0
  105. package/dist/web/list-surface.svelte.js +309 -0
  106. package/dist/web/step-surface.svelte.d.ts +17 -0
  107. package/dist/web/step-surface.svelte.d.ts.map +1 -0
  108. package/dist/web/step-surface.svelte.js +56 -0
  109. package/dist/web/webmcp-ui-context.d.ts +7 -0
  110. package/dist/web/webmcp-ui-context.d.ts.map +1 -1
  111. package/dist/web/webmcp-ui-context.js +10 -0
  112. package/dist/web/webmcp-ui.d.ts.map +1 -1
  113. package/dist/web/webmcp-ui.js +16 -3
  114. package/package.json +16 -16
@@ -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,6 +40,7 @@ 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
46
  import { onMount, untrack } from '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 {
@@ -87,6 +101,39 @@ function buildLayoutStyle(shell: ModuleShellState): string {
87
101
  bottomRightCorner?: Snippet;
88
102
  /** Content for the keyboard shortcuts overlay. */
89
103
  shortcutsOverlay?: Snippet;
104
+ /**
105
+ * A full-width header row above every edge (`#smrt-admin-shell-header`,
106
+ * 3.5rem; override `--smrt-admin-shell-header-size`). Shown at every
107
+ * width except on phones when `phoneTopBar` is given.
108
+ */
109
+ header?: Snippet<[{ viewport: ShellViewport }]>;
110
+ /**
111
+ * Phone-only context bar over the top of the main region. Hides while
112
+ * scrolling down and returns on the first scroll up (see
113
+ * `phone.hideOnScroll`); pinned while `pinChrome` or a drawer is open.
114
+ */
115
+ phoneTopBar?: Snippet<[{ hidden: boolean }]>;
116
+ /**
117
+ * Phone-only bottom bar row. Replaced by a form's action bar
118
+ * (`[data-form-action-bar]` inside main) and hidden while the on-screen
119
+ * keyboard is open.
120
+ */
121
+ phoneBottomBar?: Snippet;
122
+ /**
123
+ * Host surfaces laid over the shell above the phone bottom bar (phone
124
+ * sheets, status strips). The layer ignores pointer events; its direct
125
+ * children receive them.
126
+ */
127
+ overlays?: Snippet<[{ viewport: ShellViewport; bottomBar: BottomBarMode }]>;
128
+ /** Phone behavior switches (scrim, swipe to close, hide on scroll). */
129
+ phone?: AdminShellPhoneOptions;
130
+ /** Keep the phone top bar shown (e.g. while a banner is visible). */
131
+ pinChrome?: boolean;
132
+ /**
133
+ * Current location. When it changes on a phone, open side drawers and
134
+ * sheets close and the phone top bar comes back.
135
+ */
136
+ path?: string;
90
137
  children: Snippet;
91
138
  }
92
139
 
@@ -112,6 +159,13 @@ function buildLayoutStyle(shell: ModuleShellState): string {
112
159
  bottomLeftCorner,
113
160
  bottomRightCorner,
114
161
  shortcutsOverlay,
162
+ header,
163
+ phoneTopBar,
164
+ phoneBottomBar,
165
+ overlays,
166
+ phone,
167
+ pinChrome = false,
168
+ path,
115
169
  children,
116
170
  }: Props = $props();
117
171
 
@@ -138,8 +192,162 @@ function buildLayoutStyle(shell: ModuleShellState): string {
138
192
  }
139
193
  const shortcutEdges: PanelEdge[] = ['top', 'left', 'bottom', 'right'];
140
194
 
195
+ const SIDE_EDGES = ['left', 'right'] as const;
196
+ type SideEdge = (typeof SIDE_EDGES)[number];
197
+
198
+ let mainElement: HTMLElement | undefined = $state();
199
+ let keyboardOpen = $state(false);
200
+ let formActions = $state(false);
201
+ let chromeHidden = $state(false);
202
+ let resizing = $state<SideEdge | null>(null);
203
+ const sideElements = $state<Partial<Record<SideEdge, HTMLElement>>>({});
204
+ let scrollChrome: ReturnType<typeof installScrollChrome> | null = null;
205
+
206
+ const viewport = $derived(shell.viewport);
207
+ const isPhone = $derived(viewport === 'phone');
208
+ const showPhoneTop = $derived(isPhone && Boolean(phoneTopBar));
209
+ const showHeader = $derived(Boolean(header) && !showPhoneTop);
210
+ const bottomBar = $derived<BottomBarMode>(
211
+ isPhone && phoneBottomBar
212
+ ? bottomBarMode({ formActions, keyboardOpen })
213
+ : 'none',
214
+ );
215
+ const phoneSurfaceOpen = $derived(
216
+ isPhone &&
217
+ SIDE_EDGES.some(
218
+ (edge) => panelState(edge) === 'expanded' && shell.isEdgeShown(edge),
219
+ ),
220
+ );
221
+ const chromeStyle = $derived(
222
+ [
223
+ `--smrt-admin-shell-header-track: ${showHeader ? 'var(--smrt-admin-shell-header-size)' : '0rem'}`,
224
+ `--smrt-admin-shell-phone-bar-track: ${bottomBar === 'nav' ? 'var(--smrt-admin-shell-phone-bar-size)' : '0rem'}`,
225
+ ].join('; '),
226
+ );
227
+
228
+ /**
229
+ * Tablet/desktop: the side edge that is open as an overlay (its
230
+ * `overlayMedia` matches), laid over the page above a scrim.
231
+ */
232
+ const overlayEdge = $derived<SideEdge | null>(
233
+ isPhone
234
+ ? null
235
+ : (SIDE_EDGES.find(
236
+ (edge) =>
237
+ panelState(edge) === 'expanded' &&
238
+ shell.isEdgeShown(edge) &&
239
+ shell.presentationFor(edge) === 'overlay',
240
+ ) ?? null),
241
+ );
242
+
243
+ // An overlay takes focus when it opens (unless something inside already
244
+ // has it) and hands it back to whatever opened it when it closes.
245
+ let overlayReturnFocus: HTMLElement | null = null;
246
+ let lastOverlayEdge: SideEdge | null = null;
247
+ /**
248
+ * An overlay that was just closed and is sliding back out: it keeps its
249
+ * expanded look (inert, above a fading scrim) until the animation ends.
250
+ */
251
+ let closingEdge = $state<SideEdge | null>(null);
252
+ let closingTimer: ReturnType<typeof setTimeout> | null = null;
253
+
254
+ function endClosing(): void {
255
+ if (closingTimer) clearTimeout(closingTimer);
256
+ closingTimer = null;
257
+ closingEdge = null;
258
+ }
259
+
260
+ function prefersReducedMotion(): boolean {
261
+ return (
262
+ typeof window !== 'undefined' &&
263
+ typeof window.matchMedia === 'function' &&
264
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
265
+ );
266
+ }
267
+
268
+ /** An edge shown open: expanded, or an overlay still sliding out. */
269
+ function shownOpen(edge: PanelEdge): boolean {
270
+ return edgeExpanded(edge) || closingEdge === edge;
271
+ }
272
+
273
+ $effect(() => {
274
+ const edge = overlayEdge;
275
+ untrack(() => {
276
+ if (edge === lastOverlayEdge) return;
277
+ const previous = lastOverlayEdge;
278
+ lastOverlayEdge = edge;
279
+ if (typeof document === 'undefined') return;
280
+ if (edge) {
281
+ endClosing();
282
+ const active = document.activeElement;
283
+ overlayReturnFocus =
284
+ active instanceof HTMLElement && active !== document.body
285
+ ? active
286
+ : null;
287
+ // Effects run after the DOM update, so the panel is already shown.
288
+ const panel = sideElements[edge];
289
+ if (panel && !panel.contains(document.activeElement)) {
290
+ panel.focus({ preventScroll: true });
291
+ }
292
+ return;
293
+ }
294
+ const panel = previous ? sideElements[previous] : undefined;
295
+ const active = document.activeElement;
296
+ const lost =
297
+ !active || active === document.body || Boolean(panel?.contains(active));
298
+ if (lost && overlayReturnFocus?.isConnected) {
299
+ overlayReturnFocus.focus({ preventScroll: true });
300
+ }
301
+ overlayReturnFocus = null;
302
+ // Closed (not just docked by a wider window): slide back out.
303
+ if (
304
+ previous &&
305
+ panelState(previous) !== 'expanded' &&
306
+ shell.presentationFor(previous) === 'overlay' &&
307
+ !isPhone &&
308
+ !prefersReducedMotion()
309
+ ) {
310
+ endClosing();
311
+ closingEdge = previous;
312
+ // Fallback when animationend never fires (hidden tab, no CSS).
313
+ closingTimer = setTimeout(endClosing, 600);
314
+ }
315
+ });
316
+ });
317
+
318
+ function onEdgeAnimationEnd(event: AnimationEvent, edge: SideEdge): void {
319
+ if (closingEdge === edge && event.target === event.currentTarget) {
320
+ endClosing();
321
+ }
322
+ }
323
+
324
+ function collapsePhoneSurfaces(): void {
325
+ for (const edge of SIDE_EDGES) {
326
+ if (panelState(edge) === 'expanded' && shell.isEdgeShown(edge)) {
327
+ shell.collapsePanel(edge);
328
+ }
329
+ }
330
+ }
331
+
141
332
  onMount(() => {
142
- void shell.hydrate();
333
+ const offViewport = installShellViewport(shell);
334
+ const offOverlayMedia = installShellOverlayMedia(shell);
335
+ void shell.hydrate().then(() => shell.closeOverlaidEdges());
336
+ const offKeyboard = phoneBottomBar
337
+ ? installKeyboardWatcher((open) => (keyboardOpen = open))
338
+ : () => {};
339
+ const offFormActions =
340
+ phoneBottomBar && mainElement
341
+ ? watchFormActionBar(mainElement, (present) => (formActions = present))
342
+ : () => {};
343
+ scrollChrome =
344
+ phoneTopBar && phone?.hideOnScroll !== false && mainElement
345
+ ? installScrollChrome(mainElement, {
346
+ onChange: (hidden) => (chromeHidden = hidden),
347
+ isPinned: () =>
348
+ pinChrome || phoneSurfaceOpen || shell.viewport !== 'phone',
349
+ })
350
+ : null;
143
351
 
144
352
  function handleKeydown(event: KeyboardEvent): void {
145
353
  if (event.key === 'Escape') {
@@ -167,9 +375,148 @@ function buildLayoutStyle(shell: ModuleShellState): string {
167
375
  }
168
376
 
169
377
  window.addEventListener('keydown', handleKeydown);
170
- return () => window.removeEventListener('keydown', handleKeydown);
378
+ return () => {
379
+ window.removeEventListener('keydown', handleKeydown);
380
+ offViewport();
381
+ offOverlayMedia();
382
+ if (closingTimer) clearTimeout(closingTimer);
383
+ offKeyboard();
384
+ offFormActions();
385
+ scrollChrome?.destroy();
386
+ scrollChrome = null;
387
+ };
388
+ });
389
+
390
+ // Pinning (a banner appears, a drawer opens, leaving phone) shows the bar.
391
+ $effect(() => {
392
+ if (pinChrome || phoneSurfaceOpen || !isPhone) chromeHidden = false;
171
393
  });
172
394
 
395
+ // Navigating on a phone closes drawers/sheets and brings the top bar back.
396
+ let lastPath: string | undefined;
397
+ $effect(() => {
398
+ const current = path;
399
+ untrack(() => {
400
+ if (lastPath !== undefined && current !== lastPath) {
401
+ if (shell.viewport === 'phone') collapsePhoneSurfaces();
402
+ scrollChrome?.reset();
403
+ chromeHidden = false;
404
+ }
405
+ lastPath = current;
406
+ });
407
+ });
408
+
409
+ // ---- Resizable side edges ------------------------------------------------
410
+
411
+ function remInPx(): number {
412
+ if (typeof window === 'undefined') return 16;
413
+ const size = Number.parseFloat(
414
+ window.getComputedStyle(document.documentElement).fontSize,
415
+ );
416
+ return Number.isFinite(size) && size > 0 ? size : 16;
417
+ }
418
+
419
+ function cssLengthToPx(length: string): number | null {
420
+ const match = /^\s*(-?\d*\.?\d+)(px|rem|em)?\s*$/.exec(length);
421
+ if (!match) return null;
422
+ const value = Number.parseFloat(match[1]);
423
+ return match[2] === 'rem' || match[2] === 'em' ? value * remInPx() : value;
424
+ }
425
+
426
+ /**
427
+ * The edge's expanded width in px: the resized width, else the configured
428
+ * `expandedSize` (measured when it is not a plain px/rem length).
429
+ */
430
+ function currentSize(edge: SideEdge): number {
431
+ const limits = shell.resizeLimits(edge);
432
+ const resized = shell.panelSize(edge);
433
+ if (resized !== null) return resized;
434
+ const width =
435
+ cssLengthToPx(shell.config.panels[edge].expandedSize) ??
436
+ sideElements[edge]?.getBoundingClientRect().width ??
437
+ 0;
438
+ return limits ? clampPanelSize(width, limits) : Math.round(width);
439
+ }
440
+
441
+ function canResize(edge: SideEdge): boolean {
442
+ return (
443
+ !isPhone &&
444
+ edgeExpanded(edge) &&
445
+ shell.presentationFor(edge) === 'push' &&
446
+ shell.resizeLimits(edge) !== null
447
+ );
448
+ }
449
+
450
+ function onResizeKeydown(event: KeyboardEvent, edge: SideEdge): void {
451
+ const limits = shell.resizeLimits(edge);
452
+ if (!limits) return;
453
+ const step = limits.step * (event.shiftKey ? 4 : 1);
454
+ const grow = edge === 'left' ? 'ArrowRight' : 'ArrowLeft';
455
+ const shrink = edge === 'left' ? 'ArrowLeft' : 'ArrowRight';
456
+ let next: number | null;
457
+ if (event.key === grow) next = currentSize(edge) + step;
458
+ else if (event.key === shrink) next = currentSize(edge) - step;
459
+ else if (event.key === 'Home') next = limits.min;
460
+ else if (event.key === 'End') next = limits.max;
461
+ else if (event.key === 'Enter') next = null;
462
+ else return;
463
+ event.preventDefault();
464
+ shell.setPanelSize(edge, next);
465
+ }
466
+
467
+ /** Tears down the drag in progress, if any (see onResizePointerdown). */
468
+ let stopActiveResize: ((commit: boolean) => void) | null = null;
469
+
470
+ // Unmounting mid-drag must not leave the handle's listeners driving a shell
471
+ // state that outlives this component, nor a stale `resizing` flag.
472
+ $effect(() => () => stopActiveResize?.(false));
473
+
474
+ function onResizePointerdown(event: PointerEvent, edge: SideEdge): void {
475
+ if (event.button !== 0 || !shell.resizeLimits(edge)) return;
476
+ event.preventDefault();
477
+ stopActiveResize?.(true);
478
+ const handle = event.currentTarget as HTMLElement;
479
+ const pointerId = event.pointerId;
480
+ if (pointerId !== undefined) handle.setPointerCapture?.(pointerId);
481
+ const startX = event.clientX;
482
+ const startSize = currentSize(edge);
483
+ let latest = startSize;
484
+ resizing = edge;
485
+
486
+ function onMove(move: PointerEvent): void {
487
+ const dx = move.clientX - startX;
488
+ latest = startSize + (edge === 'left' ? dx : -dx);
489
+ shell.setPanelSize(edge, latest, { persist: false });
490
+ }
491
+
492
+ function stop(commit: boolean): void {
493
+ if (stopActiveResize !== stop) return;
494
+ stopActiveResize = null;
495
+ handle.removeEventListener('pointermove', onMove);
496
+ handle.removeEventListener('pointerup', onEnd);
497
+ handle.removeEventListener('pointercancel', onEnd);
498
+ handle.removeEventListener('lostpointercapture', onEnd);
499
+ if (pointerId !== undefined && handle.hasPointerCapture?.(pointerId)) {
500
+ handle.releasePointerCapture(pointerId);
501
+ }
502
+ resizing = null;
503
+ if (commit && latest !== startSize) shell.setPanelSize(edge, latest);
504
+ }
505
+
506
+ // pointerup/pointercancel end a drag normally; losing the capture (a
507
+ // system gesture, the element leaving the DOM) ends it too, or the shell
508
+ // would stay in its resizing state with nothing to release it.
509
+ function onEnd(): void {
510
+ stop(true);
511
+ }
512
+
513
+ stopActiveResize = stop;
514
+ handle.addEventListener('pointermove', onMove);
515
+ handle.addEventListener('pointerup', onEnd);
516
+ handle.addEventListener('pointercancel', onEnd);
517
+ handle.addEventListener('lostpointercapture', onEnd);
518
+ }
519
+
173
520
  const layoutStyle = $derived(buildLayoutStyle(shell));
174
521
 
175
522
  // Top/bottom edges reserve grid tracks for corner snippets. When a corner
@@ -194,6 +541,18 @@ function buildLayoutStyle(shell: ModuleShellState): string {
194
541
  return panelState(edge) === 'expanded';
195
542
  }
196
543
 
544
+ /** Collapsed edge whose panel content stays mounted (`keepMounted`). */
545
+ function keepsContent(edge: PanelEdge): boolean {
546
+ return (
547
+ panelState(edge) === 'collapsed' &&
548
+ shell.config.panels[edge].keepMounted === true
549
+ );
550
+ }
551
+
552
+ function panelMounted(edge: PanelEdge): boolean {
553
+ return shownOpen(edge) || keepsContent(edge);
554
+ }
555
+
197
556
  function labelFor(edge: PanelEdge): string {
198
557
  return shell.config.panels[edge].label;
199
558
  }
@@ -273,6 +632,30 @@ function buildLayoutStyle(shell: ModuleShellState): string {
273
632
  </Button>
274
633
  {/snippet}
275
634
 
635
+ {#snippet resizer(edge: SideEdge)}
636
+ {#if canResize(edge)}
637
+ {@const limits = shell.resizeLimits(edge)}
638
+ <!-- A focusable separator is the WAI-ARIA window-splitter widget; Svelte's
639
+ role table lists `separator` as non-interactive only in its static form. -->
640
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
641
+ <div
642
+ class="smrt-admin-shell__resizer smrt-admin-shell__resizer--{edge}"
643
+ role="separator"
644
+ aria-orientation="vertical"
645
+ aria-controls={`smrt-admin-shell-${edge}-panel`}
646
+ aria-label={t(M['ui.admin_shell.resize_panel'], { label: labelFor(edge) })}
647
+ aria-valuemin={limits?.min}
648
+ aria-valuemax={limits?.max}
649
+ aria-valuenow={currentSize(edge)}
650
+ tabindex="0"
651
+ data-testid={`admin-shell-resizer-${edge}`}
652
+ onpointerdown={(event) => onResizePointerdown(event, edge)}
653
+ onkeydown={(event) => onResizeKeydown(event, edge)}
654
+ ondblclick={() => shell.setPanelSize(edge, null)}
655
+ ></div>
656
+ {/if}
657
+ {/snippet}
658
+
276
659
  {#snippet focusContent(tool: ShellFocusTool | null)}
277
660
  {#if focusPanel}
278
661
  {@render focusPanel({ tool })}
@@ -294,8 +677,23 @@ function buildLayoutStyle(shell: ModuleShellState): string {
294
677
  data-left-state={panelState('left')}
295
678
  data-right-state={panelState('right')}
296
679
  data-bottom-state={panelState('bottom')}
297
- style={layoutStyle}
680
+ data-viewport={viewport}
681
+ data-bottom-bar={bottomBar}
682
+ data-phone-top={showPhoneTop ? '' : undefined}
683
+ data-chrome-hidden={chromeHidden ? '' : undefined}
684
+ data-resizing={resizing ?? undefined}
685
+ style={`${layoutStyle}; ${chromeStyle}`}
298
686
  >
687
+ {#if showHeader}
688
+ <div
689
+ id="smrt-admin-shell-header"
690
+ class="smrt-admin-shell__header"
691
+ data-testid="admin-shell-header"
692
+ >
693
+ {@render header?.({ viewport })}
694
+ </div>
695
+ {/if}
696
+
299
697
  {#if panelState('top') !== 'hidden'}
300
698
  <header
301
699
  id="smrt-admin-shell-top-panel"
@@ -330,10 +728,11 @@ function buildLayoutStyle(shell: ModuleShellState): string {
330
728
  {@render topRightCorner()}
331
729
  </div>
332
730
  {/if}
333
- {#if edgeExpanded('top')}
731
+ {#if panelMounted('top')}
334
732
  <section
335
733
  class="smrt-admin-shell__drawer smrt-admin-shell__drawer--top"
336
734
  aria-label={labelFor('top')}
735
+ hidden={!edgeExpanded('top')}
337
736
  >
338
737
  {#if appPanel}
339
738
  {@render appPanel()}
@@ -347,18 +746,36 @@ function buildLayoutStyle(shell: ModuleShellState): string {
347
746
  </header>
348
747
  {/if}
349
748
 
350
- {#if panelState('left') !== 'hidden'}
749
+ {#if shell.isEdgeShown('left')}
750
+ <!-- tabindex -1 only while overlaid, so the panel can take focus on open -->
751
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
351
752
  <aside
352
753
  id="smrt-admin-shell-left-panel"
353
754
  class="smrt-admin-shell__edge smrt-admin-shell__edge--left"
354
- data-state={panelState('left')}
355
- data-presentation={shell.config.panels.left.presentation}
755
+ data-state={closingEdge === 'left' ? 'expanded' : panelState('left')}
756
+ data-closing={closingEdge === 'left' ? '' : undefined}
757
+ data-presentation={shell.presentationFor('left')}
356
758
  role="navigation"
357
759
  aria-label={labelFor('left')}
760
+ tabindex={overlayEdge === 'left' ? -1 : undefined}
761
+ inert={(isPhone && !edgeExpanded('left')) ||
762
+ overlayEdge === 'right' ||
763
+ closingEdge === 'left'}
764
+ onanimationend={(event) => onEdgeAnimationEnd(event, 'left')}
765
+ bind:this={sideElements.left}
766
+ use:swipeDismiss={{
767
+ direction: 'left',
768
+ onDismiss: () => shell.collapsePanel('left'),
769
+ enabled: isPhone && Boolean(phone?.swipeToClose) && edgeExpanded('left'),
770
+ }}
358
771
  >
772
+ {@render resizer('left')}
359
773
  <div class="smrt-admin-shell__rail">
360
- {#if edgeExpanded('left')}
361
- <div class="smrt-admin-shell__tenant-stack">
774
+ {#if shownOpen('left') || (tenantPanel && keepsContent('left'))}
775
+ <div
776
+ class="smrt-admin-shell__tenant-stack"
777
+ hidden={!shownOpen('left')}
778
+ >
362
779
  <div class="smrt-admin-shell__tenant-content">
363
780
  {#if tenantPanel}
364
781
  {@render tenantPanel()}
@@ -374,27 +791,104 @@ function buildLayoutStyle(shell: ModuleShellState): string {
374
791
  </div>
375
792
  {/if}
376
793
  </div>
377
- {:else if tenantRail}
378
- {@render tenantRail()}
379
- {:else}
380
- {@render edgeToggle('left')}
794
+ {/if}
795
+ {#if !shownOpen('left')}
796
+ {#if tenantRail}
797
+ {@render tenantRail()}
798
+ {:else}
799
+ {@render edgeToggle('left')}
800
+ {/if}
381
801
  {/if}
382
802
  </div>
383
803
  </aside>
384
804
  {/if}
385
805
 
386
- <main class="smrt-admin-shell__main">
806
+ <main
807
+ id="smrt-admin-shell-main"
808
+ class="smrt-admin-shell__main"
809
+ bind:this={mainElement}
810
+ inert={overlayEdge !== null}
811
+ >
387
812
  {@render children()}
388
813
  </main>
389
814
 
390
- {#if panelState('right') !== 'hidden'}
815
+ {#if showPhoneTop}
816
+ <div class="smrt-admin-shell__phone-top" data-testid="admin-shell-phone-top">
817
+ {@render phoneTopBar?.({ hidden: chromeHidden })}
818
+ </div>
819
+ {/if}
820
+
821
+ {#if isPhone && phone?.scrim && phoneSurfaceOpen}
822
+ <!-- raw-primitive-allow: click-catching backdrop behind a phone drawer -->
823
+ <button
824
+ type="button"
825
+ class="smrt-admin-shell__scrim"
826
+ tabindex="-1"
827
+ aria-label={t(M['ui.admin_shell.close_panel'], {
828
+ label: labelFor(edgeExpanded('left') && shell.isEdgeShown('left') ? 'left' : 'right'),
829
+ })}
830
+ data-testid="admin-shell-scrim"
831
+ onclick={collapsePhoneSurfaces}
832
+ ></button>
833
+ {/if}
834
+
835
+ {#if overlayEdge || closingEdge}
836
+ {@const scrimEdge = (overlayEdge ?? closingEdge) as SideEdge}
837
+ <!-- raw-primitive-allow: click-catching backdrop behind an overlaid side panel -->
838
+ <button
839
+ type="button"
840
+ class="smrt-admin-shell__overlay-scrim"
841
+ tabindex="-1"
842
+ aria-label={t(M['ui.admin_shell.close_panel'], { label: labelFor(scrimEdge) })}
843
+ data-testid="admin-shell-overlay-scrim"
844
+ data-closing={overlayEdge ? undefined : ''}
845
+ onclick={() => {
846
+ if (overlayEdge) shell.collapsePanel(scrimEdge);
847
+ }}
848
+ ></button>
849
+ {/if}
850
+
851
+ {#if shell.isEdgeShown('right')}
852
+ {@const rightPhone = isPhone ? shell.phonePresentation('right') : undefined}
853
+ <!-- tabindex -1 only while overlaid, so the panel can take focus on open -->
854
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
391
855
  <aside
392
856
  id="smrt-admin-shell-right-panel"
393
857
  class="smrt-admin-shell__edge smrt-admin-shell__edge--right"
394
- data-state={panelState('right')}
395
- data-presentation={shell.config.panels.right.presentation}
858
+ data-state={closingEdge === 'right' ? 'expanded' : panelState('right')}
859
+ data-closing={closingEdge === 'right' ? '' : undefined}
860
+ data-presentation={shell.presentationFor('right')}
861
+ data-phone={rightPhone}
396
862
  aria-label={labelFor('right')}
863
+ tabindex={overlayEdge === 'right' ? -1 : undefined}
864
+ inert={(isPhone && !edgeExpanded('right')) ||
865
+ overlayEdge === 'left' ||
866
+ closingEdge === 'right'}
867
+ onanimationend={(event) => onEdgeAnimationEnd(event, 'right')}
868
+ bind:this={sideElements.right}
869
+ use:swipeDismiss={{
870
+ direction: 'right',
871
+ onDismiss: () => shell.collapsePanel('right'),
872
+ enabled:
873
+ rightPhone === 'drawer' &&
874
+ Boolean(phone?.swipeToClose) &&
875
+ edgeExpanded('right'),
876
+ }}
397
877
  >
878
+ {@render resizer('right')}
879
+ {#if rightPhone === 'sheet'}
880
+ <div
881
+ class="smrt-admin-shell__sheet-grabber"
882
+ aria-hidden="true"
883
+ use:swipeDismiss={{
884
+ direction: 'down',
885
+ onDismiss: () => shell.collapsePanel('right'),
886
+ enabled: edgeExpanded('right'),
887
+ }}
888
+ >
889
+ <span></span>
890
+ </div>
891
+ {/if}
398
892
  <div class="smrt-admin-shell__rail">
399
893
  {#if focusRail}
400
894
  {@render focusRail()}
@@ -430,8 +924,11 @@ function buildLayoutStyle(shell: ModuleShellState): string {
430
924
  {@render edgeToggle('right')}
431
925
  {/if}
432
926
  </div>
433
- {#if edgeExpanded('right')}
434
- <div class="smrt-admin-shell__panel smrt-admin-shell__panel--right">
927
+ {#if panelMounted('right')}
928
+ <div
929
+ class="smrt-admin-shell__panel smrt-admin-shell__panel--right"
930
+ hidden={!shownOpen('right')}
931
+ >
435
932
  {#key shell.activeFocusToolId}
436
933
  {@render focusContent(resolveActiveFocusTool())}
437
934
  {/key}
@@ -468,10 +965,11 @@ function buildLayoutStyle(shell: ModuleShellState): string {
468
965
  {@render bottomRightCorner()}
469
966
  </div>
470
967
  {/if}
471
- {#if edgeExpanded('bottom')}
968
+ {#if panelMounted('bottom')}
472
969
  <section
473
970
  class="smrt-admin-shell__drawer smrt-admin-shell__drawer--bottom"
474
971
  aria-label={labelFor('bottom')}
972
+ hidden={!edgeExpanded('bottom')}
475
973
  >
476
974
  {#if systemPanel}
477
975
  {@render systemPanel()}
@@ -485,6 +983,18 @@ function buildLayoutStyle(shell: ModuleShellState): string {
485
983
  </footer>
486
984
  {/if}
487
985
 
986
+ {#if overlays}
987
+ <div class="smrt-admin-shell__overlays">
988
+ {@render overlays({ viewport, bottomBar })}
989
+ </div>
990
+ {/if}
991
+
992
+ {#if bottomBar === 'nav'}
993
+ <div class="smrt-admin-shell__phone-bar" data-testid="admin-shell-phone-bar">
994
+ {@render phoneBottomBar?.()}
995
+ </div>
996
+ {/if}
997
+
488
998
  {#if shortcutsOpen}
489
999
  <div
490
1000
  class="smrt-admin-shell__shortcuts"
@@ -537,19 +1047,63 @@ function buildLayoutStyle(shell: ModuleShellState): string {
537
1047
  --smrt-admin-shell-right-collapsed: 4.25rem;
538
1048
  --smrt-admin-shell-left-expanded: 16rem;
539
1049
  --smrt-admin-shell-right-expanded: 20rem;
1050
+ --smrt-admin-shell-header-size: 3.5rem;
1051
+ --smrt-admin-shell-header-track: 0rem;
1052
+ --smrt-admin-shell-phone-top-size: 3.5rem;
1053
+ --smrt-admin-shell-phone-bar-size: calc(3.5rem + env(safe-area-inset-bottom));
1054
+ --smrt-admin-shell-phone-bar-track: 0rem;
1055
+ --smrt-admin-shell-chrome-duration: var(--smrt-duration-short4, 200ms);
540
1056
  position: relative;
541
1057
  display: grid;
542
1058
  grid-template-columns:
543
1059
  var(--smrt-admin-shell-left-track) minmax(0, 1fr)
544
1060
  var(--smrt-admin-shell-right-track);
1061
+ /* header · top edge · body · bottom edge · phone bottom bar. The extra
1062
+ rows are 0 unless the header / phone bar render. */
545
1063
  grid-template-rows:
1064
+ var(--smrt-admin-shell-header-track)
546
1065
  var(--smrt-admin-shell-top-track) minmax(0, 1fr)
547
- var(--smrt-admin-shell-bottom-track);
1066
+ var(--smrt-admin-shell-bottom-track)
1067
+ var(--smrt-admin-shell-phone-bar-track);
548
1068
  min-block-size: 100svh;
549
1069
  block-size: 100svh;
550
- background: var(--smrt-color-surface);
1070
+ /* Themeable: an app sets --smrt-admin-shell-background on the shell or an
1071
+ ancestor (a tinted page behind cards, for instance). */
1072
+ background: var(--smrt-admin-shell-background, var(--smrt-color-surface));
551
1073
  color: var(--smrt-color-on-surface);
552
1074
  overflow: hidden;
1075
+ /* Themed, thin scrollbars for every scroller inside the shell. Override
1076
+ --smrt-admin-shell-scrollbar-track / -thumb / -thumb-hover to retint. */
1077
+ scrollbar-color: var(--smrt-admin-shell-scrollbar-thumb, color-mix(in srgb, var(--smrt-color-on-surface) 26%, transparent))
1078
+ var(--smrt-admin-shell-scrollbar-track, color-mix(in srgb, var(--smrt-color-surface-container-low) 88%, transparent));
1079
+ }
1080
+
1081
+ .smrt-admin-shell :global(*) {
1082
+ scrollbar-width: thin;
1083
+ }
1084
+
1085
+ .smrt-admin-shell :global(*)::-webkit-scrollbar {
1086
+ width: 0.7rem;
1087
+ height: 0.7rem;
1088
+ }
1089
+
1090
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-track {
1091
+ background: var(--smrt-admin-shell-scrollbar-track, color-mix(in srgb, var(--smrt-color-surface-container-low) 88%, transparent));
1092
+ }
1093
+
1094
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-thumb {
1095
+ background-color: var(--smrt-admin-shell-scrollbar-thumb, color-mix(in srgb, var(--smrt-color-on-surface) 26%, transparent));
1096
+ background-clip: padding-box;
1097
+ border: 2px solid transparent;
1098
+ border-radius: var(--smrt-radius-full, 999px);
1099
+ }
1100
+
1101
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-thumb:hover {
1102
+ background-color: var(--smrt-admin-shell-scrollbar-thumb-hover, color-mix(in srgb, var(--smrt-color-on-surface) 38%, transparent));
1103
+ }
1104
+
1105
+ .smrt-admin-shell :global(*)::-webkit-scrollbar-corner {
1106
+ background: transparent;
553
1107
  }
554
1108
 
555
1109
  .smrt-admin-shell__edge {
@@ -560,9 +1114,30 @@ function buildLayoutStyle(shell: ModuleShellState): string {
560
1114
  border-color: var(--smrt-color-outline-variant);
561
1115
  }
562
1116
 
563
- .smrt-admin-shell__edge--top {
1117
+ .smrt-admin-shell__header {
564
1118
  grid-column: 1 / -1;
565
1119
  grid-row: 1;
1120
+ display: flex;
1121
+ align-items: center;
1122
+ gap: var(--smrt-spacing-2);
1123
+ box-sizing: border-box;
1124
+ min-inline-size: 0;
1125
+ min-block-size: 0;
1126
+ padding: 0 var(--smrt-spacing-3) 0 var(--smrt-spacing-2);
1127
+ /* Clip long titles sideways only: menus and popovers opened from the
1128
+ header's actions (a notifications bell, an account menu) must be able
1129
+ to drop below it. `clip` (unlike `hidden`) allows a visible block axis. */
1130
+ overflow-x: clip;
1131
+ overflow-y: visible;
1132
+ border-block-end: 1px solid var(--smrt-color-outline-variant);
1133
+ background: var(--smrt-color-surface-container-low);
1134
+ color: var(--smrt-color-on-surface);
1135
+ z-index: 30;
1136
+ }
1137
+
1138
+ .smrt-admin-shell__edge--top {
1139
+ grid-column: 1 / -1;
1140
+ grid-row: 2;
566
1141
  display: grid;
567
1142
  grid-template-columns: var(
568
1143
  --edge-columns,
@@ -574,15 +1149,17 @@ function buildLayoutStyle(shell: ModuleShellState): string {
574
1149
  }
575
1150
 
576
1151
  .smrt-admin-shell__edge--left {
1152
+ position: relative;
577
1153
  grid-column: 1;
578
- grid-row: 2;
1154
+ grid-row: 3;
579
1155
  border-inline-end: 1px solid var(--smrt-color-outline-variant);
580
1156
  z-index: 20;
581
1157
  }
582
1158
 
583
1159
  .smrt-admin-shell__edge--right {
1160
+ position: relative;
584
1161
  grid-column: 3;
585
- grid-row: 2;
1162
+ grid-row: 3;
586
1163
  border-inline-start: 1px solid var(--smrt-color-outline-variant);
587
1164
  z-index: 20;
588
1165
  }
@@ -596,7 +1173,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
596
1173
 
597
1174
  .smrt-admin-shell__edge--bottom {
598
1175
  grid-column: 1 / -1;
599
- grid-row: 3;
1176
+ grid-row: 4;
600
1177
  display: grid;
601
1178
  grid-template-columns: var(
602
1179
  --edge-columns,
@@ -609,7 +1186,7 @@ function buildLayoutStyle(shell: ModuleShellState): string {
609
1186
 
610
1187
  .smrt-admin-shell__main {
611
1188
  grid-column: 2;
612
- grid-row: 2;
1189
+ grid-row: 3;
613
1190
  min-width: 0;
614
1191
  min-height: 0;
615
1192
  overflow: auto;
@@ -656,6 +1233,15 @@ function buildLayoutStyle(shell: ModuleShellState): string {
656
1233
  padding: var(--smrt-spacing-3);
657
1234
  }
658
1235
 
1236
+ /* A right rail never pads wider than its own track: a rail collapsed to 0
1237
+ (an app that shows nothing there) paints no padding. */
1238
+ .smrt-admin-shell__edge--right .smrt-admin-shell__rail {
1239
+ padding-inline: min(
1240
+ var(--smrt-spacing-3),
1241
+ calc(var(--smrt-admin-shell-right-collapsed) / 4)
1242
+ );
1243
+ }
1244
+
659
1245
  .smrt-admin-shell__focus-rail {
660
1246
  display: grid;
661
1247
  /* Center tracks, not just items: buttons may be wider than the
@@ -704,6 +1290,15 @@ function buildLayoutStyle(shell: ModuleShellState): string {
704
1290
  overflow: hidden;
705
1291
  }
706
1292
 
1293
+ /* A collapsed `keepMounted` edge keeps its panel in the DOM but hidden;
1294
+ the panel classes set `display`, which would otherwise win over the
1295
+ `hidden` attribute. */
1296
+ .smrt-admin-shell__tenant-stack[hidden],
1297
+ .smrt-admin-shell__panel[hidden],
1298
+ .smrt-admin-shell__drawer[hidden] {
1299
+ display: none !important;
1300
+ }
1301
+
707
1302
  .smrt-admin-shell__tenant-stack {
708
1303
  display: grid;
709
1304
  grid-template-rows: minmax(0, 1fr) auto;
@@ -764,14 +1359,19 @@ function buildLayoutStyle(shell: ModuleShellState): string {
764
1359
  }
765
1360
 
766
1361
  .smrt-admin-shell__drawer--top {
767
- inset-block-start: var(--smrt-admin-shell-top-track);
1362
+ inset-block-start: calc(
1363
+ var(--smrt-admin-shell-header-track) + var(--smrt-admin-shell-top-track)
1364
+ );
768
1365
  inset-inline: var(--smrt-admin-shell-left-track)
769
1366
  var(--smrt-admin-shell-right-track);
770
1367
  max-block-size: var(--smrt-admin-shell-top-expanded);
771
1368
  }
772
1369
 
773
1370
  .smrt-admin-shell__drawer--bottom {
774
- inset-block-end: var(--smrt-admin-shell-bottom-track);
1371
+ inset-block-end: calc(
1372
+ var(--smrt-admin-shell-bottom-track) +
1373
+ var(--smrt-admin-shell-phone-bar-track)
1374
+ );
775
1375
  /* The system scope is the application footer. Its expanded drawer must
776
1376
  remain footer-wide while it rises above every desktop pane. */
777
1377
  inset-inline: 0;
@@ -848,20 +1448,332 @@ function buildLayoutStyle(shell: ModuleShellState): string {
848
1448
  font-family: var(--smrt-font-family-mono);
849
1449
  }
850
1450
 
1451
+ /* ---- Resizable side edges ------------------------------------------ */
1452
+
1453
+ .smrt-admin-shell__resizer {
1454
+ position: absolute;
1455
+ inset-block: 0;
1456
+ z-index: 2;
1457
+ inline-size: 0.75rem;
1458
+ cursor: col-resize;
1459
+ touch-action: none;
1460
+ }
1461
+
1462
+ .smrt-admin-shell__resizer--left {
1463
+ inset-inline-end: -0.375rem;
1464
+ }
1465
+
1466
+ .smrt-admin-shell__resizer--right {
1467
+ inset-inline-start: -0.375rem;
1468
+ }
1469
+
1470
+ .smrt-admin-shell__resizer::after {
1471
+ content: '';
1472
+ position: absolute;
1473
+ inset-block: 0;
1474
+ inset-inline-start: calc(50% - 1px);
1475
+ inline-size: 2px;
1476
+ background: var(--smrt-color-primary);
1477
+ opacity: 0;
1478
+ transition: opacity var(--smrt-duration-short2, 100ms)
1479
+ var(--smrt-easing-standard, ease);
1480
+ }
1481
+
1482
+ .smrt-admin-shell__resizer:hover::after,
1483
+ .smrt-admin-shell__resizer:focus-visible::after,
1484
+ .smrt-admin-shell[data-resizing] .smrt-admin-shell__resizer::after {
1485
+ opacity: 1;
1486
+ }
1487
+
1488
+ .smrt-admin-shell__resizer:focus-visible {
1489
+ outline: none;
1490
+ }
1491
+
1492
+ .smrt-admin-shell[data-resizing] {
1493
+ cursor: col-resize;
1494
+ user-select: none;
1495
+ }
1496
+
1497
+ /* ---- Phone chrome ----------------------------------------------------- */
1498
+
1499
+ .smrt-admin-shell__phone-top {
1500
+ grid-column: 1 / -1;
1501
+ grid-row: 3;
1502
+ align-self: start;
1503
+ z-index: 10;
1504
+ min-inline-size: 0;
1505
+ transition: transform var(--smrt-admin-shell-chrome-duration)
1506
+ var(--smrt-easing-standard, ease);
1507
+ }
1508
+
1509
+ .smrt-admin-shell[data-chrome-hidden] .smrt-admin-shell__phone-top {
1510
+ transform: translateY(-100%);
1511
+ }
1512
+
1513
+ .smrt-admin-shell[data-phone-top] .smrt-admin-shell__main {
1514
+ padding-block-start: var(--smrt-admin-shell-phone-top-size);
1515
+ scroll-padding-block-start: var(--smrt-admin-shell-phone-top-size);
1516
+ }
1517
+
1518
+ .smrt-admin-shell__phone-bar {
1519
+ grid-column: 1 / -1;
1520
+ grid-row: 5;
1521
+ z-index: 30;
1522
+ min-inline-size: 0;
1523
+ }
1524
+
1525
+ .smrt-admin-shell__overlays {
1526
+ position: relative;
1527
+ grid-column: 1 / -1;
1528
+ grid-row: 1 / 5;
1529
+ z-index: 35;
1530
+ min-inline-size: 0;
1531
+ min-block-size: 0;
1532
+ pointer-events: none;
1533
+ }
1534
+
1535
+ .smrt-admin-shell__overlays > :global(*) {
1536
+ pointer-events: auto;
1537
+ }
1538
+
1539
+ .smrt-admin-shell__scrim {
1540
+ grid-column: 1 / -1;
1541
+ grid-row: 1 / 5;
1542
+ z-index: 19;
1543
+ margin: 0;
1544
+ padding: 0;
1545
+ border: 0;
1546
+ background: var(--smrt-color-scrim);
1547
+ opacity: 0.36;
1548
+ cursor: pointer;
1549
+ }
1550
+
1551
+ /* Tablet/desktop overlay: an expanded side edge whose `overlayMedia`
1552
+ matches slides over the page instead of taking a grid track. */
1553
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-state='expanded'],
1554
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-state='expanded'] {
1555
+ grid-column: 1 / -1;
1556
+ z-index: 25;
1557
+ box-shadow: var(
1558
+ --smrt-elevation-3,
1559
+ 0 0.5rem 1.5rem color-mix(in srgb, var(--smrt-color-shadow, #000) 24%, transparent)
1560
+ );
1561
+ outline: none;
1562
+ }
1563
+
1564
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-state='expanded'] {
1565
+ justify-self: start;
1566
+ inline-size: min(var(--smrt-admin-shell-left-expanded), 100%);
1567
+ animation: smrt-admin-shell-slide-from-left
1568
+ var(--smrt-admin-shell-chrome-duration) ease-out;
1569
+ }
1570
+
1571
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-state='expanded'] {
1572
+ justify-self: end;
1573
+ inline-size: min(var(--smrt-admin-shell-right-expanded), 100%);
1574
+ animation: smrt-admin-shell-slide-from-right
1575
+ var(--smrt-admin-shell-chrome-duration) ease-out;
1576
+ }
1577
+
1578
+ .smrt-admin-shell__overlay-scrim {
1579
+ grid-column: 1 / -1;
1580
+ grid-row: 3;
1581
+ z-index: 22;
1582
+ margin: 0;
1583
+ padding: 0;
1584
+ border: 0;
1585
+ background: var(--smrt-color-scrim);
1586
+ opacity: 0.24;
1587
+ cursor: pointer;
1588
+ animation: smrt-admin-shell-scrim-in var(--smrt-admin-shell-chrome-duration)
1589
+ ease-out;
1590
+ }
1591
+
1592
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-closing] {
1593
+ animation: smrt-admin-shell-slide-to-left
1594
+ var(--smrt-admin-shell-chrome-duration) ease-in forwards;
1595
+ }
1596
+
1597
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-closing] {
1598
+ animation: smrt-admin-shell-slide-to-right
1599
+ var(--smrt-admin-shell-chrome-duration) ease-in forwards;
1600
+ }
1601
+
1602
+ .smrt-admin-shell__overlay-scrim[data-closing] {
1603
+ pointer-events: none;
1604
+ animation: smrt-admin-shell-scrim-out
1605
+ var(--smrt-admin-shell-chrome-duration) ease-in forwards;
1606
+ }
1607
+
1608
+ @keyframes smrt-admin-shell-slide-to-right {
1609
+ to {
1610
+ transform: translateX(100%);
1611
+ }
1612
+ }
1613
+
1614
+ @keyframes smrt-admin-shell-slide-to-left {
1615
+ to {
1616
+ transform: translateX(-100%);
1617
+ }
1618
+ }
1619
+
1620
+ @keyframes smrt-admin-shell-scrim-out {
1621
+ to {
1622
+ opacity: 0;
1623
+ }
1624
+ }
1625
+
1626
+ @keyframes smrt-admin-shell-slide-from-right {
1627
+ from {
1628
+ transform: translateX(100%);
1629
+ }
1630
+ }
1631
+
1632
+ @keyframes smrt-admin-shell-slide-from-left {
1633
+ from {
1634
+ transform: translateX(-100%);
1635
+ }
1636
+ }
1637
+
1638
+ @keyframes smrt-admin-shell-scrim-in {
1639
+ from {
1640
+ opacity: 0;
1641
+ }
1642
+ }
1643
+
1644
+ @media (prefers-reduced-motion: reduce) {
1645
+ .smrt-admin-shell__edge--left[data-presentation='overlay'][data-state='expanded'],
1646
+ .smrt-admin-shell__edge--right[data-presentation='overlay'][data-state='expanded'],
1647
+ .smrt-admin-shell__edge[data-presentation='overlay'][data-closing],
1648
+ .smrt-admin-shell__overlay-scrim {
1649
+ animation: none;
1650
+ }
1651
+ }
1652
+
1653
+ .smrt-admin-shell__sheet-grabber {
1654
+ display: grid;
1655
+ place-items: center;
1656
+ block-size: 1.5rem;
1657
+ touch-action: pan-x;
1658
+ }
1659
+
1660
+ .smrt-admin-shell__sheet-grabber span {
1661
+ inline-size: 2.5rem;
1662
+ block-size: 0.25rem;
1663
+ border-radius: var(--smrt-radius-full);
1664
+ background: var(--smrt-color-outline-variant);
1665
+ }
1666
+
1667
+ /* Phone page contracts: breadcrumbs give way to the top bar, and so does
1668
+ the page title (visually hidden: screen readers and the outline keep
1669
+ it); page tabs stick under it and slide away with it; a form's action
1670
+ row is fixed to the bottom and replaces the bottom bar. */
1671
+ .smrt-admin-shell[data-viewport='phone'] :global([data-shell-breadcrumbs]) {
1672
+ display: none;
1673
+ }
1674
+
1675
+ .smrt-admin-shell[data-phone-top] :global([data-shell-page-title]) {
1676
+ position: absolute;
1677
+ inline-size: 1px;
1678
+ block-size: 1px;
1679
+ margin: -1px;
1680
+ padding: 0;
1681
+ overflow: hidden;
1682
+ clip-path: inset(50%);
1683
+ white-space: nowrap;
1684
+ border: 0;
1685
+ }
1686
+
1687
+ /* Tabs inside a page header (a content item's tabs under its title) stick
1688
+ like any page tabs: the header's boxes step aside on phones so the tabs'
1689
+ sticky range is the page, not the header. */
1690
+ .smrt-admin-shell[data-phone-top]
1691
+ :global([data-page-header]:has([data-shell-tabs])),
1692
+ .smrt-admin-shell[data-phone-top]
1693
+ :global([data-page-header-extra]:has([data-shell-tabs])) {
1694
+ display: contents;
1695
+ }
1696
+
1697
+ .smrt-admin-shell[data-phone-top] :global([data-shell-tabs]) {
1698
+ position: sticky;
1699
+ inset-block-start: var(--smrt-admin-shell-phone-top-size);
1700
+ z-index: 6;
1701
+ background: var(--smrt-color-surface);
1702
+ transition: transform var(--smrt-admin-shell-chrome-duration)
1703
+ var(--smrt-easing-standard, ease);
1704
+ }
1705
+
1706
+ .smrt-admin-shell[data-phone-top][data-chrome-hidden]
1707
+ :global([data-shell-tabs]) {
1708
+ transform: translateY(
1709
+ calc(-100% - var(--smrt-admin-shell-phone-top-size))
1710
+ );
1711
+ }
1712
+
1713
+ .smrt-admin-shell[data-viewport='phone'] :global([data-form-action-bar]) {
1714
+ position: fixed;
1715
+ inset: auto 0 0;
1716
+ z-index: 12;
1717
+ display: flex;
1718
+ flex-flow: row nowrap;
1719
+ align-items: center;
1720
+ gap: var(--smrt-spacing-2);
1721
+ margin: 0;
1722
+ padding: var(--smrt-spacing-2) var(--smrt-spacing-4)
1723
+ calc(var(--smrt-spacing-2) + env(safe-area-inset-bottom));
1724
+ border-block-start: 1px solid var(--smrt-color-outline-variant);
1725
+ background: var(--smrt-color-surface);
1726
+ }
1727
+
1728
+ .smrt-admin-shell[data-viewport='phone'] :global([data-form-action-bar] > *) {
1729
+ flex: 1 1 0;
1730
+ min-block-size: 2.75rem;
1731
+ }
1732
+
1733
+ :global(:root[data-keyboard-open])
1734
+ .smrt-admin-shell[data-viewport='phone']
1735
+ :global([data-form-action-bar]) {
1736
+ display: none;
1737
+ }
1738
+
1739
+ .smrt-admin-shell[data-bottom-bar='form'] .smrt-admin-shell__main {
1740
+ padding-block-end: calc(5rem + env(safe-area-inset-bottom));
1741
+ }
1742
+
1743
+ /* The phone bottom bar sits at the bottom of the dynamic viewport so the
1744
+ browser's collapsing toolbar never hides it. */
1745
+ .smrt-admin-shell[data-bottom-bar='nav'],
1746
+ .smrt-admin-shell[data-bottom-bar='form'] {
1747
+ min-block-size: 100dvh;
1748
+ block-size: 100dvh;
1749
+ }
1750
+
851
1751
  @media (max-width: 48rem) {
852
1752
  .smrt-admin-shell {
853
1753
  grid-template-columns: 0 minmax(0, 1fr) 0;
854
1754
  grid-template-rows:
1755
+ var(--smrt-admin-shell-header-track)
855
1756
  var(--smrt-admin-shell-top-track) minmax(0, 1fr)
856
- var(--smrt-admin-shell-bottom-track);
1757
+ var(--smrt-admin-shell-bottom-track)
1758
+ var(--smrt-admin-shell-phone-bar-track);
1759
+ }
1760
+
1761
+ .smrt-admin-shell__resizer {
1762
+ display: none;
857
1763
  }
858
1764
 
859
1765
  .smrt-admin-shell__edge--left,
860
1766
  .smrt-admin-shell__edge--right {
861
1767
  position: absolute;
862
1768
  grid-area: auto;
863
- inset-block: var(--smrt-admin-shell-top-track)
864
- var(--smrt-admin-shell-bottom-track);
1769
+ inset-block: calc(
1770
+ var(--smrt-admin-shell-header-track) +
1771
+ var(--smrt-admin-shell-top-track)
1772
+ )
1773
+ calc(
1774
+ var(--smrt-admin-shell-bottom-track) +
1775
+ var(--smrt-admin-shell-phone-bar-track)
1776
+ );
865
1777
  inline-size: min(22rem, 86vw);
866
1778
  transform: translateX(-100%);
867
1779
  transition: transform var(--smrt-duration-short2)
@@ -882,6 +1794,46 @@ function buildLayoutStyle(shell: ModuleShellState): string {
882
1794
  transform: translateX(0);
883
1795
  }
884
1796
 
1797
+ /* Right edge as a bottom sheet. */
1798
+ .smrt-admin-shell__edge--right[data-phone='sheet'] {
1799
+ display: grid;
1800
+ grid-template-columns: minmax(0, 1fr);
1801
+ grid-template-rows: auto minmax(0, 1fr);
1802
+ inset-inline: 0;
1803
+ inset-block-start: calc(
1804
+ var(--smrt-admin-shell-header-track) +
1805
+ var(--smrt-admin-shell-top-track) + var(--smrt-spacing-8)
1806
+ );
1807
+ inline-size: auto;
1808
+ border-inline-start: 0;
1809
+ border-start-start-radius: var(--smrt-radius-lg);
1810
+ border-start-end-radius: var(--smrt-radius-lg);
1811
+ background: var(--smrt-color-surface);
1812
+ box-shadow: var(--smrt-elevation-3);
1813
+ transform: translateY(100%);
1814
+ visibility: hidden;
1815
+ transition:
1816
+ transform var(--smrt-duration-short2) var(--smrt-easing-standard),
1817
+ visibility 0s linear var(--smrt-duration-short2);
1818
+ }
1819
+
1820
+ .smrt-admin-shell__edge--right[data-phone='sheet'][data-state='expanded'] {
1821
+ grid-template-columns: minmax(0, 1fr);
1822
+ transform: none;
1823
+ visibility: visible;
1824
+ transition: transform var(--smrt-duration-short2)
1825
+ var(--smrt-easing-standard);
1826
+ }
1827
+
1828
+ .smrt-admin-shell__edge--right[data-phone='sheet'] .smrt-admin-shell__rail {
1829
+ display: none;
1830
+ }
1831
+
1832
+ .smrt-admin-shell__edge--right[data-phone='sheet'] .smrt-admin-shell__panel--right {
1833
+ grid-column: 1;
1834
+ grid-row: 2;
1835
+ }
1836
+
885
1837
  .smrt-admin-shell__edge--top,
886
1838
  .smrt-admin-shell__edge--bottom {
887
1839
  grid-template-columns: minmax(0, 1fr);
@@ -917,8 +1869,14 @@ function buildLayoutStyle(shell: ModuleShellState): string {
917
1869
  }
918
1870
 
919
1871
  @media (prefers-reduced-motion: reduce) {
1872
+ .smrt-admin-shell {
1873
+ --smrt-admin-shell-chrome-duration: 0ms;
1874
+ }
1875
+
920
1876
  .smrt-admin-shell__edge--left,
921
- .smrt-admin-shell__edge--right {
1877
+ .smrt-admin-shell__edge--right,
1878
+ .smrt-admin-shell__edge--right[data-phone='sheet'],
1879
+ .smrt-admin-shell__edge--right[data-phone='sheet'][data-state='expanded'] {
922
1880
  transition: none;
923
1881
  }
924
1882