@getdom/studio 0.1.1 → 0.2.1

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 (99) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +31 -0
  3. package/SKILL.md +21 -1
  4. package/package.json +10 -4
  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/theme.css +13 -0
@@ -0,0 +1,221 @@
1
+ <script setup>
2
+ import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue';
3
+ import { normalizeSnapPoints, resolveSnapPoint } from '../_shared/interactionMath.js';
4
+ import { useKeyboardViewport } from '../_shared/useKeyboardViewport.js';
5
+
6
+ defineOptions({
7
+ __doc: {
8
+ name: 'Bottom sheet',
9
+ tag: '<DomBottomSheet>',
10
+ description: 'A modal content sheet with configurable snap heights, a draggable and keyboard-operable handle, scrolling body, and pinned footer. Uses the shared drawer for focus, dismissal, and scroll locking. Works standalone or in the DomAppShell overlay slot.',
11
+ slots: [
12
+ { name: '(default)', payload: '{ close, expand }', description: 'Scrollable sheet content.' },
13
+ { name: 'header', description: 'Custom heading content beside the close button.' },
14
+ { name: 'footer', payload: '{ close, expand }', description: 'Pinned controls, such as a reply composer.' },
15
+ ],
16
+ events: [
17
+ { name: 'update:modelValue', payload: 'boolean', description: 'Open or dismiss the sheet.' },
18
+ { name: 'update:snapPoint', payload: 'number', description: 'The selected fraction of the available sheet viewport.' },
19
+ ],
20
+ keyboard: [
21
+ { keys: 'Handle: Arrow Up / Down', action: 'Expand or collapse to the next visible stop.' },
22
+ { keys: 'Handle: Home / End', action: 'Choose the smallest or largest visible stop.' },
23
+ { keys: 'Handle: Enter / Space', action: 'Cycle through visible snap heights.' },
24
+ { keys: 'Escape', action: 'Dismiss and return focus to the opener.' },
25
+ ],
26
+ },
27
+ });
28
+
29
+ const props = defineProps({
30
+ modelValue: { type: Boolean, default: false, _edit: { description: 'Whether the modal sheet is open.' } },
31
+ title: { type: String, default: '', _edit: { description: 'Visible title and accessible dialog name.' } },
32
+ label: { type: String, default: 'Details', _edit: { description: 'Accessible dialog name when supplying a custom header without a title.' } },
33
+ snapPoints: { type: Array, default: () => [0, 0.5, 0.9], _edit: { typeRef: 'Array<number>', description: 'Fractions of the available height, from 0 to 1. Include 0 to allow drag dismissal.' } },
34
+ initialSnapPoint: { type: Number, default: 0.5, _edit: { description: 'Preferred height on each open, rounded to the nearest non-zero stop.' } },
35
+ snapPoint: { type: Number, default: null, _edit: { typeRef: 'number | null', description: 'Optional controlled height. Use v-model:snapPoint; null uses internal state.' } },
36
+ contained: { type: Boolean, default: undefined, _edit: { description: 'Defaults to contained in DomAppShell, viewport otherwise. Use the shell overlay slot.' } },
37
+ static: { type: Boolean, default: false, _edit: { description: 'Disable backdrop dismissal. Close and Escape remain available; omit zero from snapPoints to disable drag dismissal.' } },
38
+ keyboardInset: { type: Number, default: null, _edit: { typeRef: 'number | null', description: 'Optional native keyboard inset for standalone sheets. Contained sheets inherit the resized shell.' } },
39
+ });
40
+ const emit = defineEmits(['update:modelValue', 'update:snapPoint']);
41
+ const drawer = ref(null);
42
+ const overlay = ref(null);
43
+ const panel = ref(null);
44
+ const handle = ref(null);
45
+ const teleportTarget = ref(null);
46
+ const shell = inject('dom-app-shell', null);
47
+ const isContained = computed(() => props.contained ?? Boolean(shell));
48
+ const points = computed(() => normalizeSnapPoints(props.snapPoints));
49
+ const visiblePoints = computed(() => points.value.filter((point) => point > 0));
50
+ const localSnap = ref(resolveSnapPoint(visiblePoints.value, props.initialSnapPoint));
51
+ const currentSnap = computed(() => resolveSnapPoint(visiblePoints.value, props.snapPoint ?? localSnap.value));
52
+ const dragHeight = ref(null);
53
+ const dragging = computed(() => dragHeight.value !== null);
54
+ const titleId = `dom-bottom-sheet-${useId()}`;
55
+ const handleDescriptionId = `${titleId}-size`;
56
+ const keyboard = useKeyboardViewport(drawer, () => !isContained.value && props.modelValue, () => props.keyboardInset);
57
+ const viewportStyle = computed(() => keyboard.open.value ? { height: `${keyboard.available.value}px`, top: `${keyboard.top.value}px`, bottom: 'auto', '--dom-safe-area-bottom': '0px' } : undefined);
58
+ const panelStyle = computed(() => ({
59
+ height: dragging.value ? `${dragHeight.value}px` : `${currentSnap.value * 100}%`,
60
+ transition: dragging.value ? 'none' : undefined,
61
+ }));
62
+ let gesture = null;
63
+ let ready = false;
64
+ let removeListeners;
65
+ let suppressClickUntil = 0;
66
+
67
+ /** Requests dismissal through the component's controlled open state. */
68
+ function close() {
69
+ cancelDrag();
70
+ emit('update:modelValue', false);
71
+ }
72
+
73
+ /** Sets a valid stop or requests dismissal for the explicit zero stop. */
74
+ function snapTo(value) {
75
+ const next = resolveSnapPoint(points.value, value);
76
+ if (next === 0) return close();
77
+ localSnap.value = next;
78
+ emit('update:snapPoint', next);
79
+ }
80
+
81
+ /** Expands to the largest configured stop, including before the keyboard opens. */
82
+ function expand() {
83
+ snapTo(visiblePoints.value.at(-1));
84
+ }
85
+
86
+ /** Cycles visible heights when the handle is activated without dragging. */
87
+ function cycle() {
88
+ if (Date.now() < suppressClickUntil) return;
89
+ const index = visiblePoints.value.indexOf(currentSnap.value);
90
+ snapTo(visiblePoints.value[(index + 1) % visiblePoints.value.length]);
91
+ }
92
+
93
+ /** Supports predictable keyboard resizing without triggering swipe dismissal. */
94
+ function onHandleKey(event) {
95
+ const index = visiblePoints.value.indexOf(currentSnap.value);
96
+ const destinations = { ArrowUp: index + 1, ArrowDown: index - 1, Home: 0, End: visiblePoints.value.length - 1 };
97
+ if (!(event.key in destinations)) return;
98
+ event.preventDefault();
99
+ snapTo(visiblePoints.value[Math.max(0, Math.min(visiblePoints.value.length - 1, destinations[event.key]))]);
100
+ }
101
+
102
+ /** Starts a dedicated handle gesture; body scrolling stays native and independent. */
103
+ function onPointerDown(event) {
104
+ if (!props.modelValue || gesture || !event.isPrimary || event.button !== 0) return;
105
+ const available = overlay.value.clientHeight;
106
+ if (!available) return;
107
+ gesture = { id: event.pointerId, startY: event.clientY, height: panel.value.getBoundingClientRect().height, available, lastY: event.clientY, time: event.timeStamp, velocity: 0, moved: false };
108
+ handle.value.setPointerCapture(event.pointerId);
109
+ }
110
+
111
+ /** Resizes the panel with the finger while keeping its footer inside the visible area. */
112
+ function onPointerMove(event) {
113
+ if (!gesture || event.pointerId !== gesture.id) return;
114
+ const delta = gesture.startY - event.clientY;
115
+ if (!gesture.moved && Math.abs(delta) < 4) return;
116
+ gesture.moved = true;
117
+ event.preventDefault();
118
+ gesture.velocity = (gesture.lastY - event.clientY) / Math.max(1, event.timeStamp - gesture.time) / gesture.available;
119
+ gesture.lastY = event.clientY;
120
+ gesture.time = event.timeStamp;
121
+ const minimum = points.value[0] * gesture.available;
122
+ const maximum = visiblePoints.value.at(-1) * gesture.available;
123
+ dragHeight.value = Math.max(Math.max(64, minimum), Math.min(maximum, gesture.height + delta));
124
+ }
125
+
126
+ /** Releases capture without selecting a stop, used for cancellation and teardown. */
127
+ function cancelDrag() {
128
+ const previous = gesture;
129
+ gesture = null;
130
+ dragHeight.value = null;
131
+ if (previous && handle.value?.hasPointerCapture?.(previous.id)) handle.value.releasePointerCapture(previous.id);
132
+ }
133
+
134
+ /** Snaps after a completed drag; interrupted gestures return to their previous height. */
135
+ function onPointerUp(event) {
136
+ if (!gesture || event.pointerId !== gesture.id) return;
137
+ const previous = gesture;
138
+ const destination = resolveSnapPoint(points.value, (dragHeight.value ?? previous.height) / previous.available, event.timeStamp - previous.time > 100 ? 0 : previous.velocity);
139
+ if (previous.moved) suppressClickUntil = Date.now() + 400;
140
+ cancelDrag();
141
+ if (previous.moved) snapTo(destination);
142
+ }
143
+
144
+ /** Drops a cancelled gesture without letting a subsequent click resize the sheet. */
145
+ function onPointerCancel(event) {
146
+ if (!gesture || event.pointerId !== gesture.id) return;
147
+ if (gesture.moved) suppressClickUntil = Date.now() + 400;
148
+ cancelDrag();
149
+ }
150
+
151
+ /** Expands editable content before a software keyboard reduces usable space. */
152
+ function onFocusIn(event) {
153
+ if (event.target.matches('input, textarea, [contenteditable="true"], [contenteditable=""]')) expand();
154
+ }
155
+
156
+ /** Keeps the headless drawer in sync without emitting a late close over a new open. */
157
+ function onState(event) {
158
+ if (event.target === drawer.value) emit('update:modelValue', event.detail.open);
159
+ }
160
+
161
+ /** Restores the configured initial stop on each new presentation. */
162
+ function setOpen(value) {
163
+ if (value && !drawer.value?.open) {
164
+ localSnap.value = resolveSnapPoint(visiblePoints.value, props.initialSnapPoint);
165
+ }
166
+ if (!value) cancelDrag();
167
+ if (ready && drawer.value) drawer.value.open = value;
168
+ }
169
+
170
+ /** Upgrades custom elements before teleporting, then installs owned listeners. */
171
+ async function mount() {
172
+ await import('../../lib/headless/drawer.js');
173
+ if (!drawer.value) return;
174
+ const element = drawer.value;
175
+ element.addEventListener('dom:state', onState);
176
+ removeListeners = () => element.removeEventListener('dom:state', onState);
177
+ teleportTarget.value = element.ownerDocument.body;
178
+ await nextTick();
179
+ if (!drawer.value) return;
180
+ ready = true;
181
+ setOpen(props.modelValue);
182
+ }
183
+
184
+ /** Cleans up pointer capture and events before the drawer releases focus and scroll. */
185
+ function unmount() {
186
+ ready = false;
187
+ cancelDrag();
188
+ removeListeners?.();
189
+ }
190
+
191
+ watch(() => props.modelValue, setOpen);
192
+ watch(points, cancelDrag);
193
+ onMounted(mount);
194
+ onBeforeUnmount(unmount);
195
+ defineExpose({ close, expand, snapTo });
196
+ </script>
197
+
198
+ <template>
199
+ <Teleport :to="teleportTarget || 'body'" :disabled="isContained || !teleportTarget">
200
+ <dom-drawer ref="drawer" side="bottom" :static="static || null" :aria-labelledby="title && !$slots.header ? titleId : null" :aria-label="title && !$slots.header ? null : title || label"
201
+ data-dom-bottom-sheet class="inset-0 z-50 block pointer-events-none data-[state=open]:pointer-events-auto" :class="isContained ? 'absolute' : 'fixed'" :style="viewportStyle">
202
+ <div ref="overlay" data-overlay data-no-defaults class="absolute inset-0 pointer-events-none data-[state=open]:pointer-events-auto" hidden>
203
+ <div data-backdrop data-no-defaults class="absolute inset-0 bg-canvas-fg/35 opacity-0 transition-opacity duration-250 data-[state=open]:opacity-100 motion-reduce:transition-none" @pointerdown="cancelDrag"></div>
204
+ <section ref="panel" data-panel data-no-defaults class="absolute inset-x-0 bottom-0 mx-auto flex max-h-[calc(100%-max(0.75rem,var(--dom-safe-area-top,env(safe-area-inset-top,0px))))] max-w-xl flex-col overflow-hidden rounded-t-3xl border border-b-0 border-border bg-canvas text-canvas-fg shadow-2xl outline-none translate-y-full transition-[height,transform] duration-250 ease-out data-[state=open]:translate-y-0 motion-reduce:transition-none" :style="panelStyle" :data-snap-point="currentSnap" @focusin="onFocusIn">
205
+ <span :id="handleDescriptionId" class="sr-only">{{ Math.round(currentSnap * 100) }} percent height. Use the up and down arrow keys to resize.</span>
206
+ <button ref="handle" type="button" class="flex h-11 w-full shrink-0 touch-none cursor-grab items-center justify-center active:cursor-grabbing focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring" :aria-label="`Resize ${title || label}`" :aria-describedby="handleDescriptionId"
207
+ @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerCancel" @lostpointercapture="onPointerCancel" @click="cycle" @keydown="onHandleKey">
208
+ <span class="h-1 w-10 rounded-full bg-muted-fg/40" aria-hidden="true"></span>
209
+ </button>
210
+ <header class="flex shrink-0 items-center gap-3 border-b border-border px-5 pb-3">
211
+ <div class="min-w-0 flex-1"><slot name="header"><h2 :id="titleId" class="truncate text-base font-semibold">{{ title || label }}</h2></slot></div>
212
+ <button type="button" class="grid size-11 shrink-0 place-items-center rounded-full bg-secondary text-muted-fg hover:text-canvas-fg focus-visible:outline-2 focus-visible:outline-ring" :aria-label="`Close ${title || label}`" @click="close"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" stroke-linecap="round" /></svg></button>
213
+ </header>
214
+ <div data-sheet-scroll class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4 [scroll-padding-block:1rem]"><slot :close="close" :expand="expand" /></div>
215
+ <footer v-if="$slots.footer" class="shrink-0 border-t border-border bg-canvas px-4 pt-3 pb-[max(0.75rem,var(--dom-safe-area-bottom,env(safe-area-inset-bottom,0px)))]"><slot name="footer" :close="close" :expand="expand" /></footer>
216
+ <div v-else class="shrink-0 pb-[var(--dom-safe-area-bottom,env(safe-area-inset-bottom,0px))]"></div>
217
+ </section>
218
+ </div>
219
+ </dom-drawer>
220
+ </Teleport>
221
+ </template>
@@ -5,7 +5,7 @@ defineOptions({
5
5
  __doc: {
6
6
  name: 'App list item',
7
7
  tag: '<DomAppListItem>',
8
- description: 'A touch-sized app row for settings, inboxes, menus, and navigation.',
8
+ description: 'A touch-sized row that works in any layout. Use as="div" for rows containing switches, inputs, or other controls so interactive elements are never nested inside a button.',
9
9
  slots: [
10
10
  { name: 'icon', description: 'Leading icon or avatar.' },
11
11
  { name: 'trailing', description: 'Trailing value, switch, badge, or custom action.' },
@@ -66,8 +66,8 @@ const isButton = computed(() => props.as === 'button');
66
66
  <template>
67
67
  <component
68
68
  :is="as"
69
- class="flex min-h-16 w-full items-center gap-3 border-b border-border bg-canvas px-4 py-3 text-left transition last:border-b-0 hover:bg-secondary/60 active:bg-secondary"
70
- :class="[selected && 'bg-secondary', danger && 'text-destructive']"
69
+ class="flex min-h-14 w-full items-center gap-3 border-b border-border bg-canvas px-4 py-3 text-left transition last:border-b-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring disabled:opacity-50 disabled:pointer-events-none"
70
+ :class="[selected && 'bg-secondary', danger && 'text-destructive', as !== 'div' && 'hover:bg-secondary/60 active:bg-secondary']"
71
71
  :type="isButton ? 'button' : undefined"
72
72
  >
73
73
  <div v-if="$slots.icon" class="grid size-10 shrink-0 place-items-center rounded-2xl bg-secondary text-muted-fg">
@@ -75,12 +75,12 @@ const isButton = computed(() => props.as === 'button');
75
75
  </div>
76
76
  <div class="min-w-0 flex-1">
77
77
  <div class="flex min-w-0 items-baseline gap-2">
78
- <p class="truncate text-sm font-medium" :class="danger ? 'text-destructive' : 'text-canvas-fg'">{{ label }}</p>
79
- <p v-if="meta" class="shrink-0 text-xs text-muted-fg">{{ meta }}</p>
78
+ <p class="min-w-0 flex-1 break-words text-sm font-medium" :class="danger ? 'text-destructive' : 'text-canvas-fg'">{{ label }}</p>
79
+ <p v-if="meta" class="max-w-[45%] shrink-0 break-words text-right text-xs text-muted-fg">{{ meta }}</p>
80
80
  </div>
81
81
  <p v-if="description" class="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-fg">{{ description }}</p>
82
82
  </div>
83
- <div v-if="$slots.trailing" class="flex shrink-0 items-center text-muted-fg">
83
+ <div v-if="$slots.trailing" class="flex shrink-0 items-center text-muted-fg [&_dom-toggle]:before:absolute [&_dom-toggle]:before:inset-x-0 [&_dom-toggle]:before:-inset-y-2.5">
84
84
  <slot name="trailing" />
85
85
  </div>
86
86
  <svg v-else-if="chevron" class="size-4 shrink-0 text-muted-fg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@@ -0,0 +1,42 @@
1
+ <script setup>
2
+ import DomAvatar from '../../visual/avatar/DomAvatar.vue';
3
+ import DomButton from '../../components/button/DomButton.vue';
4
+
5
+ defineOptions({
6
+ __doc: {
7
+ name: 'App profile',
8
+ tag: '<DomAppProfile>',
9
+ description: 'An iOS-style account header with a circular profile image, name, email, and an optional action. Place it above settings rows or inside any app stack screen.',
10
+ slots: [{ name: 'actions', description: 'Replace the default profile action with account controls.' }],
11
+ events: [{ name: 'action', payload: '()', description: 'Fired when the profile action is selected. The application owns navigation or editing.' }],
12
+ playground: {
13
+ mobileSlot: 'content',
14
+ initial: {
15
+ name: 'Alex Morgan',
16
+ email: 'alex@example.com',
17
+ src: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&crop=faces&w=192&h=192&q=80',
18
+ },
19
+ },
20
+ },
21
+ });
22
+
23
+ defineProps({
24
+ name: { type: String, required: true, _edit: { description: 'Account holder name; also supplies initials if the image is unavailable.' } },
25
+ email: { type: String, default: '', _edit: { description: 'Optional account email shown below the name.' } },
26
+ src: { type: String, default: '', _edit: { description: 'Profile image URL. Falls back to initials if it cannot load.' } },
27
+ actionLabel: { type: String, default: 'Edit profile', _edit: { description: 'Profile action label. Set to an empty string to hide the default action.' } },
28
+ });
29
+
30
+ defineEmits(['action']);
31
+ </script>
32
+
33
+ <template>
34
+ <section class="flex flex-col items-center px-4 pt-5 pb-2 text-center" :aria-label="`${name} profile`">
35
+ <DomAvatar :name="name" :src="src" size="xl" class="size-20! bg-primary/10! text-2xl! text-primary! ring-0!" />
36
+ <h2 class="mt-4 max-w-full break-words text-2xl font-semibold tracking-tight text-canvas-fg">{{ name }}</h2>
37
+ <p v-if="email" class="mt-1 max-w-full break-words text-sm text-muted-fg">{{ email }}</p>
38
+ <slot name="actions">
39
+ <DomButton v-if="actionLabel" type="button" variant="ghost" class="mt-1 min-h-11 text-primary" @click="$emit('action')">{{ actionLabel }}</DomButton>
40
+ </slot>
41
+ </section>
42
+ </template>
@@ -1,21 +1,21 @@
1
1
  <script setup>
2
2
  const edgeClasses = {
3
- top: 'pt-[env(safe-area-inset-top,0px)]',
4
- bottom: 'pb-[env(safe-area-inset-bottom,0px)]',
5
- left: 'pl-[env(safe-area-inset-left,0px)]',
6
- right: 'pr-[env(safe-area-inset-right,0px)]',
3
+ top: 'pt-[var(--dom-safe-area-top,env(safe-area-inset-top,0px))]',
4
+ bottom: 'pb-[var(--dom-safe-area-bottom,env(safe-area-inset-bottom,0px))]',
5
+ left: 'pl-[var(--dom-safe-area-left,env(safe-area-inset-left,0px))]',
6
+ right: 'pr-[var(--dom-safe-area-right,env(safe-area-inset-right,0px))]',
7
7
  };
8
8
 
9
9
  defineOptions({
10
10
  __doc: {
11
11
  name: 'Safe area',
12
12
  tag: '<DomSafeArea>',
13
- description: 'Applies safe-area inset padding around app chrome for devices with cutouts, home indicators, or inset system UI.',
13
+ description: 'Applies selected device safe-area insets in any layout. App shell and app stack already reserve their own insets, so no extra wrapper is needed there. Enable viewport-fit=cover in the app viewport meta tag for edge-to-edge iOS layouts.',
14
14
  slots: [
15
15
  { name: '(default)', description: 'Content that should avoid device cutouts and home indicators.' },
16
16
  ],
17
17
  playground: {
18
- mobileSlot: 'content',
18
+ mobileSlot: 'safe-area',
19
19
  },
20
20
  },
21
21
  });
@@ -1,22 +1,56 @@
1
1
  <script setup>
2
+ import { computed, provide, ref, watch } from 'vue';
3
+ import { useKeyboardViewport } from '../_shared/useKeyboardViewport.js';
4
+
5
+ const shell = ref(null);
6
+ provide('dom-app-shell', shell);
7
+
2
8
  defineOptions({
3
9
  __doc: {
4
10
  name: 'App shell',
5
11
  tag: '<DomAppShell>',
6
- description: 'A web app surface with safe-area padding, scrollable content, and fixed app chrome slots for mobile or desktop app layouts.',
12
+ description: 'A bounded app viewport with safe areas, pinned top and bottom slots, and one scrolling content region. Fills the viewport by default; use height="100%" only inside an explicitly sized parent. Set content-scroll to false when a nested DomAppStack owns scrolling.',
7
13
  slots: [
8
14
  { name: 'top', description: 'Top navigation, status, or toolbar content.' },
9
15
  { name: '(default)', description: 'Scrollable app content.' },
10
- { name: 'bottom', description: 'Bottom navigation, tab bar, or persistent controls.' },
16
+ { name: 'bottom', payload: '{ keyboardOpen }', description: 'Bottom navigation or persistent controls. Receives the detected keyboard state.' },
11
17
  { name: 'overlay', description: 'Absolute overlay layer for drawers, action sheets, and app-level panels.' },
12
18
  ],
19
+ events: [{ name: 'keyboard-change', payload: '{ open, height }', description: 'Estimated keyboard state or the app-supplied native keyboard inset.' }],
13
20
  playground: {
14
21
  mobileSlot: 'shell',
22
+ initial: { height: '100%' },
15
23
  },
16
24
  },
17
25
  });
18
26
 
19
- defineProps({
27
+ const props = defineProps({
28
+ keyboardBehavior: {
29
+ type: String,
30
+ default: 'resize',
31
+ validator: (value) => ['resize', 'none'].includes(value),
32
+ _edit: { options: ['resize', 'none'], description: 'Resize to the visible viewport while editing. Use none when the native host owns keyboard layout.' },
33
+ },
34
+ keyboardInset: {
35
+ type: Number,
36
+ default: null,
37
+ _edit: { typeRef: 'number | null', description: 'Optional native keyboard height in CSS pixels. Leave null to detect viewport changes; zero explicitly means closed.' },
38
+ },
39
+ hideBottomOnKeyboard: {
40
+ type: Boolean,
41
+ default: false,
42
+ _edit: { description: 'Hide bottom navigation while the keyboard is visible. Leave false for a persistent reply composer.' },
43
+ },
44
+ height: {
45
+ type: String,
46
+ default: '100dvh',
47
+ _edit: { description: 'App height as a CSS length. Use 100dvh standalone, 36rem for an embedded app, or 100% inside a sized parent.' },
48
+ },
49
+ contentScroll: {
50
+ type: Boolean,
51
+ default: true,
52
+ _edit: { description: 'Let the main region scroll. Disable when a nested stack or split-pane layout owns scrolling.' },
53
+ },
20
54
  variant: {
21
55
  type: String,
22
56
  default: 'app',
@@ -26,23 +60,40 @@ defineProps({
26
60
  },
27
61
  },
28
62
  });
63
+ const emit = defineEmits(['keyboard-change']);
64
+ const keyboard = useKeyboardViewport(shell, () => props.keyboardBehavior === 'resize', () => props.keyboardInset);
65
+ const keyboardOpen = keyboard.open;
66
+ const shellStyle = computed(() => ({
67
+ height: props.height,
68
+ maxHeight: keyboardOpen.value ? `${keyboard.available.value}px` : undefined,
69
+ '--dom-keyboard-height': `${keyboard.height.value}px`,
70
+ '--dom-safe-area-bottom': keyboardOpen.value ? '0px' : undefined,
71
+ }));
72
+
73
+ /** Reports geometry changes so the app can coordinate native controls. */
74
+ function reportKeyboard() {
75
+ emit('keyboard-change', { open: keyboardOpen.value, height: keyboard.height.value });
76
+ }
77
+ watch([keyboard.open, keyboard.height], reportKeyboard);
29
78
  </script>
30
79
 
31
80
  <template>
32
81
  <div
82
+ ref="shell"
33
83
  data-dom-app-shell
34
- class="relative isolate flex h-full min-h-0 w-full flex-col overflow-hidden overscroll-none bg-canvas text-canvas-fg"
84
+ :data-keyboard-open="keyboardOpen"
85
+ class="[&_input]:text-base [&_textarea]:text-base [&_select]:text-base relative isolate flex min-h-0 min-w-0 w-full flex-col overflow-hidden overscroll-none 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))]"
35
86
  :class="variant === 'card' && 'skin-card rounded-[2rem]'"
36
- style="--dom-mobile-safe-top: env(safe-area-inset-top, 0px); --dom-mobile-safe-bottom: env(safe-area-inset-bottom, 0px);"
87
+ :style="shellStyle"
37
88
  >
38
- <div v-if="$slots.top" class="shrink-0 pt-[var(--dom-mobile-safe-top)]">
89
+ <div v-if="$slots.top" class="shrink-0 [--dom-safe-area-top:0px] [--dom-safe-area-bottom:0px] [--dom-safe-area-left:0px] [--dom-safe-area-right:0px]">
39
90
  <slot name="top" />
40
91
  </div>
41
- <main data-dom-app-shell-main class="min-h-0 flex-1 overflow-y-auto overscroll-contain scroll-smooth [-webkit-overflow-scrolling:touch]">
92
+ <main data-dom-app-shell-main class="min-h-0 min-w-0 flex-1 overscroll-contain [--dom-safe-area-top:0px] [--dom-safe-area-bottom:0px] [--dom-safe-area-left:0px] [--dom-safe-area-right:0px]" :class="contentScroll ? 'overflow-y-auto' : 'flex flex-col overflow-hidden'">
42
93
  <slot />
43
94
  </main>
44
- <div v-if="$slots.bottom" class="shrink-0 pb-[var(--dom-mobile-safe-bottom)]">
45
- <slot name="bottom" />
95
+ <div v-if="$slots.bottom" v-show="!hideBottomOnKeyboard || !keyboardOpen" class="shrink-0 [--dom-safe-area-top:0px] [--dom-safe-area-bottom:0px] [--dom-safe-area-left:0px] [--dom-safe-area-right:0px]">
96
+ <slot name="bottom" :keyboard-open="keyboardOpen" />
46
97
  </div>
47
98
  <div v-if="$slots.overlay" data-dom-app-shell-overlay class="pointer-events-none absolute inset-0 z-20">
48
99
  <slot name="overlay" />