@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
@@ -75,6 +75,7 @@ export const popoverProps = {
75
75
  </script>
76
76
 
77
77
  <script setup>
78
+ import './popover.css';
78
79
  import { computed, onMounted, ref, useSlots } from 'vue';
79
80
 
80
81
  defineOptions({
@@ -189,56 +190,3 @@ onMounted(async () => {
189
190
  </div>
190
191
  </dom-popover>
191
192
  </template>
192
-
193
- <style>
194
- .dom-popover-arrow {
195
- position: absolute;
196
- z-index: 0;
197
- display: block;
198
- width: 16px;
199
- height: 8px;
200
- overflow: visible;
201
- fill: var(--skin-bg, var(--canvas));
202
- stroke: var(--skin-border, var(--border));
203
- stroke-width: 1;
204
- stroke-linejoin: round;
205
- pointer-events: none;
206
- }
207
-
208
- .dom-popover-arrow-fill {
209
- fill: var(--skin-bg, var(--canvas));
210
- stroke: none;
211
- }
212
-
213
- .dom-popover-arrow-stroke {
214
- fill: none;
215
- stroke: var(--skin-border, var(--border));
216
- stroke-width: 1;
217
- stroke-linecap: round;
218
- stroke-linejoin: round;
219
- }
220
-
221
- .dom-floating-transition[data-side="top"] > .dom-popover-card > .dom-popover-arrow {
222
- bottom: -8px;
223
- left: var(--dom-popover-arrow-x);
224
- transform: translateX(-50%) rotate(180deg);
225
- }
226
-
227
- .dom-floating-transition[data-side="bottom"] > .dom-popover-card > .dom-popover-arrow {
228
- top: -8px;
229
- left: var(--dom-popover-arrow-x);
230
- transform: translateX(-50%);
231
- }
232
-
233
- .dom-floating-transition[data-side="left"] > .dom-popover-card > .dom-popover-arrow {
234
- right: -12px;
235
- top: var(--dom-popover-arrow-y);
236
- transform: translateY(-50%) rotate(90deg);
237
- }
238
-
239
- .dom-floating-transition[data-side="right"] > .dom-popover-card > .dom-popover-arrow {
240
- left: -12px;
241
- top: var(--dom-popover-arrow-y);
242
- transform: translateY(-50%) rotate(-90deg);
243
- }
244
- </style>
@@ -0,0 +1,50 @@
1
+ .dom-popover-arrow {
2
+ position: absolute;
3
+ z-index: 0;
4
+ display: block;
5
+ width: 16px;
6
+ height: 8px;
7
+ overflow: visible;
8
+ fill: var(--skin-bg, var(--canvas));
9
+ stroke: var(--skin-border, var(--border));
10
+ stroke-width: 1;
11
+ stroke-linejoin: round;
12
+ pointer-events: none;
13
+ }
14
+
15
+ .dom-popover-arrow-fill {
16
+ fill: var(--skin-bg, var(--canvas));
17
+ stroke: none;
18
+ }
19
+
20
+ .dom-popover-arrow-stroke {
21
+ fill: none;
22
+ stroke: var(--skin-border, var(--border));
23
+ stroke-width: 1;
24
+ stroke-linecap: round;
25
+ stroke-linejoin: round;
26
+ }
27
+
28
+ .dom-floating-transition[data-side="top"] > .dom-popover-card > .dom-popover-arrow {
29
+ bottom: -8px;
30
+ left: var(--dom-popover-arrow-x);
31
+ transform: translateX(-50%) rotate(180deg);
32
+ }
33
+
34
+ .dom-floating-transition[data-side="bottom"] > .dom-popover-card > .dom-popover-arrow {
35
+ top: -8px;
36
+ left: var(--dom-popover-arrow-x);
37
+ transform: translateX(-50%);
38
+ }
39
+
40
+ .dom-floating-transition[data-side="left"] > .dom-popover-card > .dom-popover-arrow {
41
+ right: -12px;
42
+ top: var(--dom-popover-arrow-y);
43
+ transform: translateY(-50%) rotate(90deg);
44
+ }
45
+
46
+ .dom-floating-transition[data-side="right"] > .dom-popover-card > .dom-popover-arrow {
47
+ left: -12px;
48
+ top: var(--dom-popover-arrow-y);
49
+ transform: translateY(-50%) rotate(-90deg);
50
+ }
@@ -0,0 +1,551 @@
1
+ <script setup>
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue';
3
+ import DomIconButton from '../icon-button/DomIconButton.vue';
4
+ import DomRangeInput from '../../forms/range-input/DomRangeInput.vue';
5
+
6
+ defineOptions({
7
+ __doc: {
8
+ name: 'Video feed',
9
+ tag: '<DomVideoFeed>',
10
+ description: 'A vertical video feed with touch scrolling, snap navigation, and one active player. Built for desktop and mobile.',
11
+ icon: 'M8 3h8v18H8zM11 9l4 3-4 3M3 9l2-2 2 2M3 15l2 2 2-2',
12
+ slots: [
13
+ { name: 'caption', payload: '{ item, index }', description: 'Replaces the title, creator, and description overlay. Include media attribution here when required.' },
14
+ { name: 'actions', payload: '{ item, index }', description: 'Application-owned actions beside the caption, such as save or like.' },
15
+ { name: 'empty', description: 'Content shown when the feed has no items.' },
16
+ ],
17
+ events: [
18
+ { name: 'update:index', payload: '(index: number)', description: 'The active item changed through scrolling, keyboard, or navigation.' },
19
+ { name: 'update:muted', payload: '(muted: boolean)', description: 'The viewer changed the sound preference.' },
20
+ { name: 'change', payload: '({ item, index })', description: 'Active item changed; use this to request another page near the end.' },
21
+ { name: 'error', payload: '({ item, index, error })', description: 'A video failed to load or play. Autoplay policy blocks show a play button instead.' },
22
+ ],
23
+ keyboard: [
24
+ { keys: '↑ / ↓', action: 'Previous or next video when the feed is focused.' },
25
+ { keys: 'Home / End', action: 'First or last video.' },
26
+ { keys: 'Space / K', action: 'Play or pause.' },
27
+ { keys: 'M', action: 'Toggle sound.' },
28
+ { keys: 'Tab', action: 'Move through playback, seek, caption, and application controls.' },
29
+ ],
30
+ },
31
+ });
32
+
33
+ const props = defineProps({
34
+ items: {
35
+ type: Array,
36
+ /** Returns a fresh collection for each feed instance. */
37
+ default: () => [],
38
+ _edit: { description: 'Video records with a unique id, src, and title. Optional poster, creator, description, objectPosition, and WebVTT tracks.' },
39
+ },
40
+ index: { type: Number, default: 0, _edit: { description: 'Active item index; supports v-model:index.' } },
41
+ height: { type: String, default: '100dvh', _edit: { description: 'Feed height. Use a CSS length, or 100% inside an explicitly sized parent.' } },
42
+ muted: { type: Boolean, default: true, _edit: { description: 'Initial sound preference; supports v-model:muted. Muted playback is most compatible with autoplay.' } },
43
+ autoplay: { type: Boolean, default: true, _edit: { description: 'Play the visible item automatically, except when reduced motion or data saving is requested.' } },
44
+ loop: { type: Boolean, default: true, _edit: { description: 'Loop the active video. Does not wrap feed navigation.' } },
45
+ fit: { type: String, default: 'cover', _edit: { options: ['cover', 'contain'], description: 'Fill the portrait frame or preserve the complete source image.' } },
46
+ label: { type: String, default: 'Video feed', _edit: { description: 'Accessible name of the feed.' } },
47
+ });
48
+ const emit = defineEmits(['update:index', 'update:muted', 'change', 'error']);
49
+ const viewport = ref(null);
50
+ const activeIndex = ref(clampIndex(props.index));
51
+ const visitedSources = ref(new Map());
52
+ const soundMuted = ref(props.muted);
53
+ const playing = ref(false);
54
+ const waiting = ref(false);
55
+ const failed = ref(false);
56
+ const blocked = ref(false);
57
+ const currentTime = ref(0);
58
+ const duration = ref(0);
59
+ const scrubbing = ref(false);
60
+ const captions = ref(true);
61
+ const visible = ref(false);
62
+ const reducedMotion = ref(false);
63
+ const saveData = ref(false);
64
+ const instructionsId = useId();
65
+ let userPaused = false;
66
+ let explicitPlayback = false;
67
+ let mounted = false;
68
+ let scrollFrame = 0;
69
+ let progressFrame = 0;
70
+ let playRun = 0;
71
+ let intersectionObserver;
72
+ let resizeObserver;
73
+ let motionQuery;
74
+ let ownerDocument;
75
+ let ownerWindow;
76
+
77
+ /** Returns the currently selected application-owned record. */
78
+ const activeItem = computed(() => props.items[activeIndex.value]);
79
+ /** Limits progress to the current media duration, including while loading. */
80
+ const progress = computed(() => duration.value ? Math.min(100, currentTime.value / duration.value * 100) : 0);
81
+ /** Makes media dimensions available to the portrait frame without parent layout tricks. */
82
+ const frameStyle = computed(() => ({ height: props.height }));
83
+
84
+ rememberActiveVideo();
85
+
86
+ /** Clamps external indices to a finite item position, including an empty feed. */
87
+ function clampIndex(index) {
88
+ return Math.max(0, Math.min(props.items.length - 1, Number.isFinite(index) ? Math.trunc(index) : 0));
89
+ }
90
+
91
+ /** Keeps a visited player mounted so its paused frame and playback position survive navigation. */
92
+ function rememberActiveVideo() {
93
+ const item = props.items[activeIndex.value];
94
+ if (item) visitedSources.value.set(item.id, item.src);
95
+ }
96
+
97
+ /** Lazily mounts neighbouring sources while retaining every previously active player. */
98
+ function shouldMountVideo(item, index) {
99
+ return Math.abs(index - activeIndex.value) <= 1 || visitedSources.value.get(item.id) === item.src;
100
+ }
101
+
102
+ /** Returns the active, neighbouring, and retained paused media elements. */
103
+ function videos() {
104
+ return Array.from(viewport.value?.querySelectorAll('video') || []);
105
+ }
106
+
107
+ /** Resolves the active player independently of Vue's keyed list ref ordering. */
108
+ function activeVideo() {
109
+ return videos().find(isActiveVideo);
110
+ }
111
+
112
+ /** Tests whether a media element belongs to the active slide. */
113
+ function isActiveVideo(video) {
114
+ return Number(video.dataset.index) === activeIndex.value;
115
+ }
116
+
117
+ /** Determines whether playback is allowed by visibility and the viewer's intent. */
118
+ function shouldPlay() {
119
+ return mounted && visible.value && !ownerDocument.hidden && !userPaused && !failed.value
120
+ && (explicitPlayback || (props.autoplay && !reducedMotion.value && !saveData.value));
121
+ }
122
+
123
+ /**
124
+ * Pauses inactive players and safely requests playback on the visible one.
125
+ * A generation counter prevents a late play promise from reviving a stale slide.
126
+ */
127
+ async function syncPlayback() {
128
+ const run = ++playRun;
129
+ const video = activeVideo();
130
+ if (!shouldPlay()) stopProgress();
131
+ for (const candidate of videos()) {
132
+ candidate.muted = soundMuted.value;
133
+ if (candidate !== video || !shouldPlay()) candidate.pause();
134
+ }
135
+ if (!video || !shouldPlay()) return;
136
+ try {
137
+ await video.play();
138
+ if (run !== playRun && (!isActiveVideo(video) || !shouldPlay())) video.pause();
139
+ } catch (error) {
140
+ if (run !== playRun || error.name === 'AbortError') return;
141
+ waiting.value = false;
142
+ if (error.name === 'NotAllowedError') blocked.value = true;
143
+ else handleMediaError({ target: video });
144
+ }
145
+ }
146
+
147
+ /** Updates active state and preserves focus when a previously focused slide becomes inert. */
148
+ async function selectIndex(index) {
149
+ const next = clampIndex(index);
150
+ if (next === activeIndex.value) return;
151
+ // Stop the outgoing player before Vue updates the active slide or mounts its neighbour.
152
+ playRun++;
153
+ stopProgress();
154
+ scrubbing.value = false;
155
+ for (const video of videos()) video.pause();
156
+ const focusedSlide = ownerDocument?.activeElement?.closest('[data-video-slide]');
157
+ if (focusedSlide && viewport.value?.contains(focusedSlide)) viewport.value.focus({ preventScroll: true });
158
+ activeIndex.value = next;
159
+ rememberActiveVideo();
160
+ userPaused = false;
161
+ explicitPlayback = false;
162
+ playing.value = false;
163
+ waiting.value = false;
164
+ failed.value = false;
165
+ blocked.value = false;
166
+ currentTime.value = 0;
167
+ duration.value = 0;
168
+ emit('update:index', next);
169
+ emit('change', { item: props.items[next], index: next });
170
+ await nextTick();
171
+ readMediaState();
172
+ applyCaptions();
173
+ syncPlayback();
174
+ }
175
+
176
+ /** Scrolls to an item; native scroll events commit the visible slide as it arrives. */
177
+ function goTo(index, smooth = true) {
178
+ const next = clampIndex(index);
179
+ if (!viewport.value) return;
180
+ viewport.value.scrollTo({ top: next * viewport.value.clientHeight, behavior: smooth && !reducedMotion.value ? 'smooth' : 'instant' });
181
+ if (!smooth || reducedMotion.value) selectIndex(next);
182
+ }
183
+
184
+ /** Moves to the previous item without wrapping at the first item. */
185
+ function previous() { goTo(activeIndex.value - 1); }
186
+ /** Moves to the next item without wrapping at the last item. */
187
+ function next() { goTo(activeIndex.value + 1); }
188
+
189
+ /** Samples native scroll position once per animation frame for touch and wheel input. */
190
+ function handleScroll() {
191
+ if (!scrollFrame) scrollFrame = ownerWindow.requestAnimationFrame(commitScroll);
192
+ }
193
+
194
+ /** Commits the nearest snap point without taking over native scrolling. */
195
+ function commitScroll() {
196
+ scrollFrame = 0;
197
+ if (viewport.value?.clientHeight) selectIndex(Math.round(viewport.value.scrollTop / viewport.value.clientHeight));
198
+ }
199
+
200
+ /** Applies an explicit play or pause request, including autoplay-policy recovery. */
201
+ function togglePlayback() {
202
+ userPaused = playing.value;
203
+ explicitPlayback = !userPaused;
204
+ blocked.value = false;
205
+ syncPlayback();
206
+ }
207
+
208
+ /** Keeps the viewer's sound preference consistent while moving between items. */
209
+ function toggleSound() {
210
+ soundMuted.value = !soundMuted.value;
211
+ emit('update:muted', soundMuted.value);
212
+ syncPlayback();
213
+ }
214
+
215
+ /** Reads progress only from the active player, including cached metadata on revisits. */
216
+ function readMediaState() {
217
+ const video = activeVideo();
218
+ if (!scrubbing.value) currentTime.value = video?.currentTime || 0;
219
+ duration.value = Number.isFinite(video?.duration) ? video.duration : 0;
220
+ }
221
+
222
+ /** Samples the media clock at display cadence without interpolating through buffering or seeks. */
223
+ function sampleProgress() {
224
+ progressFrame = 0;
225
+ readMediaState();
226
+ syncProgress();
227
+ }
228
+
229
+ /** Runs one animation loop only while the active video is playing and the viewer is not scrubbing. */
230
+ function syncProgress() {
231
+ if (!playing.value || waiting.value || scrubbing.value || !shouldPlay()) {
232
+ stopProgress();
233
+ return;
234
+ }
235
+ if (!progressFrame) progressFrame = ownerWindow.requestAnimationFrame(sampleProgress);
236
+ }
237
+
238
+ /** Cancels pending progress work on pause, buffering, navigation, or teardown. */
239
+ function stopProgress() {
240
+ if (progressFrame) ownerWindow.cancelAnimationFrame(progressFrame);
241
+ progressFrame = 0;
242
+ }
243
+
244
+ /** Synchronizes media events and rejects late play events from hidden or inactive players. */
245
+ function handleMediaEvent(event) {
246
+ const video = event.target;
247
+ if (event.type === 'play' && (!isActiveVideo(video) || !shouldPlay())) video.pause();
248
+ if (!isActiveVideo(video)) return;
249
+ playing.value = !video.paused && !video.ended;
250
+ if (event.type === 'waiting') waiting.value = true;
251
+ if (['playing', 'pause', 'ended', 'canplay'].includes(event.type)) waiting.value = false;
252
+ if (event.type === 'ended' && !props.loop) userPaused = true;
253
+ readMediaState();
254
+ syncProgress();
255
+ if (event.type === 'loadedmetadata') applyCaptions();
256
+ }
257
+
258
+ /** Shows a recoverable error for a failed active media resource. */
259
+ function handleMediaError(event) {
260
+ if (!isActiveVideo(event.target)) return;
261
+ failed.value = true;
262
+ waiting.value = false;
263
+ playing.value = false;
264
+ stopProgress();
265
+ emit('error', { item: activeItem.value, index: activeIndex.value, error: event.target.error });
266
+ }
267
+
268
+ /** Retries the active source after a network or decoder failure. */
269
+ function retry() {
270
+ failed.value = false;
271
+ blocked.value = false;
272
+ userPaused = false;
273
+ explicitPlayback = true;
274
+ activeVideo()?.load();
275
+ syncPlayback();
276
+ }
277
+
278
+ /** Keeps pointer scrubbing authoritative until release so media updates cannot pull the handle away. */
279
+ function handleScrubbing(dragging) {
280
+ scrubbing.value = dragging;
281
+ if (!dragging) readMediaState();
282
+ syncProgress();
283
+ }
284
+
285
+ /** Seeks from the DOM Studio range's numeric value while preserving playback intent. */
286
+ function seek(value) {
287
+ const video = activeVideo();
288
+ if (!video || !duration.value || failed.value) return;
289
+ const time = Math.max(0, Math.min(100, Number(value))) / 100 * duration.value;
290
+ if (!Number.isFinite(time)) return;
291
+ currentTime.value = time;
292
+ video.currentTime = time;
293
+ }
294
+
295
+ /** Formats a finite duration as minutes and seconds for the seek control. */
296
+ function formatTime(seconds) {
297
+ const value = Math.max(0, Math.floor(seconds || 0));
298
+ return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')}`;
299
+ }
300
+
301
+ /** Enables the designated default caption track, or the first supplied track. */
302
+ function applyCaptions() {
303
+ const video = activeVideo();
304
+ if (!video) return;
305
+ const preferred = Math.max(0, (activeItem.value?.tracks || []).findIndex(isDefaultTrack));
306
+ for (let index = 0; index < video.textTracks.length; index++) {
307
+ const track = video.textTracks[index];
308
+ track.mode = captions.value && index === preferred ? 'showing' : 'disabled';
309
+ for (const cue of track.cues || []) {
310
+ if (cue.line !== 'auto') continue;
311
+ cue.snapToLines = false;
312
+ cue.line = 55;
313
+ }
314
+ }
315
+ }
316
+
317
+ /** Identifies the application's preferred WebVTT caption track. */
318
+ function isDefaultTrack(track) { return track.default; }
319
+ /** Toggles text captions without affecting the shared sound preference. */
320
+ function toggleCaptions() {
321
+ captions.value = !captions.value;
322
+ applyCaptions();
323
+ }
324
+
325
+ /** Handles feed shortcuts while leaving links, buttons, and form fields in control of their own keys. */
326
+ function handleKeydown(event) {
327
+ if (event.altKey || event.ctrlKey || event.metaKey || event.target.closest('button, a, input, textarea, select, [contenteditable="true"]')) return;
328
+ if (!props.items.length) return;
329
+ switch (event.key.toLowerCase()) {
330
+ case 'arrowup': case 'pageup': previous(); break;
331
+ case 'arrowdown': case 'pagedown': next(); break;
332
+ case 'home': goTo(0); break;
333
+ case 'end': goTo(props.items.length - 1); break;
334
+ case ' ': case 'k': togglePlayback(); break;
335
+ case 'm': toggleSound(); break;
336
+ default: return;
337
+ }
338
+ event.preventDefault();
339
+ }
340
+
341
+ /** Prevents background-tab playback and resumes only when prior intent allows it. */
342
+ function handleVisibility() { syncPlayback(); }
343
+ /** Tracks platform motion preferences and stops automatic playback when they change. */
344
+ function handleMotion() {
345
+ reducedMotion.value = motionQuery.matches;
346
+ syncPlayback();
347
+ }
348
+ /** Preserves the selected snap point when resizing or rotating the device. */
349
+ function handleResize() { goTo(activeIndex.value, false); }
350
+ /** Watches actual feed intersection, including clipped or offscreen documentation examples. */
351
+ function handleIntersection(entries) {
352
+ visible.value = entries[0].isIntersecting && entries[0].intersectionRatio >= 0.5;
353
+ syncPlayback();
354
+ }
355
+
356
+ /**
357
+ * Applies application navigation without treating a v-model acknowledgement as
358
+ * a new scroll command. Echoed indices must not interrupt native snap momentum.
359
+ */
360
+ function handleExternalIndex(index) {
361
+ if (clampIndex(index) !== activeIndex.value) goTo(index, false);
362
+ }
363
+ /** Applies controlled sound changes without emitting them back to the application. */
364
+ function handleExternalMuted(muted) {
365
+ soundMuted.value = muted;
366
+ syncPlayback();
367
+ }
368
+ /** Returns the source identity list to detect replacements as well as pagination. */
369
+ function itemSources() { return props.items.map(itemIdentity); }
370
+ /** Provides a stable source signature for keyed media updates. */
371
+ function itemIdentity(item) { return `${item.id}:${item.src}`; }
372
+ /** Reconciles empty, appended, reordered, and replaced media lists. */
373
+ async function handleItemsChange() {
374
+ failed.value = false;
375
+ blocked.value = false;
376
+ const currentSources = new Map(props.items.map(itemSourceEntry));
377
+ for (const [id, src] of visitedSources.value) {
378
+ if (currentSources.get(id) !== src) visitedSources.value.delete(id);
379
+ }
380
+ rememberActiveVideo();
381
+ await nextTick();
382
+ goTo(activeIndex.value, false);
383
+ readMediaState();
384
+ applyCaptions();
385
+ syncPlayback();
386
+ }
387
+
388
+ /** Pairs a stable item ID with its current source when pruning retained players. */
389
+ function itemSourceEntry(item) { return [item.id, item.src]; }
390
+
391
+ /** Reads the controlled index for the prop watcher. */
392
+ function getIndex() { return props.index; }
393
+ /** Reads the controlled sound setting for the prop watcher. */
394
+ function getMuted() { return props.muted; }
395
+ /** Reads the autoplay preference for the prop watcher. */
396
+ function getAutoplay() { return props.autoplay; }
397
+
398
+ watch(getIndex, handleExternalIndex);
399
+ watch(getMuted, handleExternalMuted);
400
+ watch(getAutoplay, syncPlayback);
401
+ watch(itemSources, handleItemsChange);
402
+
403
+ /** Installs browser observers only after mount, keeping server rendering deterministic. */
404
+ onMounted(function mountFeed() {
405
+ mounted = true;
406
+ ownerDocument = viewport.value.ownerDocument;
407
+ ownerWindow = ownerDocument.defaultView;
408
+ motionQuery = ownerWindow.matchMedia('(prefers-reduced-motion: reduce)');
409
+ reducedMotion.value = motionQuery.matches;
410
+ saveData.value = Boolean(ownerWindow.navigator.connection?.saveData);
411
+ motionQuery.addEventListener('change', handleMotion);
412
+ ownerDocument.addEventListener('visibilitychange', handleVisibility);
413
+ intersectionObserver = new ownerWindow.IntersectionObserver(handleIntersection, { threshold: [0, 0.5] });
414
+ intersectionObserver.observe(viewport.value);
415
+ resizeObserver = new ownerWindow.ResizeObserver(handleResize);
416
+ resizeObserver.observe(viewport.value);
417
+ goTo(activeIndex.value, false);
418
+ });
419
+
420
+ /** Releases observers, queued frames, and all playback when the feed is removed. */
421
+ onBeforeUnmount(function unmountFeed() {
422
+ mounted = false;
423
+ stopProgress();
424
+ playRun++;
425
+ for (const video of videos()) video.pause();
426
+ intersectionObserver?.disconnect();
427
+ resizeObserver?.disconnect();
428
+ motionQuery?.removeEventListener('change', handleMotion);
429
+ ownerDocument?.removeEventListener('visibilitychange', handleVisibility);
430
+ if (scrollFrame) ownerWindow.cancelAnimationFrame(scrollFrame);
431
+ });
432
+
433
+ defineExpose({ goTo, next, previous });
434
+ </script>
435
+
436
+ <template>
437
+ <div class="relative flex w-full min-w-0 justify-center gap-4 overflow-hidden rounded-2xl bg-secondary/50 [container-type:size]" :style="frameStyle" @keydown="handleKeydown">
438
+ <p :id="instructionsId" class="sr-only">Swipe or scroll vertically for another video. When focused, use up and down arrows to navigate, space to play or pause, and M for sound.</p>
439
+ <div
440
+ ref="viewport"
441
+ class="relative h-full min-w-0 w-full max-w-[56.25cqh] snap-y snap-mandatory overflow-x-hidden overflow-y-auto rounded-2xl bg-(--video-canvas) text-(--video-fg) [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
442
+ role="region"
443
+ aria-roledescription="carousel"
444
+ :aria-label="label"
445
+ :aria-describedby="instructionsId"
446
+ tabindex="0"
447
+ @scroll.passive="handleScroll"
448
+ >
449
+ <div v-if="!items.length" class="grid h-full place-items-center p-8 text-center">
450
+ <slot name="empty"><p class="text-sm">No videos yet.</p></slot>
451
+ </div>
452
+ <article
453
+ v-for="(item, itemIndex) in items" :key="item.id"
454
+ class="relative isolate h-full w-full snap-start snap-always overflow-hidden"
455
+ data-video-slide
456
+ :inert="itemIndex !== activeIndex"
457
+ :aria-hidden="itemIndex !== activeIndex"
458
+ :aria-label="`${itemIndex + 1} of ${items.length}: ${item.title || 'Video'}`"
459
+ aria-roledescription="slide"
460
+ >
461
+ <img v-if="item.poster" :src="item.poster" alt="" class="absolute inset-0 h-full w-full" :class="fit === 'contain' ? 'object-contain' : 'object-cover'" :style="{ objectPosition: item.objectPosition }" :loading="itemIndex === activeIndex ? 'eager' : 'lazy'" />
462
+ <video
463
+ v-if="shouldMountVideo(item, itemIndex)"
464
+ :key="item.src"
465
+ :data-index="itemIndex"
466
+ :src="item.src"
467
+ :poster="item.poster"
468
+ :muted="soundMuted"
469
+ :loop="loop"
470
+ :preload="itemIndex === activeIndex ? 'auto' : 'metadata'"
471
+ :crossorigin="item.tracks?.length ? 'anonymous' : undefined"
472
+ playsinline
473
+ :aria-label="item.title || 'Video'"
474
+ class="absolute inset-0 h-full w-full"
475
+ :class="fit === 'contain' ? 'object-contain' : 'object-cover'"
476
+ :style="{ objectPosition: item.objectPosition }"
477
+ @play="handleMediaEvent" @playing="handleMediaEvent" @pause="handleMediaEvent" @ended="handleMediaEvent"
478
+ @waiting="handleMediaEvent" @canplay="handleMediaEvent" @loadedmetadata="handleMediaEvent" @timeupdate="handleMediaEvent" @error="handleMediaError"
479
+ @seeking="handleMediaEvent" @seeked="handleMediaEvent" @durationchange="handleMediaEvent"
480
+ >
481
+ <track v-for="track in item.tracks || []" :key="track.src" kind="captions" :src="track.src" :srclang="track.srclang" :label="track.label" :default="track.default" @load="applyCaptions" />
482
+ </video>
483
+ <button type="button" tabindex="-1" aria-hidden="true" class="absolute inset-0 z-10 cursor-pointer" @click="togglePlayback"></button>
484
+ <div class="pointer-events-none absolute inset-x-0 top-0 z-10 h-32 bg-linear-to-b from-(--video-canvas)/70 to-transparent"></div>
485
+ <div class="absolute inset-x-0 top-0 z-20 flex items-center justify-between gap-2 p-3 pt-[max(0.75rem,env(safe-area-inset-top))]">
486
+ <span class="rounded-full bg-(--video-canvas)/50 px-3 py-1.5 text-xs font-semibold tabular-nums backdrop-blur">{{ itemIndex + 1 }} / {{ items.length }}</span>
487
+ <div class="flex gap-1">
488
+ <DomIconButton :label="itemIndex === activeIndex && playing ? 'Pause video' : 'Play video'" :icon="itemIndex === activeIndex && playing ? 'M9 5v14M15 5v14' : 'M8 5l11 7-11 7V5Z'" size="lg" class="!bg-(--video-canvas)/50 !text-(--video-fg) backdrop-blur" @click="togglePlayback" />
489
+ <DomIconButton :label="soundMuted ? 'Unmute video' : 'Mute video'" :icon="soundMuted ? 'M11 5L6 9H3v6h3l5 4V5ZM17 9l5 6M22 9l-5 6' : 'M11 5L6 9H3v6h3l5 4V5ZM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14'" size="lg" class="!bg-(--video-canvas)/50 !text-(--video-fg) backdrop-blur" @click="toggleSound" />
490
+ </div>
491
+ </div>
492
+ <div v-if="itemIndex === activeIndex && (failed || blocked || !playing || waiting)" class="pointer-events-none absolute inset-0 z-10 grid place-items-center p-6">
493
+ <div v-if="failed" role="status" class="pointer-events-auto rounded-2xl bg-(--video-canvas)/90 p-5 text-center">
494
+ <p class="text-sm font-semibold">This video couldn’t load.</p>
495
+ <button type="button" class="mt-3 min-h-11 rounded-full border border-current px-5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-ring" @click="retry">Try again</button>
496
+ </div>
497
+ <span v-else-if="waiting && playing" role="status" class="rounded-full bg-(--video-canvas)/65 px-4 py-2 text-sm">Loading video…</span>
498
+ <span v-else-if="!playing" class="grid size-16 place-items-center rounded-full bg-(--video-canvas)/50 backdrop-blur" aria-hidden="true">
499
+ <svg viewBox="0 0 24 24" class="ml-1 size-7 fill-current"><path d="M8 5l11 7-11 7z" /></svg>
500
+ </span>
501
+ </div>
502
+ <div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-linear-to-t from-(--video-canvas)/95 via-(--video-canvas)/65 to-transparent px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-24">
503
+ <div class="flex items-end gap-3">
504
+ <div class="pointer-events-auto min-w-0 flex-1 space-y-2">
505
+ <slot name="caption" :item="item" :index="itemIndex">
506
+ <p v-if="item.creator" class="text-xs font-semibold">{{ item.creator }}</p>
507
+ <h3 class="text-xl font-semibold leading-tight tracking-tight">{{ item.title }}</h3>
508
+ <p v-if="item.description" class="text-sm leading-relaxed opacity-85">{{ item.description }}</p>
509
+ </slot>
510
+ </div>
511
+ <div v-if="$slots.actions" class="pointer-events-auto flex shrink-0 flex-col items-center gap-3"><slot name="actions" :item="item" :index="itemIndex" /></div>
512
+ </div>
513
+ <div class="pointer-events-auto mt-3 flex items-center gap-3" :class="{ invisible: itemIndex !== activeIndex }">
514
+ <div class="min-w-0 flex-1">
515
+ <DomRangeInput
516
+ :model-value="progress"
517
+ :min="0" :max="100" :step="0.1"
518
+ :show-value="false" chrome="none"
519
+ :disabled="!duration || failed || itemIndex !== activeIndex"
520
+ :aria-label="`Seek ${item.title || 'video'}`"
521
+ :aria-valuetext="`${formatTime(currentTime)} of ${formatTime(duration)}`"
522
+ control-class="min-h-11"
523
+ track-class="h-0.5! rounded-none! bg-(--video-fg)/25! shadow-none!"
524
+ progress-class="h-0.5! rounded-none! bg-(--video-fg)/80! shadow-none!"
525
+ handle-class="size-3! bg-(--video-fg)! opacity-0 transition-opacity! group-hover/range:opacity-100 group-data-[keyboard-focus]/range:opacity-100 motion-reduce:transition-none!"
526
+ active-handle-class="opacity-100!"
527
+ @update:model-value="seek"
528
+ @update:dragging="handleScrubbing"
529
+ />
530
+ </div>
531
+ <span class="text-[11px] font-medium tabular-nums">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
532
+ <button v-if="item.tracks?.length" type="button" :aria-pressed="captions" :aria-label="captions ? 'Turn captions off' : 'Turn captions on'" class="min-h-11 min-w-11 rounded-lg border border-current px-2 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-ring" @click="toggleCaptions">CC</button>
533
+ </div>
534
+ </div>
535
+ </article>
536
+ </div>
537
+ <div v-if="items.length > 1" class="hidden shrink-0 flex-col items-center justify-center gap-3 pr-4 sm:flex" role="group" aria-label="Video navigation">
538
+ <DomIconButton label="Previous video" icon="M6 14l6-6 6 6" variant="secondary" size="lg" :disabled="activeIndex === 0" @click="previous" />
539
+ <DomIconButton label="Next video" icon="M6 10l6 6 6-6" variant="secondary" size="lg" :disabled="activeIndex === items.length - 1" @click="next" />
540
+ </div>
541
+ <p class="sr-only" aria-live="polite" aria-atomic="true">{{ activeItem ? `Video ${activeIndex + 1} of ${items.length}: ${activeItem.title}` : 'No videos' }}</p>
542
+ </div>
543
+ </template>
544
+
545
+ <style scoped>
546
+ /* Native captions use the same themeable contrast as the playback controls. */
547
+ video::cue {
548
+ color: var(--video-fg);
549
+ background: var(--video-canvas);
550
+ }
551
+ </style>
@@ -109,6 +109,7 @@ import DomAgentConversationList from './DomAgentConversationList.vue';
109
109
 
110
110
  defineOptions({
111
111
  __doc: {
112
+ nav: { section: 'ai', primary: true },
112
113
  name: 'Agent chat shell',
113
114
  tag: '<DomAgentChatShell>',
114
115
  description: 'A three-pane AI chat application shell with a resizable conversation sidebar, scroll-contained conversation window, closable context aside, streaming tool calls, and audio/text handoff controls.',
@@ -140,6 +140,7 @@ import DomStatusPill from '../../visual/status-pill/DomStatusPill.vue';
140
140
 
141
141
  defineOptions({
142
142
  __doc: {
143
+ nav: { section: 'ai' },
143
144
  name: 'Agent conversation',
144
145
  tag: '<DomAgentConversation>',
145
146
  description: 'A scroll-contained AI conversation surface with a fixed header, streaming messages, reasoning summaries, audio/text mode switching, tool-call rendering, and a composer footer.',