@getdom/studio 0.1.0 → 0.2.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 (101) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +53 -1
  3. package/SKILL.md +21 -1
  4. package/package.json +6 -2
  5. package/public/llms.txt +13 -2
  6. package/src/pages/_layout/componentCatalog.generated.js +1185 -44
  7. package/src/pages/_layout/componentLoaders.js +41 -0
  8. package/src/pages/_layout/componentManager.js +38 -27
  9. package/src/pages/_layout/componentRuntimeRecords.js +3 -1
  10. package/src/pages/charts/_shared/ChartContainer.vue +67 -0
  11. package/src/pages/charts/_shared/ChartLegend.vue +22 -0
  12. package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
  13. package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
  14. package/src/pages/charts/_shared/XYChart.vue +140 -0
  15. package/src/pages/charts/_shared/XYRenderer.vue +90 -0
  16. package/src/pages/charts/_shared/chartProps.js +23 -0
  17. package/src/pages/charts/_shared/chartUtils.js +95 -0
  18. package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
  19. package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
  20. package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
  21. package/src/pages/charts/date-range-picker/dateRange.js +69 -0
  22. package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
  23. package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
  24. package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
  25. package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
  26. package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
  27. package/src/pages/components/_shared/media/useMediaInput.js +200 -0
  28. package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
  29. package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
  30. package/src/pages/components/button/DomButton.vue +10 -4
  31. package/src/pages/components/button/buttonStyles.js +5 -0
  32. package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
  33. package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
  34. package/src/pages/components/dialog/DomDialog.vue +338 -36
  35. package/src/pages/components/icon-button/DomIconButton.vue +8 -2
  36. package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
  37. package/src/pages/components/menu-stack/menuTree.js +35 -0
  38. package/src/pages/components/popover/DomPopover.vue +1 -53
  39. package/src/pages/components/popover/popover.css +50 -0
  40. package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
  41. package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
  42. package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
  43. package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
  44. package/src/pages/data/gantt/DomGantt.vue +788 -0
  45. package/src/pages/data/gantt/ganttTime.js +223 -0
  46. package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
  47. package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
  48. package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
  49. package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
  50. package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
  51. package/src/pages/designer/_shared/cssEditorContext.js +19 -0
  52. package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
  53. package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
  54. package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
  55. package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
  56. package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
  57. package/src/pages/designer/background-editor/backgroundModel.js +102 -0
  58. package/src/pages/designer/border-editor/BorderControl.vue +72 -0
  59. package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
  60. package/src/pages/designer/border-editor/borderModel.js +21 -0
  61. package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
  62. package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
  63. package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
  64. package/src/pages/designer/frame/frameLayout.js +93 -0
  65. package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
  66. package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
  67. package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
  68. package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
  69. package/src/pages/designer/stage/DesignerBounds.vue +31 -0
  70. package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
  71. package/src/pages/designer/stage/SpacingGuides.vue +33 -0
  72. package/src/pages/designer/stage/resizeGeometry.js +15 -0
  73. package/src/pages/designer/stage/snapGeometry.js +69 -0
  74. package/src/pages/designer/stage/snapPipeline.js +17 -0
  75. package/src/pages/designer/stage/spacingGeometry.js +129 -0
  76. package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
  77. package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
  78. package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
  79. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
  80. package/src/pages/forms/toggle/DomToggle.vue +1 -1
  81. package/src/pages/lib/headless/base.js +10 -5
  82. package/src/pages/lib/headless/dialog.js +25 -6
  83. package/src/pages/lib/headless/drawer.js +49 -21
  84. package/src/pages/lib/vue/index.d.ts +1161 -14
  85. package/src/pages/lib/vue/index.js +43 -0
  86. package/src/pages/mobile/_shared/interactionMath.js +46 -0
  87. package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
  88. package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
  89. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
  90. package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
  91. package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
  92. package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
  93. package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
  94. package/src/pages/mobile/shell/DomAppShell.vue +60 -9
  95. package/src/pages/mobile/stack/DomAppStack.vue +214 -55
  96. package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
  97. package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
  98. package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
  99. package/src/style.css +2 -445
  100. package/src/tailwind.css +351 -0
  101. package/src/theme.css +99 -0
@@ -15,10 +15,11 @@ const StackScreenRenderer = defineComponent({
15
15
  emptyTitle: { type: String, default: 'Select a setting' },
16
16
  emptyDescription: { type: String, default: 'Choose an item to continue.' },
17
17
  },
18
+ /** Returns the renderer for a resolved, loading, or empty screen. */
18
19
  setup(props) {
19
20
  return () => {
20
21
  if (props.loading) return renderLoading(props.screen, props.loadingLabel);
21
- if (props.error) return renderError(props.error);
22
+ if (props.error) return renderError(props.error, props.helpers.retry);
22
23
  if (!props.screen) return renderEmpty(props.emptyTitle, props.emptyDescription);
23
24
  if (props.screen.slot) return props.screen.slot({ ...props.helpers, screen: props.screen });
24
25
  if (props.component) {
@@ -35,6 +36,9 @@ const StackScreenRenderer = defineComponent({
35
36
  },
36
37
  });
37
38
 
39
+ /**
40
+ * Builds accessible navigation rows for the current screen children.
41
+ */
38
42
  function renderGeneratedMenu(screen, childScreens, helpers) {
39
43
  return h('div', { class: 'overflow-hidden rounded-[1.5rem] border border-border bg-canvas' }, childScreens.map((child) => {
40
44
  const slots = child.icon ? {
@@ -59,6 +63,9 @@ function renderGeneratedMenu(screen, childScreens, helpers) {
59
63
  }));
60
64
  }
61
65
 
66
+ /**
67
+ * Renders the loading state while a screen module resolves.
68
+ */
62
69
  function renderLoading(screen, fallbackLabel) {
63
70
  return h('div', { class: 'grid min-h-64 place-items-center p-6 text-center' }, [
64
71
  h('div', { class: 'space-y-3' }, [
@@ -71,15 +78,22 @@ function renderLoading(screen, fallbackLabel) {
71
78
  ]);
72
79
  }
73
80
 
74
- function renderError(error) {
81
+ /**
82
+ * Renders a recoverable screen-loading failure.
83
+ */
84
+ function renderError(error, retry) {
75
85
  return h('div', { class: 'grid min-h-64 place-items-center p-6 text-center' }, [
76
86
  h('div', { class: 'max-w-sm space-y-2 rounded-2xl border border-destructive/30 bg-destructive/10 p-5' }, [
77
87
  h('p', { class: 'text-sm font-semibold text-destructive' }, 'Unable to load this setting'),
78
88
  h('p', { class: 'text-xs leading-5 text-muted-fg' }, error?.message || 'The screen loader rejected.'),
89
+ h('button', { type: 'button', class: 'min-h-11 rounded-lg px-4 font-medium text-canvas-fg focus-visible:outline-2 focus-visible:outline-ring', onClick: retry }, 'Try again'),
79
90
  ]),
80
91
  ]);
81
92
  }
82
93
 
94
+ /**
95
+ * Renders the fallback when a screen has no content.
96
+ */
83
97
  function renderEmpty(title, description) {
84
98
  return h('div', { class: 'grid min-h-64 place-items-center p-6 text-center' }, [
85
99
  h('div', { class: 'max-w-sm' }, [
@@ -93,10 +107,11 @@ defineOptions({
93
107
  __doc: {
94
108
  name: 'App stack',
95
109
  tag: '<DomAppStack>',
96
- description: 'A settings-style screen stack that handles slide navigation, breadcrumbs, lazy screens, and responsive panel styling.',
110
+ description: 'A bounded screen stack with an iOS-style back button and sliding title on mobile, desktop breadcrumbs, and lazy screens. Fills the viewport by default. Inside DomAppShell, set height="100%" and disable the shell content-scroll so each screen owns scrolling.',
97
111
  slots: [
98
112
  { name: '(default)', description: 'Optional DomAppStackScreen children used to register slot-driven screens.' },
99
- { name: 'breadcrumb', payload: '{ crumbs, canGoBack, back, goTo, push, path }', description: 'Replace the breadcrumb and back control while keeping the stack state.' },
113
+ { name: 'header', payload: '{ activeScreen, canGoBack, back, path }', description: 'Replace the mobile header; the header transitions with its screen.' },
114
+ { name: 'breadcrumb', payload: '{ crumbs, canGoBack, back, goTo, push, path }', description: 'Replace the desktop breadcrumb and back control while keeping the stack state.' },
100
115
  ],
101
116
  events: [
102
117
  { name: 'update:path', payload: '(path: Array<string>)', description: 'Fired when the active stack path changes.' },
@@ -105,12 +120,18 @@ defineOptions({
105
120
  { name: 'error', payload: '(payload: { screen, error })', description: 'Fired when a lazy screen component rejects.' },
106
121
  ],
107
122
  playground: {
108
- mobileSlot: 'content',
123
+ mobileSlot: 'stack',
109
124
  initial: {
125
+ height: '100%',
110
126
  root: 'settings',
111
127
  screens: [
112
- { id: 'settings', title: 'Settings', children: ['account', 'notifications'] },
113
- { id: 'account', title: 'Account', description: 'Profile, password, and sign-in settings.' },
128
+ { id: 'settings', title: 'Settings', children: ['account', 'notifications'], description: '' },
129
+ { id: 'account', title: 'Account', description: 'Profile and preferences.', children: ['general', 'apps'] },
130
+ { id: 'general', title: 'General', children: ['language'], description: '' },
131
+ { id: 'language', title: 'Language & Region', description: 'English (UK), United Kingdom.' },
132
+ { id: 'apps', title: 'Apps', children: ['calendar'], description: '' },
133
+ { id: 'calendar', title: 'Calendar', children: ['calendar-notifications'], description: '' },
134
+ { id: 'calendar-notifications', title: 'Notifications', description: 'Event reminders, invitations, and shared calendars.' },
114
135
  { id: 'notifications', title: 'Notifications', description: 'Push, email, and quiet hours.' },
115
136
  ],
116
137
  },
@@ -119,6 +140,16 @@ defineOptions({
119
140
  });
120
141
 
121
142
  const props = defineProps({
143
+ height: {
144
+ type: String,
145
+ default: '100dvh',
146
+ _edit: { description: 'Bounded stack height. Use 100% inside DomAppShell with content-scroll disabled, or a CSS length for an embedded stack.' },
147
+ },
148
+ showHeader: {
149
+ type: Boolean,
150
+ default: true,
151
+ _edit: { description: 'Show the mobile back button and current screen title.' },
152
+ },
122
153
  path: {
123
154
  type: Array,
124
155
  default: null,
@@ -157,7 +188,7 @@ const props = defineProps({
157
188
  showBreadcrumbs: {
158
189
  type: Boolean,
159
190
  default: true,
160
- _edit: { description: 'Render the default breadcrumb bar, or provide the breadcrumb slot.' },
191
+ _edit: { description: 'Show desktop breadcrumbs. Mobile uses the current title and back button; showHeader controls that header.' },
161
192
  },
162
193
  animate: {
163
194
  type: Boolean,
@@ -226,6 +257,8 @@ const internalPath = ref([]);
226
257
  const direction = ref('forward');
227
258
  const isDesktopMedia = ref(false);
228
259
  const screenPanel = ref(null);
260
+ const visitedPaths = reactive(new Map());
261
+ const hasMounted = ref(false);
229
262
 
230
263
  let removeDesktopMediaListener = null;
231
264
 
@@ -277,7 +310,6 @@ const crumbs = computed(() => resolvedPath.value.map((id, index) => {
277
310
  const canGoBack = computed(() => resolvedPath.value.length > 1);
278
311
  const isDesktopLayout = computed(() => props.layout === 'desktop' || (props.layout === 'auto' && isDesktopMedia.value));
279
312
  const transitionName = computed(() => (props.animate ? `dom-app-stack-${direction.value}` : ''));
280
- const activeComponent = computed(() => componentForScreen(activeScreen.value));
281
313
  const helpers = computed(() => ({
282
314
  activeId: activeId.value,
283
315
  back,
@@ -291,6 +323,7 @@ const helpers = computed(() => ({
291
323
  push,
292
324
  replace,
293
325
  reset,
326
+ retry: retryActiveScreen,
294
327
  }));
295
328
  const breadcrumbSlotProps = computed(() => ({
296
329
  ...helpers.value,
@@ -304,6 +337,7 @@ watch(rootId, (id) => {
304
337
  }, { immediate: true });
305
338
 
306
339
  watch(activeScreen, (screen) => {
340
+ if (screen) visitedPaths.set(screen.id, [...resolvedPath.value]);
307
341
  ensureLoaded(screen);
308
342
  }, { immediate: true });
309
343
 
@@ -313,6 +347,9 @@ watch(() => props.path, (next, previous) => {
313
347
  }, { deep: true });
314
348
 
315
349
  onMounted(() => {
350
+ // Each first visit mounts a new Transition. Animate those appearances, but
351
+ // keep the initial screen still during the stack's own mount and hydration.
352
+ hasMounted.value = true;
316
353
  configureDesktopMedia();
317
354
  });
318
355
 
@@ -324,6 +361,9 @@ onBeforeUnmount(() => {
324
361
  removeDesktopMediaListener?.();
325
362
  });
326
363
 
364
+ /**
365
+ * Normalizes an application-owned screen record without mutating it.
366
+ */
327
367
  function normalizeScreen(screen) {
328
368
  if (!screen || !screen.id) return null;
329
369
  return {
@@ -334,6 +374,9 @@ function normalizeScreen(screen) {
334
374
  };
335
375
  }
336
376
 
377
+ /**
378
+ * Collects declarative screen registrations from the default slot.
379
+ */
337
380
  function extractSlotScreens(vnodes) {
338
381
  const screens = [];
339
382
 
@@ -357,11 +400,17 @@ function extractSlotScreens(vnodes) {
357
400
  return screens;
358
401
  }
359
402
 
403
+ /**
404
+ * Identifies a declarative screen registration vnode.
405
+ */
360
406
  function isStackScreenVNode(vnode) {
361
407
  const type = vnode?.type;
362
408
  return type?.name === 'DomAppStackScreen' || type?.__name === 'AppStackScreen';
363
409
  }
364
410
 
411
+ /**
412
+ * Resolves child records and identifiers into stable string ids.
413
+ */
365
414
  function normalizeChildren(children) {
366
415
  if (!Array.isArray(children)) return [];
367
416
  return children
@@ -372,23 +421,35 @@ function normalizeChildren(children) {
372
421
  .filter(Boolean);
373
422
  }
374
423
 
424
+ /**
425
+ * Normalizes a controlled path and supplies the root when empty.
426
+ */
375
427
  function normalizePath(path) {
376
428
  const next = Array.isArray(path) ? path.map((item) => String(item)).filter(Boolean) : [];
377
429
  if (!next.length && rootId.value) return [rootId.value];
378
430
  return next;
379
431
  }
380
432
 
433
+ /**
434
+ * Compares navigation paths without changing either array.
435
+ */
381
436
  function samePath(a, b) {
382
437
  if (a.length !== b.length) return false;
383
438
  return a.every((item, index) => item === b[index]);
384
439
  }
385
440
 
441
+ /**
442
+ * Determines whether navigation pushes forward or returns to an ancestor.
443
+ */
386
444
  function compareDirection(next, previous = []) {
387
445
  if (next.length > previous.length && previous.every((id, index) => next[index] === id)) return 'forward';
388
446
  if (next.length < previous.length && next.every((id, index) => previous[index] === id)) return 'back';
389
447
  return next.length >= previous.length ? 'forward' : 'back';
390
448
  }
391
449
 
450
+ /**
451
+ * Builds an ancestry path and rejects cycles or disconnected parents.
452
+ */
392
453
  function pathToScreen(id) {
393
454
  const target = String(id || '');
394
455
  if (!target) return null;
@@ -409,6 +470,9 @@ function pathToScreen(id) {
409
470
  return path;
410
471
  }
411
472
 
473
+ /**
474
+ * Commits a navigation change and reports its direction to consumers.
475
+ */
412
476
  function commitPath(path, reason = 'navigate') {
413
477
  const previousPath = resolvedPath.value;
414
478
  const nextPath = normalizePath(path);
@@ -428,6 +492,9 @@ function commitPath(path, reason = 'navigate') {
428
492
  nextTick(focusActivePanel);
429
493
  }
430
494
 
495
+ /**
496
+ * Opens a screen, reusing its existing ancestry where available.
497
+ */
431
498
  function push(id) {
432
499
  const target = String(id || '');
433
500
  if (!target) return;
@@ -439,18 +506,27 @@ function push(id) {
439
506
  commitPath(pathToScreen(target) || [...resolvedPath.value, target], 'push');
440
507
  }
441
508
 
509
+ /**
510
+ * Replaces the active destination with the requested screen.
511
+ */
442
512
  function replace(id) {
443
513
  const target = String(id || '');
444
514
  if (!target) return;
445
515
  commitPath(pathToScreen(target) || [...resolvedPath.value.slice(0, -1), target], 'replace');
446
516
  }
447
517
 
518
+ /**
519
+ * Returns the requested number of levels without leaving the root.
520
+ */
448
521
  function back(steps = 1) {
449
522
  const count = Math.max(1, Number(steps) || 1);
450
523
  if (resolvedPath.value.length <= 1) return;
451
524
  commitPath(resolvedPath.value.slice(0, Math.max(1, resolvedPath.value.length - count)), 'back');
452
525
  }
453
526
 
527
+ /**
528
+ * Navigates to a breadcrumb index or registered screen id.
529
+ */
454
530
  function goTo(target) {
455
531
  if (typeof target === 'number') {
456
532
  const index = Math.max(0, Math.min(target, resolvedPath.value.length - 1));
@@ -468,16 +544,25 @@ function goTo(target) {
468
544
  commitPath(pathToScreen(id) || [...resolvedPath.value, id], 'breadcrumb');
469
545
  }
470
546
 
547
+ /**
548
+ * Resets navigation to the root or another registered destination.
549
+ */
471
550
  function reset(id = rootId.value) {
472
551
  const target = String(id || '');
473
552
  commitPath(pathToScreen(target) || (target ? [target] : []), 'reset');
474
553
  }
475
554
 
555
+ /**
556
+ * Opens a child under its declared parent path.
557
+ */
476
558
  function openChild(parentId, childId) {
477
559
  const parentPath = pathToScreen(parentId) || resolvedPath.value;
478
560
  commitPath([...parentPath, String(childId)], 'push');
479
561
  }
480
562
 
563
+ /**
564
+ * Resolves the unique child records belonging to a screen.
565
+ */
481
566
  function childScreensFor(screen) {
482
567
  if (!screen) return [];
483
568
  const childIds = [
@@ -489,10 +574,16 @@ function childScreensFor(screen) {
489
574
  .filter(Boolean);
490
575
  }
491
576
 
577
+ /**
578
+ * Detects the documented function-based lazy screen loader.
579
+ */
492
580
  function isLazyScreenComponent(component) {
493
581
  return typeof component === 'function';
494
582
  }
495
583
 
584
+ /**
585
+ * Returns a resolved component without invoking its loader during render.
586
+ */
496
587
  function componentForScreen(screen) {
497
588
  if (!screen) return null;
498
589
  if (loadedComponents.has(screen.id)) return loadedComponents.get(screen.id);
@@ -500,6 +591,9 @@ function componentForScreen(screen) {
500
591
  return screen.component || null;
501
592
  }
502
593
 
594
+ /**
595
+ * Loads each asynchronous screen once and records success or failure.
596
+ */
503
597
  async function ensureLoaded(screen) {
504
598
  if (!screen || screen.slot || loadedComponents.has(screen.id) || loadingScreens.has(screen.id)) return;
505
599
  const loader = screen.loader || (isLazyScreenComponent(screen.component) ? screen.component : null);
@@ -521,64 +615,88 @@ async function ensureLoaded(screen) {
521
615
  }
522
616
  }
523
617
 
618
+ /**
619
+ * Reports whether the requested screen has a pending loader.
620
+ */
524
621
  function isScreenLoading(screen) {
525
622
  return !!screen && loadingScreens.has(screen.id);
526
623
  }
527
624
 
625
+ /**
626
+ * Returns the most recent loader error for a screen.
627
+ */
528
628
  function screenError(screen) {
529
629
  return screen ? loadErrors.get(screen.id) : null;
530
630
  }
531
631
 
632
+ /**
633
+ * Focuses the visible screen without moving its retained scroll position.
634
+ */
532
635
  function focusActivePanel() {
533
636
  if (!props.focusOnNavigate) return;
534
- screenPanel.value?.focus?.({ preventScroll: true });
637
+ screenPanel.value?.querySelector('[data-dom-app-stack-screen]:not([inert]) [data-dom-app-stack-pane]')?.focus({ preventScroll: true });
535
638
  }
536
639
 
537
- function mediaQueryForBreakpoint(value) {
538
- const text = String(value || '').trim();
539
- if (!text) return '(min-width: 768px)';
540
- if (text.startsWith('(')) return text;
541
- return `(min-width: ${text})`;
640
+ /** Retries the active loader after a recoverable failure. */
641
+ function retryActiveScreen() {
642
+ ensureLoaded(activeScreen.value);
542
643
  }
543
644
 
645
+ /** Observes the actual stack width, including when embedded in a desktop pane or iframe. */
544
646
  function configureDesktopMedia() {
545
- if (typeof window === 'undefined') return;
647
+ const element = screenPanel.value;
648
+ const view = element?.ownerDocument.defaultView;
649
+ if (!element || !view) return;
546
650
  removeDesktopMediaListener?.();
547
-
548
- const query = window.matchMedia(mediaQueryForBreakpoint(props.desktopBreakpoint));
549
- const update = () => {
550
- isDesktopMedia.value = query.matches;
551
- };
552
-
651
+ const breakpoint = String(props.desktopBreakpoint || '768px').trim();
652
+ if (breakpoint.startsWith('(')) {
653
+ const query = view.matchMedia(breakpoint);
654
+ /** Synchronizes an explicitly supplied media-query breakpoint. */
655
+ const update = () => { isDesktopMedia.value = query.matches; };
656
+ update();
657
+ query.addEventListener('change', update);
658
+ removeDesktopMediaListener = () => query.removeEventListener('change', update);
659
+ return;
660
+ }
661
+ const probe = element.ownerDocument.createElement('span');
662
+ probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;height:0;';
663
+ probe.style.width = breakpoint;
664
+ element.appendChild(probe);
665
+ /** Resolves a CSS-length breakpoint against the component's own container. */
666
+ const update = () => { isDesktopMedia.value = element.clientWidth >= (probe.getBoundingClientRect().width || 768); };
667
+ const observer = new view.ResizeObserver(update);
668
+ observer.observe(element);
669
+ observer.observe(probe);
553
670
  update();
554
- if (query.addEventListener) query.addEventListener('change', update);
555
- else query.addListener(update);
556
671
  removeDesktopMediaListener = () => {
557
- if (query.removeEventListener) query.removeEventListener('change', update);
558
- else query.removeListener(update);
672
+ observer.disconnect();
673
+ probe.remove();
559
674
  };
560
675
  }
676
+
561
677
  </script>
562
678
 
563
679
  <template>
564
680
  <div
565
681
  data-dom-app-stack
566
- class="flex h-full min-h-[28rem] w-full flex-col overflow-hidden bg-canvas text-canvas-fg"
682
+ ref="screenPanel"
683
+ class="[&_input]:text-base [&_textarea]:text-base [&_select]:text-base relative flex min-h-0 min-w-0 w-full flex-col overflow-hidden bg-canvas text-canvas-fg pt-[var(--dom-safe-area-top,env(safe-area-inset-top,0px))] pb-[var(--dom-safe-area-bottom,env(safe-area-inset-bottom,0px))] pl-[var(--dom-safe-area-left,env(safe-area-inset-left,0px))] pr-[var(--dom-safe-area-right,env(safe-area-inset-right,0px))]"
684
+ :style="{ height }"
567
685
  >
568
686
  <slot
569
- v-if="showBreadcrumbs"
687
+ v-if="isDesktopLayout && showBreadcrumbs"
570
688
  name="breadcrumb"
571
689
  v-bind="breadcrumbSlotProps"
572
690
  >
573
691
  <nav
574
- class="flex min-h-12 items-center gap-2 border-b border-border bg-canvas/95 px-2 backdrop-blur"
692
+ class="flex min-h-12 shrink-0 items-center gap-2 border-b border-border bg-canvas/95 px-2 backdrop-blur"
575
693
  :class="breadcrumbClass"
576
694
  aria-label="Settings path"
577
695
  >
578
696
  <button
579
697
  v-if="canGoBack"
580
698
  type="button"
581
- class="inline-flex h-9 shrink-0 items-center gap-1 rounded-full px-2.5 text-sm font-medium text-canvas-fg transition hover:bg-secondary active:scale-95"
699
+ class="inline-flex min-h-11 shrink-0 items-center gap-1 rounded-full px-2.5 text-sm font-medium text-canvas-fg transition hover:bg-secondary active:scale-95"
582
700
  :class="backButtonClass"
583
701
  @click="back()"
584
702
  >
@@ -597,7 +715,7 @@ function configureDesktopMedia() {
597
715
  <button
598
716
  v-if="index < crumbs.length - 1"
599
717
  type="button"
600
- class="max-w-36 truncate rounded-full px-2.5 py-1.5 font-medium text-muted-fg transition hover:bg-secondary hover:text-canvas-fg"
718
+ class="min-h-11 max-w-36 truncate rounded-full px-2.5 py-1.5 font-medium text-muted-fg transition hover:bg-secondary hover:text-canvas-fg"
601
719
  :class="breadcrumbItemClass"
602
720
  @click="goTo(index)"
603
721
  >{{ crumb.title }}</button>
@@ -621,45 +739,86 @@ function configureDesktopMedia() {
621
739
  </nav>
622
740
  </slot>
623
741
 
624
- <div class="relative min-h-0 flex-1 overflow-hidden">
625
- <Transition :name="transitionName">
742
+ <div class="relative min-h-0 flex-1 overflow-hidden [--dom-safe-area-top:0px] [--dom-safe-area-bottom:0px] [--dom-safe-area-left:0px] [--dom-safe-area-right:0px]">
743
+ <Transition
744
+ v-for="[id, screenPath] in visitedPaths"
745
+ :key="id"
746
+ :name="transitionName"
747
+ :appear="hasMounted && animate"
748
+ @after-enter="focusActivePanel"
749
+ >
626
750
  <section
627
- :key="activeId || 'empty'"
628
- ref="screenPanel"
629
- tabindex="-1"
630
- data-dom-app-stack-pane
631
- class="absolute inset-0 min-h-0 overflow-y-auto outline-none [-webkit-overflow-scrolling:touch]"
632
- :class="[isDesktopLayout ? 'p-4 md:p-6' : 'p-4', isDesktopLayout ? desktopContentClass : mobilePanelClass]"
633
- :aria-busy="isScreenLoading(activeScreen) ? 'true' : undefined"
751
+ v-show="id === activeId"
752
+ :data-dom-app-stack-screen="id"
753
+ :inert="id !== activeId"
754
+ class="absolute inset-0 flex min-h-0 flex-col bg-canvas"
634
755
  >
635
- <StackScreenRenderer
636
- :screen="activeScreen"
637
- :component="activeComponent"
638
- :helpers="helpers"
639
- :child-screens="childScreensFor(activeScreen)"
640
- :loading="isScreenLoading(activeScreen)"
641
- :error="screenError(activeScreen)"
642
- :loading-label="loadingLabel"
643
- :empty-title="emptyTitle"
644
- :empty-description="emptyDescription"
645
- />
756
+ <slot v-if="!isDesktopLayout && showHeader" name="header" v-bind="{ ...breadcrumbSlotProps, activeScreen: screenById.get(id), path: screenPath, canGoBack: screenPath.length > 1 }">
757
+ <header class="grid min-h-11 shrink-0 grid-cols-[1fr_minmax(0,2fr)_1fr] items-center border-b border-border bg-canvas/95 px-2">
758
+ <button
759
+ v-if="screenPath.length > 1"
760
+ type="button"
761
+ class="inline-flex min-h-11 min-w-11 items-center gap-0.5 justify-self-start rounded-lg pr-2 text-sm text-primary active:opacity-60 focus-visible:outline-2 focus-visible:outline-ring"
762
+ :class="backButtonClass"
763
+ :aria-label="`Back to ${screenById.get(screenPath.at(-2))?.title || 'previous page'}`"
764
+ @click="back()"
765
+ >
766
+ <svg class="size-6 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
767
+ <path d="M12.7 4.3a1 1 0 0 1 0 1.4L8.42 10l4.3 4.3a1 1 0 0 1-1.42 1.4l-5-5a1 1 0 0 1 0-1.4l5-5a1 1 0 0 1 1.42 0Z" />
768
+ </svg>
769
+ <span>Back</span>
770
+ </button>
771
+ <span v-else aria-hidden="true"></span>
772
+ <h1 class="truncate text-center text-base font-semibold tracking-tight">{{ screenById.get(id)?.title || emptyTitle }}</h1>
773
+ </header>
774
+ </slot>
775
+ <div
776
+ data-dom-app-stack-pane
777
+ tabindex="-1"
778
+ :aria-label="screenById.get(id)?.title"
779
+ class="min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none"
780
+ :class="[isDesktopLayout ? 'p-6' : 'p-3', isDesktopLayout ? desktopContentClass : mobilePanelClass]"
781
+ :aria-busy="isScreenLoading(screenById.get(id)) ? 'true' : undefined"
782
+ >
783
+ <StackScreenRenderer
784
+ :screen="screenById.get(id)"
785
+ :component="componentForScreen(screenById.get(id))"
786
+ :helpers="helpers"
787
+ :child-screens="childScreensFor(screenById.get(id))"
788
+ :loading="isScreenLoading(screenById.get(id))"
789
+ :error="screenError(screenById.get(id))"
790
+ :loading-label="loadingLabel"
791
+ :empty-title="emptyTitle"
792
+ :empty-description="emptyDescription"
793
+ />
794
+ </div>
646
795
  </section>
647
796
  </Transition>
797
+ <div v-if="!activeScreen" class="p-6 text-center text-sm text-muted-fg">{{ emptyTitle }}</div>
648
798
  </div>
649
799
  </div>
650
800
  </template>
651
801
 
652
802
  <style scoped>
653
- [data-dom-app-stack-pane] {
803
+ [data-dom-app-stack-screen] {
654
804
  backface-visibility: hidden;
655
- will-change: transform, opacity;
805
+ }
806
+
807
+ .dom-app-stack-forward-enter-active,
808
+ .dom-app-stack-back-leave-active {
809
+ z-index: 2;
810
+ }
811
+
812
+ .dom-app-stack-forward-leave-active,
813
+ .dom-app-stack-back-enter-active {
814
+ z-index: 1;
656
815
  }
657
816
 
658
817
  .dom-app-stack-forward-enter-active,
659
818
  .dom-app-stack-back-enter-active {
660
819
  pointer-events: none;
661
820
  transition:
662
- transform 360ms cubic-bezier(0.4, 0, 0.2, 1),
821
+ transform 320ms cubic-bezier(0.32, 0.72, 0, 1),
663
822
  opacity 220ms ease;
664
823
  }
665
824
 
@@ -667,7 +826,7 @@ function configureDesktopMedia() {
667
826
  .dom-app-stack-back-leave-active {
668
827
  pointer-events: none;
669
828
  transition:
670
- transform 360ms cubic-bezier(0.4, 0, 0.2, 1),
829
+ transform 320ms cubic-bezier(0.32, 0.72, 0, 1),
671
830
  opacity 140ms ease 180ms;
672
831
  }
673
832