@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,505 @@
1
+ <script setup>
2
+ import { computed, ref } from 'vue';
3
+ import DomPopover from '../../components/popover/DomPopover.vue';
4
+ import DomIconButton from '../../components/icon-button/DomIconButton.vue';
5
+ import DomButton from '../../components/button/DomButton.vue';
6
+ import DomBadge from '../../visual/badge/DomBadge.vue';
7
+ import DomEmptyState from '../../visual/empty-state/DomEmptyState.vue';
8
+ import DomSkeleton from '../../visual/skeleton/DomSkeleton.vue';
9
+ import { notificationTone } from './notificationPresentation.js';
10
+
11
+ /**
12
+ * @typedef {object} NotificationAction
13
+ * @property {string} label Human-readable action label.
14
+ * @property {string} href Authorized application destination.
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} NotificationMessage
19
+ * @property {string} title Plain-text notification title.
20
+ * @property {string} body Plain-text notification body.
21
+ * @property {'info'|'success'|'warning'|'error'} [severity] Semantic severity independent from persistence or prominence.
22
+ * @property {'inbox'|'toast'|'banner'} [presentation] Delivery presentation recorded by the framework.
23
+ * @property {NotificationAction} [action] Optional authorized destination.
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} NotificationInboxItem
28
+ * @property {string} id Durable item identifier.
29
+ * @property {string} type Stable application notification type.
30
+ * @property {NotificationMessage} message Recipient-facing plain-text content.
31
+ * @property {string} createdAt ISO creation time.
32
+ * @property {string|null} readAt Independent read state.
33
+ * @property {string|null} dismissedAt Independent banner dismissal state.
34
+ * @property {string|null} archivedAt Independent archive state.
35
+ */
36
+
37
+ defineOptions({
38
+ __doc: {
39
+ name: 'Notification inbox',
40
+ tag: '<DomNotificationInbox>',
41
+ description: 'A polished notification bell and durable inbox panel for application-owned in-app records, including unread, action, archive, pagination, loading, and failure-ready states.',
42
+ icon: 'M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4',
43
+ slots: [
44
+ { name: 'trigger', payload: '{ unreadCount, open }', description: 'Replace the default bell trigger while retaining the inbox popover.' },
45
+ { name: 'header', payload: '{ unreadCount, refresh }', description: 'Replace the panel heading and refresh control.' },
46
+ { name: 'item', payload: '{ item, unread, transition, action }', description: 'Replace one notification row while retaining the panel list and state helpers.' },
47
+ { name: 'item-icon', payload: '{ item, tone }', description: 'Replace the semantic icon shown for one default notification row.' },
48
+ { name: 'empty', description: 'Replace the caught-up empty state.' },
49
+ { name: 'footer', payload: '{ hasMore, loadingMore, loadMore }', description: 'Replace the optional pagination footer.' },
50
+ ],
51
+ events: [
52
+ { name: 'transition', payload: '({ item, transition: "read" | "unread" | "archive" })', description: 'Requests an application-owned durable state transition.' },
53
+ { name: 'action', payload: '({ item, action })', description: 'Fired when the recipient follows a notification action.' },
54
+ { name: 'refresh', description: 'Requests a fresh first page without marking any item read.' },
55
+ { name: 'load-more', description: 'Requests the next application-owned inbox page.' },
56
+ { name: 'open', description: 'Fired when the inbox popover opens.' },
57
+ { name: 'close', description: 'Fired when the inbox popover closes.' },
58
+ ],
59
+ },
60
+ });
61
+
62
+ const props = defineProps({
63
+ items: {
64
+ type: Array,
65
+ default: () => [],
66
+ _edit: {
67
+ component: 'DomJsonListInput',
68
+ description: 'Application-owned durable notification items. The shape matches db3.ai InAppItem records.',
69
+ },
70
+ },
71
+ unreadCount: {
72
+ type: Number,
73
+ default: null,
74
+ _edit: { description: 'Authoritative unread count for the full scoped inbox. Omit to count unread supplied items.' },
75
+ },
76
+ loading: {
77
+ type: Boolean,
78
+ default: false,
79
+ _edit: { group: 'State', description: 'Show initial inbox loading placeholders when no items are available.' },
80
+ },
81
+ refreshing: {
82
+ type: Boolean,
83
+ default: false,
84
+ _edit: { group: 'State', description: 'Show refresh progress without replacing already loaded items.' },
85
+ },
86
+ loadingMore: {
87
+ type: Boolean,
88
+ default: false,
89
+ _edit: { group: 'State', description: 'Show progress on the pagination action.' },
90
+ },
91
+ updatingIds: {
92
+ type: Array,
93
+ default: () => [],
94
+ _edit: { group: 'State', description: 'Item IDs with a durable state transition currently in flight.' },
95
+ },
96
+ hasMore: {
97
+ type: Boolean,
98
+ default: false,
99
+ _edit: { group: 'State', description: 'Show a control that requests the next application-owned page.' },
100
+ },
101
+ error: {
102
+ type: String,
103
+ default: '',
104
+ _edit: { group: 'State', description: 'Compact recoverable error shown above the notification list.' },
105
+ },
106
+ title: {
107
+ type: String,
108
+ default: 'Notifications',
109
+ _edit: { group: 'Labels', description: 'Inbox panel heading.' },
110
+ },
111
+ triggerLabel: {
112
+ type: String,
113
+ default: 'Open notifications',
114
+ _edit: { group: 'Labels', description: 'Accessible label for the default bell trigger.' },
115
+ },
116
+ emptyTitle: {
117
+ type: String,
118
+ default: 'You’re all caught up',
119
+ _edit: { group: 'Labels', description: 'Empty-state heading.' },
120
+ },
121
+ emptyDescription: {
122
+ type: String,
123
+ default: 'New updates will appear here as they arrive.',
124
+ _edit: { group: 'Labels', description: 'Empty-state supporting copy.' },
125
+ },
126
+ loadMoreLabel: {
127
+ type: String,
128
+ default: 'Load older notifications',
129
+ _edit: { group: 'Labels', description: 'Pagination action label.' },
130
+ },
131
+ showRefresh: {
132
+ type: Boolean,
133
+ default: true,
134
+ _edit: { group: 'Controls', description: 'Show the application-owned refresh action in the default header.' },
135
+ },
136
+ showArchive: {
137
+ type: Boolean,
138
+ default: true,
139
+ _edit: { group: 'Controls', description: 'Show an archive action for every default notification row.' },
140
+ },
141
+ formatTimestamp: {
142
+ type: Function,
143
+ default: null,
144
+ _edit: { group: 'Content', description: 'Optional formatter receiving createdAt and the complete item.' },
145
+ },
146
+ position: {
147
+ type: String,
148
+ default: 'bottom-end',
149
+ _edit: { group: 'Layout', options: ['bottom-end', 'bottom-start', 'top-end', 'top-start'], description: 'Preferred popover placement around the trigger.' },
150
+ },
151
+ panelWidth: {
152
+ type: String,
153
+ default: 'w-[min(25rem,calc(100vw-1rem))]',
154
+ _edit: { group: 'Layout', description: 'Tailwind width utilities applied to the popover panel.' },
155
+ },
156
+ maxHeight: {
157
+ type: String,
158
+ default: 'min(34rem, calc(100dvh - 9rem))',
159
+ _edit: { group: 'Layout', description: 'CSS maximum height for the scrolling notification list.' },
160
+ },
161
+ });
162
+
163
+ const emit = defineEmits(['transition', 'action', 'refresh', 'load-more', 'open', 'close']);
164
+ const open = ref(false);
165
+ const safeItems = computed(getSafeItems);
166
+ const resolvedUnreadCount = computed(getUnreadCount);
167
+ const updatingSet = computed(getUpdatingSet);
168
+ const listStyle = computed(getListStyle);
169
+
170
+ const bellIcon = 'M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4';
171
+ const refreshIcon = 'M20 11a8 8 0 1 0-2.34 5.66M20 4v7h-7';
172
+ const readIcon = 'M4 12l5 5L20 6';
173
+ const unreadIcon = 'M4 6h16v12H4zM4 7l8 6 8-6';
174
+ const archiveIcon = 'M4 7h16M5 7l1 13h12l1-13M3 4h18v3H3zM10 11h4';
175
+ const emptyIcon = 'M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4';
176
+
177
+ /**
178
+ * Returns only object-shaped items while preserving application row identity.
179
+ *
180
+ * @returns {NotificationInboxItem[]} Safe notification item collection.
181
+ */
182
+ function getSafeItems() {
183
+ return Array.isArray(props.items) ? props.items.filter((item) => item && typeof item === 'object') : [];
184
+ }
185
+
186
+ /**
187
+ * Uses the server-owned count when supplied or derives it from the loaded rows.
188
+ *
189
+ * @returns {number} Non-negative unread count.
190
+ */
191
+ function getUnreadCount() {
192
+ if (Number.isFinite(props.unreadCount)) return Math.max(0, Number(props.unreadCount));
193
+ return safeItems.value.filter((item) => !item.readAt && !item.archivedAt).length;
194
+ }
195
+
196
+ /**
197
+ * Builds a stable lookup for item-level pending state.
198
+ *
199
+ * @returns {Set<string>} Updating notification identifiers.
200
+ */
201
+ function getUpdatingSet() {
202
+ return new Set((Array.isArray(props.updatingIds) ? props.updatingIds : []).map(String));
203
+ }
204
+
205
+ /**
206
+ * Converts the configurable list height into an inline style.
207
+ *
208
+ * @returns {{ maxHeight: string }} Notification list style.
209
+ */
210
+ function getListStyle() {
211
+ return { maxHeight: props.maxHeight };
212
+ }
213
+
214
+ /**
215
+ * Reports whether a durable update is in flight for one item.
216
+ *
217
+ * @param {NotificationInboxItem} item Notification item.
218
+ * @returns {boolean} Whether the row controls should be disabled.
219
+ */
220
+ function isUpdating(item) {
221
+ return updatingSet.value.has(String(item.id));
222
+ }
223
+
224
+ /**
225
+ * Returns the semantic DOM Studio tone for an item's framework severity.
226
+ *
227
+ * @param {NotificationInboxItem} item Notification item.
228
+ * @returns {'info'|'success'|'warning'|'danger'} Semantic tone.
229
+ */
230
+ function itemTone(item) {
231
+ return notificationTone(item?.message?.severity);
232
+ }
233
+
234
+ /**
235
+ * Resolves the visual classes for a notification severity.
236
+ *
237
+ * @param {NotificationInboxItem} item Notification item.
238
+ * @returns {string} Tailwind utility classes.
239
+ */
240
+ function iconClass(item) {
241
+ return {
242
+ info: 'bg-primary/10 text-primary ring-primary/15',
243
+ success: 'bg-success/15 text-success ring-success/20',
244
+ warning: 'bg-warning/20 text-warning-fg ring-warning/30',
245
+ danger: 'bg-destructive/15 text-destructive ring-destructive/20',
246
+ }[itemTone(item)];
247
+ }
248
+
249
+ /**
250
+ * Resolves an icon path for the notification severity.
251
+ *
252
+ * @param {NotificationInboxItem} item Notification item.
253
+ * @returns {string} SVG path data.
254
+ */
255
+ function severityIcon(item) {
256
+ return {
257
+ info: 'M12 16v-4M12 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z',
258
+ success: 'M20 6L9 17l-5-5',
259
+ warning: 'M12 9v4M12 17h.01M10.3 3.7 2.7 17a2 2 0 0 0 1.73 3h15.14a2 2 0 0 0 1.73-3L13.7 3.7a2 2 0 0 0-3.4 0z',
260
+ danger: 'M12 8v5M12 17h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z',
261
+ }[itemTone(item)];
262
+ }
263
+
264
+ /**
265
+ * Formats a notification timestamp through the consumer hook or a stable UTC fallback.
266
+ *
267
+ * @param {NotificationInboxItem} item Notification item.
268
+ * @returns {string} Display-ready timestamp.
269
+ */
270
+ function formattedTimestamp(item) {
271
+ if (typeof props.formatTimestamp === 'function') {
272
+ const formatted = props.formatTimestamp(item.createdAt, item);
273
+ return formatted === null || formatted === undefined ? '' : String(formatted);
274
+ }
275
+ const date = new Date(item.createdAt);
276
+ if (Number.isNaN(date.getTime())) return String(item.createdAt || '');
277
+ return new Intl.DateTimeFormat('en-GB', {
278
+ day: 'numeric',
279
+ month: 'short',
280
+ hour: '2-digit',
281
+ minute: '2-digit',
282
+ hour12: false,
283
+ timeZone: 'UTC',
284
+ }).format(date);
285
+ }
286
+
287
+ /**
288
+ * Restricts action destinations to the framework's root-relative and HTTP(S) contract.
289
+ *
290
+ * @param {unknown} href Candidate action destination.
291
+ * @returns {string} Safe link destination or an empty string.
292
+ */
293
+ function safeHref(href) {
294
+ const value = String(href || '');
295
+ if (value.startsWith('/') && !value.startsWith('//')) return value;
296
+ try {
297
+ const url = new URL(value);
298
+ return ['http:', 'https:'].includes(url.protocol) ? value : '';
299
+ } catch {
300
+ return '';
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Emits one explicit framework-compatible recipient state transition.
306
+ *
307
+ * @param {NotificationInboxItem} item Notification item.
308
+ * @param {'read'|'unread'|'archive'} transition Requested durable transition.
309
+ * @returns {void}
310
+ */
311
+ function requestTransition(item, transition) {
312
+ if (isUpdating(item)) return;
313
+ emit('transition', { item, transition });
314
+ }
315
+
316
+ /**
317
+ * Emits action intent without coupling navigation to read state.
318
+ *
319
+ * @param {NotificationInboxItem} item Notification item.
320
+ * @returns {void}
321
+ */
322
+ function followAction(item) {
323
+ if (!item?.message?.action) return;
324
+ emit('action', { item, action: item.message.action });
325
+ }
326
+
327
+ /**
328
+ * Requests a fresh first page without inferring a read transition.
329
+ *
330
+ * @returns {void}
331
+ */
332
+ function refresh() {
333
+ if (!props.refreshing) emit('refresh');
334
+ }
335
+
336
+ /**
337
+ * Requests the next cursor page from the owning application.
338
+ *
339
+ * @returns {void}
340
+ */
341
+ function loadMore() {
342
+ if (!props.loadingMore) emit('load-more');
343
+ }
344
+
345
+ /**
346
+ * Tracks the native popover state and exposes it to trigger slots.
347
+ *
348
+ * @returns {void}
349
+ */
350
+ function handleOpen() {
351
+ open.value = true;
352
+ emit('open');
353
+ }
354
+
355
+ /**
356
+ * Tracks dismissal of the native popover.
357
+ *
358
+ * @returns {void}
359
+ */
360
+ function handleClose() {
361
+ open.value = false;
362
+ emit('close');
363
+ }
364
+ </script>
365
+
366
+ <template>
367
+ <DomPopover
368
+ :position="position"
369
+ :width="panelWidth"
370
+ padding="p-0"
371
+ :arrow="false"
372
+ @open="handleOpen"
373
+ @close="handleClose"
374
+ >
375
+ <template #trigger>
376
+ <slot name="trigger" :unread-count="resolvedUnreadCount" :open="open">
377
+ <span class="relative inline-grid">
378
+ <DomIconButton :label="triggerLabel" :icon="bellIcon" :active="open" />
379
+ <span
380
+ v-if="resolvedUnreadCount > 0"
381
+ class="pointer-events-none absolute -right-1 -top-1 inline-flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold leading-none text-primary-fg ring-2 ring-canvas"
382
+ aria-hidden="true"
383
+ >
384
+ {{ resolvedUnreadCount > 99 ? '99+' : resolvedUnreadCount }}
385
+ </span>
386
+ <span class="sr-only" aria-live="polite">{{ resolvedUnreadCount }} unread notifications</span>
387
+ </span>
388
+ </slot>
389
+ </template>
390
+
391
+ <section class="overflow-hidden rounded-xl bg-canvas text-canvas-fg" :aria-label="title" :aria-busy="loading || refreshing">
392
+ <slot name="header" :unread-count="resolvedUnreadCount" :refresh="refresh">
393
+ <header class="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5">
394
+ <div class="min-w-0">
395
+ <div class="flex items-center gap-2">
396
+ <h2 class="truncate text-sm font-semibold tracking-tight">{{ title }}</h2>
397
+ <DomBadge v-if="resolvedUnreadCount > 0" tone="primary" size="sm">{{ resolvedUnreadCount }} unread</DomBadge>
398
+ </div>
399
+ <p class="mt-0.5 text-xs text-muted-fg">Your latest account and workspace updates</p>
400
+ </div>
401
+ <DomIconButton
402
+ v-if="showRefresh"
403
+ label="Refresh notifications"
404
+ :icon="refreshIcon"
405
+ size="sm"
406
+ :loading="refreshing"
407
+ @click="refresh"
408
+ />
409
+ </header>
410
+ </slot>
411
+
412
+ <div v-if="error" role="alert" class="border-b border-destructive/20 bg-destructive/10 px-4 py-2.5 text-xs text-destructive">
413
+ <div class="flex items-center justify-between gap-3">
414
+ <p>{{ error }}</p>
415
+ <button v-if="showRefresh" type="button" class="shrink-0 font-semibold underline underline-offset-2" @click="refresh">Try again</button>
416
+ </div>
417
+ </div>
418
+
419
+ <div class="overscroll-contain overflow-y-auto" :style="listStyle">
420
+ <div v-if="loading && !safeItems.length" role="status" aria-label="Loading notifications" class="grid gap-4 p-4">
421
+ <div v-for="index in 4" :key="index" class="flex gap-3">
422
+ <DomSkeleton variant="circle" width="2.25rem" height="2.25rem" />
423
+ <div class="min-w-0 flex-1 pt-1"><DomSkeleton variant="text" :lines="2" /></div>
424
+ </div>
425
+ </div>
426
+
427
+ <slot v-else-if="!safeItems.length" name="empty">
428
+ <DomEmptyState
429
+ class="m-4"
430
+ :title="emptyTitle"
431
+ :description="emptyDescription"
432
+ :icon="emptyIcon"
433
+ size="sm"
434
+ />
435
+ </slot>
436
+
437
+ <div v-else role="list" class="divide-y divide-border">
438
+ <article
439
+ v-for="item in safeItems"
440
+ :key="item.id"
441
+ role="listitem"
442
+ class="group relative flex gap-3 px-4 py-3.5 transition-colors hover:bg-secondary/45"
443
+ :class="!item.readAt ? 'bg-primary/[0.035]' : ''"
444
+ >
445
+ <span v-if="!item.readAt" class="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-primary" aria-hidden="true"></span>
446
+ <slot name="item" :item="item" :unread="!item.readAt" :transition="requestTransition" :action="followAction">
447
+ <slot name="item-icon" :item="item" :tone="itemTone(item)">
448
+ <span class="mt-0.5 grid size-9 shrink-0 place-items-center rounded-full ring-1 ring-inset" :class="iconClass(item)">
449
+ <svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
450
+ <path :d="severityIcon(item)" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
451
+ </svg>
452
+ </span>
453
+ </slot>
454
+
455
+ <div class="min-w-0 flex-1">
456
+ <div class="flex items-start justify-between gap-3">
457
+ <h3 class="text-sm leading-5" :class="item.readAt ? 'font-medium' : 'font-semibold'">{{ item.message?.title }}</h3>
458
+ <time v-if="formattedTimestamp(item)" :datetime="item.createdAt" class="shrink-0 pt-0.5 text-[11px] text-muted-fg">{{ formattedTimestamp(item) }}</time>
459
+ </div>
460
+ <p class="mt-1 text-xs leading-5 text-muted-fg">{{ item.message?.body }}</p>
461
+
462
+ <div class="mt-2.5 flex min-h-8 flex-wrap items-center justify-between gap-2">
463
+ <a
464
+ v-if="item.message?.action && safeHref(item.message.action.href)"
465
+ :href="safeHref(item.message.action.href)"
466
+ class="inline-flex items-center gap-1 text-xs font-semibold text-primary outline-none hover:underline focus-visible:rounded focus-visible:ring-2 focus-visible:ring-ring/60"
467
+ @click="followAction(item)"
468
+ >
469
+ {{ item.message.action.label }}
470
+ <svg viewBox="0 0 24 24" class="size-3.5" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
471
+ </a>
472
+ <span v-else></span>
473
+
474
+ <div class="flex items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
475
+ <DomIconButton
476
+ :label="item.readAt ? 'Mark as unread' : 'Mark as read'"
477
+ :icon="item.readAt ? unreadIcon : readIcon"
478
+ size="xs"
479
+ :loading="isUpdating(item)"
480
+ @click="requestTransition(item, item.readAt ? 'unread' : 'read')"
481
+ />
482
+ <DomIconButton
483
+ v-if="showArchive"
484
+ label="Archive notification"
485
+ :icon="archiveIcon"
486
+ size="xs"
487
+ :disabled="isUpdating(item)"
488
+ @click="requestTransition(item, 'archive')"
489
+ />
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </slot>
494
+ </article>
495
+ </div>
496
+ </div>
497
+
498
+ <slot v-if="hasMore" name="footer" :has-more="hasMore" :loading-more="loadingMore" :load-more="loadMore">
499
+ <footer class="border-t border-border bg-secondary/20 p-3">
500
+ <DomButton class="w-full justify-center" size="sm" variant="ghost" :loading="loadingMore" @click="loadMore">{{ loadMoreLabel }}</DomButton>
501
+ </footer>
502
+ </slot>
503
+ </section>
504
+ </DomPopover>
505
+ </template>
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Maps framework notification severity to DOM Studio's semantic tone vocabulary.
3
+ *
4
+ * @param {unknown} severity Framework-provided notification severity.
5
+ * @returns {'info'|'success'|'warning'|'danger'} DOM Studio semantic tone.
6
+ */
7
+ export function notificationTone(severity) {
8
+ if (severity === 'error' || severity === 'danger') return 'danger';
9
+ if (severity === 'success') return 'success';
10
+ if (severity === 'warning') return 'warning';
11
+ return 'info';
12
+ }
13
+
14
+ /**
15
+ * Creates a transient toast record from a durable in-app notification item.
16
+ * Calling applications should use this only for a newly observed live arrival;
17
+ * loading inbox history must not replay old items as fresh toasts.
18
+ *
19
+ * @param {Record<string, any>} item Durable in-app notification item.
20
+ * @param {{ duration?: number, dismissible?: boolean }} [options] Toast overrides.
21
+ * @returns {{ id: string, title: string, description: string, tone: string, duration: number, dismissible: boolean }} Toast stack record.
22
+ */
23
+ export function notificationToast(item, options = {}) {
24
+ const message = item?.message || {};
25
+ const semanticTone = notificationTone(message.severity);
26
+ return {
27
+ id: String(item?.id || ''),
28
+ title: String(message.title || ''),
29
+ description: String(message.body || ''),
30
+ tone: semanticTone === 'info' ? 'default' : semanticTone,
31
+ duration: options.duration ?? 6000,
32
+ dismissible: options.dismissible ?? true,
33
+ };
34
+ }
@@ -0,0 +1,62 @@
1
+ <script setup>
2
+ defineProps({
3
+ icon: {
4
+ type: String,
5
+ required: true,
6
+ },
7
+ label: {
8
+ type: String,
9
+ required: true,
10
+ },
11
+ });
12
+ </script>
13
+
14
+ <template>
15
+ <button type="button" class="icon-button" :aria-label="label" :title="label">
16
+ <svg v-if="icon === 'duplicate'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
17
+ <path d="M8 8h10v10H8V8Z" stroke="currentColor" stroke-width="1.8" />
18
+ <path d="M6 16H4V4h12v2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
19
+ </svg>
20
+ <svg v-else-if="icon === 'eye'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
21
+ <path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
22
+ <path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="currentColor" stroke-width="1.8" />
23
+ </svg>
24
+ <svg v-else-if="icon === 'eye-off'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
25
+ <path d="M3 3l18 18M10.6 10.6A3 3 0 0 0 13.4 13.4M7.1 7.8C4.1 9.4 2.5 12 2.5 12s3.5 6 9.5 6c1.6 0 3-.4 4.2-1M19.1 15.1c1.6-1.4 2.4-3.1 2.4-3.1s-3.5-6-9.5-6c-.8 0-1.6.1-2.3.3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
26
+ </svg>
27
+ <svg v-else-if="icon === 'remove'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
28
+ <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" />
29
+ </svg>
30
+ <svg v-else-if="icon === 'plus'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
31
+ <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" />
32
+ </svg>
33
+ <svg v-else-if="icon === 'minus'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
34
+ <path d="M5 12h14" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" />
35
+ </svg>
36
+ </button>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .icon-button {
41
+ display: inline-flex;
42
+ width: 1.75rem;
43
+ height: 1.75rem;
44
+ align-items: center;
45
+ justify-content: center;
46
+ border-radius: 0.375rem;
47
+ color: var(--muted-fg);
48
+ font-size: 0.875rem;
49
+ line-height: 1;
50
+ transition: background-color 140ms ease, color 140ms ease;
51
+ }
52
+
53
+ .icon-button svg {
54
+ width: 1rem;
55
+ height: 1rem;
56
+ }
57
+
58
+ .icon-button:hover {
59
+ background: var(--secondary);
60
+ color: var(--secondary-fg);
61
+ }
62
+ </style>
@@ -0,0 +1,59 @@
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+ import { cssEditorContextKey } from './cssEditorContext.js';
4
+
5
+ const props = defineProps({
6
+ title: {
7
+ type: String,
8
+ required: true,
9
+ },
10
+ meta: {
11
+ type: String,
12
+ default: '',
13
+ },
14
+ modelValue: {
15
+ type: Boolean,
16
+ default: true,
17
+ },
18
+ muted: {
19
+ type: Boolean,
20
+ default: false,
21
+ },
22
+ });
23
+
24
+ const emit = defineEmits(['update:modelValue']);
25
+ const editorContext = inject(cssEditorContextKey, null);
26
+ const framed = computed(() => editorContext?.framed.value !== false);
27
+
28
+ /**
29
+ * Toggles the section disclosure without changing its material values.
30
+ *
31
+ * @returns {void}
32
+ */
33
+ function toggleOpen() {
34
+ emit('update:modelValue', !props.modelValue);
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <section
40
+ class="text-canvas-fg"
41
+ :class="[
42
+ framed ? 'rounded-lg border border-border skin-card' : 'border-b border-border bg-canvas',
43
+ muted && 'opacity-60',
44
+ ]"
45
+ >
46
+ <div class="grid grid-cols-[1fr_auto] items-center gap-2" :class="framed ? 'px-3 py-2' : 'px-4 py-3'">
47
+ <button type="button" class="flex min-w-0 items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" :aria-label="`${modelValue ? 'Collapse' : 'Expand'} ${title}`" :aria-expanded="modelValue" @click="toggleOpen">
48
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" class="size-4 shrink-0 text-muted-fg" :class="modelValue && 'rotate-90'"><path d="m6 3 5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
49
+ <span class="min-w-0"><span class="block truncate text-sm font-semibold">{{ title }}</span><span v-if="meta" class="block text-xs text-muted-fg">{{ meta }}</span></span>
50
+ </button>
51
+ <div class="flex items-center gap-1">
52
+ <slot name="actions" />
53
+ </div>
54
+ </div>
55
+ <div v-if="modelValue" class="border-t border-border">
56
+ <slot />
57
+ </div>
58
+ </section>
59
+ </template>
@@ -0,0 +1,19 @@
1
+ /** Internal context shared by a material editor and its section/colour controls. */
2
+ export const cssEditorContextKey = Symbol('css-component-editor');
3
+
4
+ /**
5
+ * Resolves simple semantic variable references against the active preview palette.
6
+ * The original model value stays untouched so linked colours remain linked.
7
+ * Unknown references and cycles are preserved for the browser's CSS fallback.
8
+ *
9
+ * @param {string} value CSS colour or expression containing variable references.
10
+ * @param {Record<string, string>} variables Active preview CSS variables.
11
+ * @param {Set<string>} visited References already being resolved in this branch.
12
+ * @returns {string} CSS expression with known variables substituted.
13
+ */
14
+ export function resolveEditorColor(value, variables = {}, visited = new Set()) {
15
+ return String(value ?? '').replace(/var\(\s*(--[\w-]+)\s*\)/g, (reference, name) => {
16
+ if (visited.has(name) || variables[name] == null) return reference;
17
+ return resolveEditorColor(variables[name], variables, new Set([...visited, name]));
18
+ });
19
+ }