@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,223 @@
1
+ import { resolveResourceKey, resolveResourceValue } from '../_shared/resourceRecord.js';
2
+
3
+ /**
4
+ * Parses an instant or local calendar date without interpreting YYYY-MM-DD as UTC.
5
+ * Invalid, missing, and overflowing calendar dates return null.
6
+ * @param {unknown} value Date, timestamp, or ISO-compatible string.
7
+ * @returns {Date|null} Independent valid date.
8
+ */
9
+ export function parseGanttDate(value) {
10
+ if (value === null || value === undefined || value === '') return null;
11
+ let date;
12
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
13
+ const [year, month, day] = value.split('-').map(Number);
14
+ date = new Date(year, month - 1, day);
15
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
16
+ } else if (value instanceof Date || typeof value === 'string' || typeof value === 'number') {
17
+ date = new Date(value);
18
+ }
19
+ return date && Number.isFinite(date.getTime()) ? date : null;
20
+ }
21
+
22
+ /**
23
+ * Advances local calendar days, preserving wall-clock time across DST boundaries.
24
+ * @param {Date} date Starting date.
25
+ * @param {number} days Number of calendar days.
26
+ * @returns {Date} Shifted copy.
27
+ */
28
+ export function addGanttDays(date, days) {
29
+ const result = new Date(date);
30
+ result.setDate(result.getDate() + days);
31
+ return result;
32
+ }
33
+
34
+ /**
35
+ * Creates fixed-width visible cells; omitted time retains its original timestamps.
36
+ * Hour cells follow elapsed time so DST days contain their actual 23 or 25 hours.
37
+ * @param {object} options Range, zoom, workingHours, weekendDays, and hiding flags.
38
+ * @returns {{cells: Array<object>, width: number, cellWidth: number, start: Date, end: Date}} Axis geometry.
39
+ */
40
+ export function createGanttAxis(options) {
41
+ const start = parseGanttDate(options.startDate) || new Date();
42
+ start.setHours(0, 0, 0, 0);
43
+ const days = Math.min(3660, Math.max(1, Math.floor(Number(options.days) || 84)));
44
+ const end = addGanttDays(start, days);
45
+ const hourly = options.zoom === 'days';
46
+ const cellWidth = hourly ? 32 : options.zoom === 'months' ? 14 : 48;
47
+ const workingHours = options.workingHours || [6, 18];
48
+ const hourStart = Math.max(0, Math.min(23, Number(workingHours[0]) || 0));
49
+ const hourEnd = Math.max(hourStart + 1, Math.min(24, Number(workingHours[1]) || 24));
50
+ const weekends = Array.isArray(options.weekendDays) ? options.weekendDays : [0, 6];
51
+ const cells = [];
52
+ for (let day = start; day < end; day = addGanttDays(day, 1)) {
53
+ const weekend = weekends.includes(day.getDay());
54
+ if (weekend && options.hideWeekends) continue;
55
+ const nextDay = addGanttDays(day, 1);
56
+ for (let cursor = day; cursor < nextDay;) {
57
+ const next = hourly ? new Date(Math.min(+cursor + 3600000, +nextDay)) : nextDay;
58
+ const outsideHours = hourly && (cursor.getHours() < hourStart || cursor.getHours() >= hourEnd);
59
+ if (!(outsideHours && options.hideNonWorkingHours)) {
60
+ cells.push({ start: +cursor, end: +next, left: cells.length * cellWidth, width: cellWidth, muted: weekend || outsideHours });
61
+ }
62
+ cursor = next;
63
+ }
64
+ }
65
+ return { cells, width: cells.length * cellWidth, cellWidth, start, end };
66
+ }
67
+
68
+ /**
69
+ * Maps an instant to compressed-axis pixels. Hidden periods map to their boundary.
70
+ * @param {object} axis Result of createGanttAxis.
71
+ * @param {number|Date} value Instant to locate.
72
+ * @returns {number} Clamped horizontal coordinate.
73
+ */
74
+ export function ganttDateToX(axis, value) {
75
+ const time = +value;
76
+ let low = 0;
77
+ let high = axis.cells.length;
78
+ while (low < high) {
79
+ const middle = (low + high) >> 1;
80
+ if (axis.cells[middle].end <= time) low = middle + 1;
81
+ else high = middle;
82
+ }
83
+ const cell = axis.cells[low];
84
+ if (!cell) return axis.width;
85
+ return cell.left + Math.max(0, (time - cell.start) / (cell.end - cell.start)) * cell.width;
86
+ }
87
+
88
+ /**
89
+ * Converts pixels back into real time, distinguishing range starts from ends at a gap.
90
+ * @param {object} axis Visible axis.
91
+ * @param {number} x Horizontal coordinate.
92
+ * @param {'start'|'end'} edge Boundary bias at omitted periods.
93
+ * @returns {number|null} Timestamp, or null for an empty axis.
94
+ */
95
+ export function ganttXToDate(axis, x, edge = 'start') {
96
+ if (!axis.cells.length) return null;
97
+ const bounded = Math.max(0, Math.min(axis.width, x));
98
+ const index = Math.min(axis.cells.length - 1, Math.max(0, edge === 'end' ? Math.ceil(bounded / axis.cellWidth) - 1 : Math.floor(bounded / axis.cellWidth)));
99
+ const cell = axis.cells[index];
100
+ return cell.start + ((bounded - cell.left) / cell.width) * (cell.end - cell.start);
101
+ }
102
+
103
+ /**
104
+ * Resolves an item span. Explicit ends take precedence; durations must be positive.
105
+ * Day durations are whole local calendar days; hours/minutes are elapsed time.
106
+ * @param {object} item Application-owned record.
107
+ * @param {object} fields Field resolvers and durationUnit.
108
+ * @param {number} index Source index supplied to resolvers.
109
+ * @returns {{start: number, end: number}|null} Valid exclusive-end interval.
110
+ */
111
+ export function resolveGanttSpan(item, fields, index = 0) {
112
+ const start = parseGanttDate(resolveResourceValue(item, fields.startKey, index));
113
+ if (!start) return null;
114
+ const rawEnd = resolveResourceValue(item, fields.endKey, index);
115
+ let end = parseGanttDate(rawEnd);
116
+ if (rawEnd === undefined || rawEnd === null || rawEnd === '') {
117
+ const duration = Number(resolveResourceValue(item, fields.durationKey, index));
118
+ if (!Number.isFinite(duration) || duration <= 0) return null;
119
+ if (fields.durationUnit === 'days') {
120
+ if (!Number.isInteger(duration)) return null;
121
+ end = addGanttDays(start, duration);
122
+ } else {
123
+ end = new Date(+start + duration * (fields.durationUnit === 'minutes' ? 60000 : 3600000));
124
+ }
125
+ }
126
+ return end && +end > +start ? { start: +start, end: +end } : null;
127
+ }
128
+
129
+ /**
130
+ * Projects item records into lane geometry and stacks overlapping spans.
131
+ * Source records are never sorted in place or mutated. Invalid/off-axis items are omitted.
132
+ * @param {Array<object>} rows Application-owned resource or feature rows.
133
+ * @param {Array<object>} items Application-owned scheduled items.
134
+ * @param {object} fields Logical field mappings and rowHeight.
135
+ * @param {object} axis Visible axis.
136
+ * @returns {Array<object>} Rows retaining source identity, with stacked item records.
137
+ */
138
+ export function layoutGanttRows(rows, items, fields, axis) {
139
+ const groups = new Map();
140
+ for (const [index, item] of items.entries()) {
141
+ const span = resolveGanttSpan(item, fields, index);
142
+ if (!span) continue;
143
+ const left = ganttDateToX(axis, span.start);
144
+ const right = ganttDateToX(axis, span.end);
145
+ if (right <= left) continue;
146
+ const rowId = resolveResourceValue(item, fields.itemRowKey, index);
147
+ const group = groups.get(rowId) || [];
148
+ group.push({ item, key: resolveResourceKey(item, fields.itemKey, index), title: resolveResourceValue(item, fields.titleKey, index), tone: resolveResourceValue(item, fields.toneKey, index), ...span, left, width: right - left });
149
+ groups.set(rowId, group);
150
+ }
151
+ return rows.map((row, index) => {
152
+ const key = resolveResourceKey(row, fields.rowKey, index);
153
+ const records = (groups.get(key) || []).sort((a, b) => a.start - b.start || a.end - b.end);
154
+ const tracks = [];
155
+ for (const record of records) {
156
+ let track = tracks.findIndex((end) => end <= record.left);
157
+ if (track === -1) track = tracks.length;
158
+ tracks[track] = record.left + record.width;
159
+ record.top = 12 + track * 36;
160
+ }
161
+ return { row, key, label: resolveResourceValue(row, fields.rowLabelKey, index), records, height: Math.max(Number(fields.rowHeight) || 64, 24 + tracks.length * 36) };
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Resolves the shared movement, resize, and creation increment in axis pixels.
167
+ * @param {object} axis Visible time axis.
168
+ * @param {string} zoom Current zoom level.
169
+ * @param {number} selectionStep Hourly-view increment in minutes.
170
+ * @returns {number} Positive pixel increment.
171
+ */
172
+ export function ganttSnapWidth(axis, zoom, selectionStep = 30) {
173
+ return axis.cellWidth * (zoom === 'days' ? Math.min(60, Math.max(1, Number(selectionStep) || 30)) / 60 : 1);
174
+ }
175
+
176
+ /**
177
+ * Moves a span by snapped pointer distance without changing its source record.
178
+ * Week/month movement preserves both local wall-clock endpoints across DST;
179
+ * hourly movement preserves elapsed duration. Hidden cells affect where the
180
+ * pointer lands, but are never subtracted from the stored interval.
181
+ * @param {object} axis Visible time axis.
182
+ * @param {{start: number, end: number}} span Original exclusive interval.
183
+ * @param {number} anchorX Axis coordinate where the item was grabbed.
184
+ * @param {number} deltaX Pointer travel in axis pixels, including scrolling.
185
+ * @param {string} zoom Current zoom.
186
+ * @param {number} selectionStep Hourly increment in minutes.
187
+ * @returns {{start: number, end: number}} Proposed interval.
188
+ */
189
+ export function moveGanttSpan(axis, span, anchorX, deltaX, zoom, selectionStep = 30) {
190
+ const step = ganttSnapWidth(axis, zoom, selectionStep);
191
+ const delta = Math.round(deltaX / step) * step;
192
+ if (!axis.cells.length || !delta) return { start: span.start, end: span.end };
193
+ const anchor = Math.max(0, Math.min(axis.width - step, Math.floor(anchorX / step) * step));
194
+ const target = Math.max(0, Math.min(axis.width - step, anchor + delta));
195
+ const from = ganttXToDate(axis, anchor);
196
+ const to = ganttXToDate(axis, target);
197
+ if (zoom === 'days') return { start: span.start + to - from, end: span.end + to - from };
198
+ const first = new Date(from);
199
+ const last = new Date(to);
200
+ const days = (Date.UTC(last.getFullYear(), last.getMonth(), last.getDate()) - Date.UTC(first.getFullYear(), first.getMonth(), first.getDate())) / 86400000;
201
+ return { start: +addGanttDays(new Date(span.start), days), end: +addGanttDays(new Date(span.end), days) };
202
+ }
203
+
204
+ /**
205
+ * Adjusts only an item's exclusive end using the shared visible-time increment.
206
+ * A changed span retains at least one visible selection increment. The true end
207
+ * is preserved for zero movement, including ends inside hidden nights/weekends.
208
+ * @param {object} axis Visible time axis.
209
+ * @param {{start: number, end: number}} span Original exclusive interval.
210
+ * @param {number} deltaX Pointer travel from the original right edge.
211
+ * @param {string} zoom Current zoom.
212
+ * @param {number} selectionStep Hourly increment in minutes.
213
+ * @returns {{start: number, end: number}} Proposed interval.
214
+ */
215
+ export function resizeGanttSpan(axis, span, deltaX, zoom, selectionStep = 30) {
216
+ const step = ganttSnapWidth(axis, zoom, selectionStep);
217
+ const delta = Math.round(deltaX / step) * step;
218
+ if (!axis.cells.length || !delta) return { start: span.start, end: span.end };
219
+ const minimum = Math.min(axis.width, ganttDateToX(axis, span.start) + step);
220
+ const position = Math.max(minimum, Math.min(axis.width, ganttDateToX(axis, span.end) + delta));
221
+ const end = ganttXToDate(axis, position, 'end');
222
+ return { start: span.start, end: end > span.start ? end : span.end };
223
+ }
@@ -245,6 +245,36 @@ export const monthCalendarProps = {
245
245
  default: true,
246
246
  _edit: { description: 'Render weekday headers.' },
247
247
  },
248
+ stickyDayHeaders: {
249
+ type: Boolean,
250
+ default: null,
251
+ _edit: { description: 'Pin weekday headers to the active scrollport, below sticky month titles. When omitted, enabled in continuous mode and disabled in separated months.' },
252
+ },
253
+ showDayNames: {
254
+ type: Boolean,
255
+ default: true,
256
+ _edit: { description: 'Show the weekday name beside the date inside each day cell, including the mobile day list.' },
257
+ },
258
+ todayIndicator: {
259
+ type: String,
260
+ default: 'cell',
261
+ /**
262
+ * Accepts supported visual treatments for the current date.
263
+ *
264
+ * @param {string} value Requested indicator style.
265
+ * @returns {boolean} Whether the indicator is supported.
266
+ */
267
+ validator: (value) => ['cell', 'circle', 'none'].includes(value),
268
+ _edit: { group: 'Styling', options: ['cell', 'circle', 'none'], description: 'Highlight today with a cell outline, a coloured circle behind the date, or no visual indicator.' },
269
+ },
270
+ /**
271
+ * @type {MonthCalendarDayClass}
272
+ */
273
+ todayDateClass: {
274
+ type: [String, Array, Object, Function],
275
+ default: 'bg-primary text-primary-fg',
276
+ _edit: { group: 'Styling', description: 'Classes for today’s date circle. Replaces the default primary background and foreground colours when todayIndicator is circle.' },
277
+ },
248
278
  /**
249
279
  * @type {MonthCalendarDayClass}
250
280
  */
@@ -302,6 +332,8 @@ const dragState = ref(createEmptyDragState());
302
332
  const activeContinuousMonthValue = ref('');
303
333
  const calendarRoot = ref(null);
304
334
  const externalContinuousScrollHost = ref(null);
335
+ let monthHeaderResizeObserver = null;
336
+ const resolvedStickyDayHeaders = computed(() => props.stickyDayHeaders ?? props.continuous);
305
337
  const resolvedWeekStartsOn = computed(() => clampWeekStartsOn(props.weekStartsOn));
306
338
  const defaultMutedDayClass = 'bg-secondary/20 text-muted-fg/55';
307
339
  const rangeStart = computed(() => parseDate(props.startDate));
@@ -367,12 +399,16 @@ const wideWeekGridColumns = computed(() => {
367
399
 
368
400
  onMounted(() => {
369
401
  nextTick(configureExternalContinuousScrollTracking);
402
+ nextTick(configureMonthHeaderOffsets);
370
403
  });
371
404
 
372
405
  onBeforeUnmount(() => {
373
406
  removeExternalContinuousScrollTracking();
407
+ monthHeaderResizeObserver?.disconnect();
374
408
  });
375
409
 
410
+ watch([calendarSections, () => props.stickyMonthHeaders], () => nextTick(configureMonthHeaderOffsets), { flush: 'post' });
411
+
376
412
  watch(
377
413
  () => [props.continuous, props.containedContinuousScroll, props.startDate, props.months, props.weekStartsOn],
378
414
  () => {
@@ -678,6 +714,7 @@ function rowHeaderLabelFor(week) {
678
714
  * @returns {Date|null} First day of the configured month, or null when the props are incomplete.
679
715
  */
680
716
  function initialDateFromProps() {
717
+ if (props.initialYear == null || props.initialMonth == null) return null;
681
718
  const year = Number(props.initialYear);
682
719
  const month = Number(props.initialMonth);
683
720
  if (!Number.isFinite(year) || !Number.isFinite(month)) return null;
@@ -1354,6 +1391,33 @@ function onSectionScroll(section, event) {
1354
1391
  updateActiveContinuousMonthForScrollHost(scrollHost, scrollHost);
1355
1392
  }
1356
1393
 
1394
+ /**
1395
+ * Measures month headings so sticky weekday rows clear wrapped titles and slots.
1396
+ * Reconnects after range or layout changes and releases the previous observer.
1397
+ *
1398
+ * @returns {void}
1399
+ */
1400
+ function configureMonthHeaderOffsets() {
1401
+ monthHeaderResizeObserver?.disconnect();
1402
+ if (!calendarRoot.value || typeof ResizeObserver === 'undefined') return;
1403
+ monthHeaderResizeObserver = new ResizeObserver(updateMonthHeaderOffsets);
1404
+ for (const header of calendarRoot.value.querySelectorAll('[data-month-heading]')) {
1405
+ monthHeaderResizeObserver.observe(header);
1406
+ }
1407
+ }
1408
+
1409
+ /**
1410
+ * Stores each heading's actual height on its section for sibling sticky rows.
1411
+ *
1412
+ * @param {ResizeObserverEntry[]} entries Month headings whose sizes changed.
1413
+ * @returns {void}
1414
+ */
1415
+ function updateMonthHeaderOffsets(entries) {
1416
+ for (const { target } of entries) {
1417
+ target.parentElement?.style.setProperty('--dom-month-calendar-month-heading-height', `${target.getBoundingClientRect().height}px`);
1418
+ }
1419
+ }
1420
+
1357
1421
  /**
1358
1422
  * Attaches active-month tracking to the nearest parent scrollport when continuous mode is externally scrolled.
1359
1423
  *
@@ -1437,7 +1501,8 @@ function updateActiveContinuousMonthForScrollHost(scrollHost, scope) {
1437
1501
 
1438
1502
  const hostTop = scrollHost === window ? 0 : scrollHost.getBoundingClientRect().top;
1439
1503
  const header = scope.querySelector('[data-continuous-day-headers]');
1440
- const headerHeight = header instanceof HTMLElement ? header.getBoundingClientRect().height : 0;
1504
+ const stickyHeader = header instanceof HTMLElement && resolvedStickyDayHeaders.value && getComputedStyle(header).display !== 'none';
1505
+ const headerHeight = stickyHeader ? header.getBoundingClientRect().height : 0;
1441
1506
  const activationTop = hostTop + headerHeight + 1;
1442
1507
  const activeRow = scopedRows.reduce((current, row) => {
1443
1508
  if (!(row instanceof HTMLElement)) return current;
@@ -1484,6 +1549,7 @@ function onDayClick(cell, event) {
1484
1549
  >
1485
1550
  <div
1486
1551
  v-if="section.continuous && stickyMonthHeaders"
1552
+ data-month-heading
1487
1553
  class="dom-month-calendar__continuous-mobile-header sticky top-0 z-30 mb-3 items-center justify-between gap-3 border-b border-border bg-canvas/95 py-3 shadow-sm backdrop-blur"
1488
1554
  >
1489
1555
  <span class="text-[11px] font-semibold uppercase tracking-wide text-muted-fg">Month</span>
@@ -1491,8 +1557,9 @@ function onDayClick(cell, event) {
1491
1557
  </div>
1492
1558
  <header
1493
1559
  v-if="!section.continuous"
1560
+ data-month-heading
1494
1561
  class="mb-4 flex flex-wrap items-end justify-between gap-3"
1495
- :class="stickyMonthHeaders && 'sticky top-0 z-20 border-b border-border bg-canvas/95 py-3 shadow-sm backdrop-blur'"
1562
+ :class="stickyMonthHeaders && 'sticky top-0 z-30 border-b border-border bg-canvas/95 py-3 shadow-sm backdrop-blur'"
1496
1563
  >
1497
1564
  <h2 :id="section.id" class="text-3xl font-bold tracking-tight text-canvas-fg">{{ section.label }}</h2>
1498
1565
  <div class="text-sm font-medium text-muted-fg">
@@ -1508,13 +1575,14 @@ function onDayClick(cell, event) {
1508
1575
  </header>
1509
1576
  <h2 v-else :id="section.id" class="sr-only">{{ section.label }}</h2>
1510
1577
 
1578
+ <!-- Parent-scrolled grids use clip to round resting corners without trapping sticky headers. -->
1511
1579
  <div
1512
1580
  class="rounded-2xl border border-border bg-canvas"
1513
1581
  :data-continuous-section="section.continuous ? '' : undefined"
1514
1582
  :class="[
1515
1583
  section.continuous && containedContinuousScroll && 'max-h-[70vh] overflow-auto overscroll-contain',
1516
- section.continuous && !containedContinuousScroll && 'overflow-x-auto md:overflow-visible',
1517
- !section.continuous && 'overflow-x-auto',
1584
+ section.continuous && !containedContinuousScroll && (resolvedStickyDayHeaders ? 'overflow-clip' : 'overflow-x-auto md:overflow-clip'),
1585
+ !section.continuous && (resolvedStickyDayHeaders ? 'overflow-clip' : 'overflow-x-auto'),
1518
1586
  ]"
1519
1587
  @scroll.passive="onSectionScroll(section, $event)"
1520
1588
  >
@@ -1530,8 +1598,9 @@ function onDayClick(cell, event) {
1530
1598
  :data-continuous-day-headers="section.continuous ? '' : undefined"
1531
1599
  class="dom-month-calendar__day-headers grid border-b border-border"
1532
1600
  :class="[
1533
- section.continuous ? 'sticky top-0 z-20 bg-secondary shadow-sm' : 'bg-secondary/30',
1601
+ resolvedStickyDayHeaders ? 'sticky z-20 bg-secondary shadow-sm' : 'bg-secondary/30',
1534
1602
  ]"
1603
+ :style="resolvedStickyDayHeaders ? { top: stickyMonthHeaders && !(section.continuous && containedContinuousScroll) ? 'var(--dom-month-calendar-month-heading-height, 0px)' : '0px' } : undefined"
1535
1604
  >
1536
1605
  <div
1537
1606
  v-if="hasRowRail"
@@ -1612,7 +1681,7 @@ function onDayClick(cell, event) {
1612
1681
  clickable && !cell.disabled && hoverableDays && 'hover:bg-secondary/50',
1613
1682
  clickable && cell.disabled && 'cursor-not-allowed opacity-55',
1614
1683
  !clickable && 'cursor-default',
1615
- cell.today && !cell.hidden && 'ring-2 ring-inset ring-ring/40',
1684
+ cell.today && !cell.hidden && todayIndicator === 'cell' && 'ring-2 ring-inset ring-ring/40',
1616
1685
  dayClassFor(cell),
1617
1686
  dragAndDrop && dragState.active && canDropOnCell(cell) && !isDropTarget(cell) && 'bg-primary/5',
1618
1687
  dragAndDrop && isDropTarget(cell) && 'bg-primary/10 ring-2 ring-inset ring-ring/60',
@@ -1627,9 +1696,15 @@ function onDayClick(cell, event) {
1627
1696
  @drop="onDayDrop(cell, $event)"
1628
1697
  >
1629
1698
  <template v-if="!cell.hidden">
1630
- <div class="flex items-start justify-between gap-2">
1631
- <span class="text-base font-bold leading-none tabular-nums">{{ cell.day }}</span>
1632
- <span class="text-base lowercase leading-none tabular-nums">{{ cell.weekday }}</span>
1699
+ <div class="flex items-center justify-between gap-2">
1700
+ <span
1701
+ class="text-base font-bold leading-none tabular-nums"
1702
+ :class="[
1703
+ todayIndicator === 'circle' && 'inline-flex size-7 shrink-0 items-center justify-center rounded-full',
1704
+ cell.today && todayIndicator === 'circle' && resolveDayClass(todayDateClass, cell),
1705
+ ]"
1706
+ >{{ cell.day }}</span>
1707
+ <span v-if="showDayNames" class="text-base lowercase leading-none tabular-nums">{{ cell.weekday }}</span>
1633
1708
  </div>
1634
1709
  <div class="mt-3 min-h-0 flex-1 h-full">
1635
1710
  <slot