@nomideusz/svelte-calendar 0.5.2 → 0.6.3

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 (49) hide show
  1. package/README.md +408 -178
  2. package/dist/adapters/memory.d.ts +7 -9
  3. package/dist/adapters/memory.js +8 -23
  4. package/dist/adapters/recurring.d.ts +8 -6
  5. package/dist/adapters/recurring.js +28 -30
  6. package/dist/calendar/Calendar.svelte +776 -356
  7. package/dist/calendar/Calendar.svelte.d.ts +57 -6
  8. package/dist/core/index.d.ts +2 -2
  9. package/dist/core/index.js +1 -1
  10. package/dist/core/locale.js +2 -2
  11. package/dist/core/palette.d.ts +5 -0
  12. package/dist/core/palette.js +8 -0
  13. package/dist/core/types.d.ts +14 -0
  14. package/dist/engine/event-store.svelte.d.ts +0 -17
  15. package/dist/engine/event-store.svelte.js +30 -17
  16. package/dist/engine/index.d.ts +1 -1
  17. package/dist/engine/view-state.svelte.d.ts +10 -5
  18. package/dist/engine/view-state.svelte.js +32 -16
  19. package/dist/index.d.ts +7 -7
  20. package/dist/index.js +3 -4
  21. package/dist/primitives/DayHeader.svelte +103 -103
  22. package/dist/primitives/EmptySlot.svelte +105 -105
  23. package/dist/primitives/EventBlock.svelte +312 -312
  24. package/dist/primitives/NowIndicator.svelte +178 -178
  25. package/dist/primitives/TimeGutter.svelte +104 -104
  26. package/dist/theme/auto.d.ts +53 -0
  27. package/dist/theme/auto.js +534 -0
  28. package/dist/theme/index.d.ts +3 -1
  29. package/dist/theme/index.js +3 -1
  30. package/dist/theme/presets.d.ts +20 -6
  31. package/dist/theme/presets.js +55 -42
  32. package/dist/views/agenda/AgendaDay.svelte +1073 -976
  33. package/dist/views/agenda/AgendaWeek.svelte +934 -775
  34. package/dist/views/agenda/index.d.ts +0 -2
  35. package/dist/views/agenda/index.js +0 -2
  36. package/dist/views/index.d.ts +3 -2
  37. package/dist/views/index.js +3 -2
  38. package/dist/views/mobile/Mobile.svelte +17 -0
  39. package/dist/views/mobile/Mobile.svelte.d.ts +7 -0
  40. package/dist/views/mobile/MobileDay.svelte +702 -0
  41. package/dist/views/mobile/MobileDay.svelte.d.ts +20 -0
  42. package/dist/views/mobile/MobileWeek.svelte +461 -0
  43. package/dist/views/mobile/MobileWeek.svelte.d.ts +20 -0
  44. package/dist/views/mobile/index.d.ts +1 -0
  45. package/dist/views/mobile/index.js +1 -0
  46. package/dist/views/planner/PlannerDay.svelte +1129 -957
  47. package/dist/views/planner/PlannerWeek.svelte +949 -840
  48. package/dist/widget/CalendarWidget.svelte +142 -157
  49. package/package.json +2 -1
@@ -0,0 +1,702 @@
1
+ <!--
2
+ MobileDay — touch-first single-day view.
3
+
4
+ Vertical time grid (hours top → bottom). Scrolls vertically.
5
+ Large touch targets, swipe left/right to change day.
6
+ Events positioned absolutely within hour lanes.
7
+ -->
8
+ <script lang="ts">
9
+ import { getContext, onMount, tick, untrack } from 'svelte';
10
+ import { createClock } from '../../core/clock.svelte.js';
11
+ import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
12
+ import type { DragState } from '../../engine/drag.svelte.js';
13
+ import type { ViewState } from '../../engine/view-state.svelte.js';
14
+ import { DAY_MS, HOUR_MS, sod, isAllDay, isMultiDay, segmentForDay } from '../../core/time.js';
15
+ import type { DaySegment } from '../../core/time.js';
16
+ import { fmtH, fmtTime, getLabels } from '../../core/locale.js';
17
+
18
+ const L = $derived(getLabels());
19
+
20
+ interface Props {
21
+ height?: number | null;
22
+ events?: TimelineEvent[];
23
+ style?: string;
24
+ focusDate?: Date;
25
+ locale?: string;
26
+ oneventclick?: (event: TimelineEvent) => void;
27
+ oneventcreate?: (range: { start: Date; end: Date }) => void;
28
+ selectedEventId?: string | null;
29
+ readOnly?: boolean;
30
+ visibleHours?: [number, number];
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ let {
35
+ height = null,
36
+ events = [],
37
+ style = '',
38
+ locale,
39
+ focusDate,
40
+ oneventclick,
41
+ oneventcreate,
42
+ selectedEventId = null,
43
+ readOnly = false,
44
+ visibleHours,
45
+ }: Props = $props();
46
+
47
+ // ── Context ────────────────────────────────────────
48
+ const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
49
+ const loadRangeCtx = getContext<{ current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void }>('calendar:loadRange') as { current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void } | undefined;
50
+ const blockedSlotsCtx = getContext<{ current: BlockedSlot[] | undefined }>('calendar:blockedSlots') as { current: BlockedSlot[] | undefined } | undefined;
51
+ const minDurationCtx = getContext<{ current: number | undefined }>('calendar:minDuration') as { current: number | undefined } | undefined;
52
+ const callbacksCtx = getContext<{ oneventhover?: (event: TimelineEvent) => void }>('calendar:callbacks') as { oneventhover?: (event: TimelineEvent) => void } | undefined;
53
+ const disabledDatesCtx = getContext<{ current: Date[] | undefined }>('calendar:disabledDates') as { current: Date[] | undefined } | undefined;
54
+ const autoHeightCtx = getContext<{ current: boolean }>('calendar:autoHeight') as { current: boolean } | undefined;
55
+
56
+ const blockedSlots = $derived(blockedSlotsCtx?.current);
57
+ const minDuration = $derived(minDurationCtx?.current);
58
+ const autoHeight = $derived(autoHeightCtx?.current ?? false);
59
+ const oneventhover = $derived(callbacksCtx?.oneventhover);
60
+ const disabledDates = $derived(disabledDatesCtx?.current);
61
+ const disabledSet = $derived(new Set(disabledDates?.map(d => sod(d.getTime())) ?? []));
62
+
63
+ const clock = createClock();
64
+
65
+ // ── Config ─────────────────────────────────────────
66
+ const HOUR_HEIGHT = 64;
67
+ const GUTTER_W = 40;
68
+ const startHour = $derived(visibleHours?.[0] ?? 0);
69
+ const endHour = $derived(visibleHours?.[1] ?? 24);
70
+ const hourCount = $derived(Math.max(1, endHour - startHour));
71
+ const gridHeight = $derived(hourCount * HOUR_HEIGHT);
72
+
73
+ // ── Day state ──────────────────────────────────────
74
+ const dayMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
75
+ const dayEnd = $derived(dayMs + DAY_MS);
76
+ const isToday = $derived(dayMs === clock.today);
77
+ const isPast = $derived(dayMs < clock.today);
78
+ const isDisabled = $derived(disabledSet.has(dayMs));
79
+
80
+ // ── Load range ─────────────────────────────────────
81
+ $effect(() => {
82
+ if (!loadRangeCtx) return;
83
+ const rangeStart = new Date(dayMs - 2 * DAY_MS);
84
+ const rangeEnd = new Date(dayMs + 3 * DAY_MS);
85
+ loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
86
+ return () => loadRangeCtx.set(null);
87
+ });
88
+
89
+ // ── Events partition ───────────────────────────────
90
+ const timedEvents = $derived(
91
+ events
92
+ .filter(ev => !isAllDay(ev) && !isMultiDay(ev) && ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs)
93
+ .sort((a, b) => a.start.getTime() - b.start.getTime())
94
+ );
95
+
96
+ const allDayEvents = $derived.by(() => {
97
+ const segs: DaySegment[] = [];
98
+ for (const ev of events) {
99
+ if (!isAllDay(ev) && !isMultiDay(ev)) continue;
100
+ const seg = segmentForDay(ev, dayMs);
101
+ if (seg) segs.push(seg);
102
+ }
103
+ return segs;
104
+ });
105
+
106
+ // ── Positioned events ──────────────────────────────
107
+ interface PosEvent {
108
+ ev: TimelineEvent;
109
+ top: number;
110
+ height: number;
111
+ left: string;
112
+ width: string;
113
+ isCurrent: boolean;
114
+ isNext: boolean;
115
+ col: number;
116
+ totalCols: number;
117
+ }
118
+
119
+ const positionedEvents = $derived.by(() => {
120
+ const now = clock.tick;
121
+ const sorted = [...timedEvents];
122
+
123
+ // Find the next upcoming event today
124
+ let nextEventId: string | null = null;
125
+ if (isToday) {
126
+ for (const ev of [...sorted].sort((a, b) => a.start.getTime() - b.start.getTime())) {
127
+ const s = ev.start.getTime();
128
+ if (s > now) { nextEventId = ev.id; break; }
129
+ }
130
+ }
131
+
132
+ // Overlap grouping
133
+ const infos = sorted.map(ev => {
134
+ const sMs = Math.max(ev.start.getTime(), dayMs + startHour * HOUR_MS);
135
+ const eMs = Math.min(ev.end.getTime(), dayMs + endHour * HOUR_MS);
136
+ const topH = (sMs - dayMs) / HOUR_MS - startHour;
137
+ const botH = (eMs - dayMs) / HOUR_MS - startHour;
138
+ return {
139
+ ev,
140
+ top: topH * HOUR_HEIGHT,
141
+ height: Math.max(24, (botH - topH) * HOUR_HEIGHT),
142
+ isCurrent: ev.start.getTime() <= now && ev.end.getTime() > now,
143
+ isNext: ev.id === nextEventId,
144
+ startMs: sMs,
145
+ endMs: eMs,
146
+ col: 0,
147
+ totalCols: 1,
148
+ };
149
+ });
150
+
151
+ // Assign columns for overlapping events
152
+ const par = infos.map((_, i) => i);
153
+ function find(i: number): number {
154
+ while (par[i] !== i) { par[i] = par[par[i]]; i = par[i]; }
155
+ return i;
156
+ }
157
+ for (let i = 0; i < infos.length; i++) {
158
+ for (let j = i + 1; j < infos.length; j++) {
159
+ if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
160
+ else break;
161
+ }
162
+ }
163
+
164
+ const groups = new Map<number, number[]>();
165
+ for (let i = 0; i < infos.length; i++) {
166
+ const root = find(i);
167
+ if (!groups.has(root)) groups.set(root, []);
168
+ groups.get(root)!.push(i);
169
+ }
170
+
171
+ for (const [, indices] of groups) {
172
+ const rows: number[] = [];
173
+ for (const idx of indices) {
174
+ let row = 0;
175
+ for (let r = 0; r < rows.length; r++) {
176
+ if (rows[r] <= infos[idx].startMs) { row = r; rows[r] = infos[idx].endMs; break; }
177
+ row = r + 1;
178
+ }
179
+ if (row >= rows.length) rows.push(infos[idx].endMs);
180
+ infos[idx].col = row;
181
+ }
182
+ for (const idx of indices) infos[idx].totalCols = rows.length;
183
+ }
184
+
185
+ return infos.map(info => ({
186
+ ev: info.ev,
187
+ top: info.top,
188
+ height: info.height,
189
+ left: `calc(${GUTTER_W}px + ${(info.col / info.totalCols) * 100}% * (1 - ${GUTTER_W} / var(--mb-grid-w, 300)))`,
190
+ width: `calc((100% - ${GUTTER_W}px) / ${info.totalCols} - 2px)`,
191
+ isCurrent: info.isCurrent,
192
+ isNext: info.isNext,
193
+ col: info.col,
194
+ totalCols: info.totalCols,
195
+ })) as PosEvent[];
196
+ });
197
+
198
+ // ── Now indicator ──────────────────────────────────
199
+ const nowOffset = $derived.by(() => {
200
+ if (!isToday) return -1;
201
+ const h = (clock.tick - dayMs) / HOUR_MS - startHour;
202
+ if (h < 0 || h > hourCount) return -1;
203
+ return h * HOUR_HEIGHT;
204
+ });
205
+
206
+ // ── Blocked slot check ─────────────────────────────
207
+ function isBlockedAt(hour: number): boolean {
208
+ if (!blockedSlots?.length) return false;
209
+ const jsDay = new Date(dayMs).getDay();
210
+ const isoDay = jsDay === 0 ? 7 : jsDay;
211
+ return blockedSlots.some(slot => {
212
+ if (slot.day && slot.day !== isoDay) return false;
213
+ return hour >= slot.start && hour < slot.end;
214
+ });
215
+ }
216
+
217
+ // ── Touch swipe navigation ─────────────────────────
218
+ let el: HTMLDivElement;
219
+ let touchStartX = 0;
220
+ let touchStartY = 0;
221
+ let swiping = false;
222
+ let swipeOffset = $state(0);
223
+ const SWIPE_THRESHOLD = 50;
224
+
225
+ function onTouchStart(e: TouchEvent) {
226
+ const t = e.touches[0];
227
+ touchStartX = t.clientX;
228
+ touchStartY = t.clientY;
229
+ swiping = true;
230
+ swipeOffset = 0;
231
+ }
232
+
233
+ function onTouchMove(e: TouchEvent) {
234
+ if (!swiping) return;
235
+ const t = e.touches[0];
236
+ const dx = t.clientX - touchStartX;
237
+ const dy = t.clientY - touchStartY;
238
+ // Only swipe if horizontal movement dominates
239
+ if (Math.abs(dy) > Math.abs(dx) * 0.8) { swiping = false; return; }
240
+ swipeOffset = dx;
241
+ }
242
+
243
+ function onTouchEnd() {
244
+ if (!swiping) { swipeOffset = 0; return; }
245
+ if (Math.abs(swipeOffset) > SWIPE_THRESHOLD) {
246
+ if (swipeOffset > 0) {
247
+ viewState?.prev();
248
+ } else {
249
+ viewState?.next();
250
+ }
251
+ }
252
+ swipeOffset = 0;
253
+ swiping = false;
254
+ }
255
+
256
+ // ── Click-to-create ────────────────────────────────
257
+ function handleGridClick(e: MouseEvent) {
258
+ if (!oneventcreate || readOnly || isDisabled) return;
259
+ if ((e.target as HTMLElement).closest('.mb-event')) return;
260
+ const grid = (e.currentTarget as HTMLElement);
261
+ const rect = grid.getBoundingClientRect();
262
+ const y = e.clientY - rect.top + grid.scrollTop;
263
+ const hour = startHour + y / HOUR_HEIGHT;
264
+ if (isBlockedAt(hour)) return;
265
+ const snapHour = Math.floor(hour);
266
+ const durMin = minDuration ? Math.max(60, minDuration) : 60;
267
+ const start = new Date(dayMs + snapHour * HOUR_MS);
268
+ const end = new Date(start.getTime() + durMin * 60_000);
269
+ oneventcreate({ start, end });
270
+ }
271
+
272
+ // ── Auto-scroll to now ─────────────────────────────
273
+ let gridEl: HTMLDivElement;
274
+ onMount(() => {
275
+ if (nowOffset > 0 && gridEl) {
276
+ const scrollTarget = Math.max(0, nowOffset - 120);
277
+ gridEl.scrollTop = scrollTarget;
278
+ }
279
+ });
280
+ </script>
281
+
282
+ <div
283
+ class="mb"
284
+ class:mb--auto={autoHeight}
285
+ style={style || undefined}
286
+ style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')}
287
+ role="region"
288
+ aria-label={L.dayPlanner}
289
+ ontouchstart={onTouchStart}
290
+ ontouchmove={onTouchMove}
291
+ ontouchend={onTouchEnd}
292
+ >
293
+ <!-- All-day events bar -->
294
+ {#if allDayEvents.length > 0}
295
+ <div class="mb-allday">
296
+ {#each allDayEvents.slice(0, 3) as seg (seg.ev.id)}
297
+ <button
298
+ class="mb-allday-chip"
299
+ class:mb-allday-chip--selected={selectedEventId === seg.ev.id}
300
+ style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
301
+ onclick={() => oneventclick?.(seg.ev)}
302
+ >
303
+ <span class="mb-allday-dot"></span>
304
+ <span class="mb-allday-title">{seg.ev.title}</span>
305
+ {#if seg.totalDays > 1}
306
+ <span class="mb-allday-span">{seg.dayIndex}/{seg.totalDays}</span>
307
+ {/if}
308
+ </button>
309
+ {/each}
310
+ {#if allDayEvents.length > 3}
311
+ <span class="mb-allday-more">{L.nMore(allDayEvents.length - 3)}</span>
312
+ {/if}
313
+ </div>
314
+ {/if}
315
+
316
+ <!-- Scrollable time grid -->
317
+ <div
318
+ class="mb-grid"
319
+ bind:this={gridEl}
320
+ onclick={handleGridClick}
321
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleGridClick(e as unknown as MouseEvent); }}
322
+ role="grid"
323
+ tabindex="-1"
324
+ style:--mb-grid-w="300"
325
+ >
326
+ <div class="mb-grid-inner" style:height="{gridHeight}px">
327
+ <!-- Hour lanes -->
328
+ {#each { length: hourCount } as _, h}
329
+ {@const hour = startHour + h}
330
+ {@const blocked = isBlockedAt(hour)}
331
+ <div
332
+ class="mb-hour"
333
+ class:mb-hour--blocked={blocked}
334
+ style:top="{h * HOUR_HEIGHT}px"
335
+ style:height="{HOUR_HEIGHT}px"
336
+ >
337
+ <div class="mb-hour-label">{fmtH(hour, locale)}</div>
338
+ <div class="mb-hour-line"></div>
339
+ {#if blocked && blockedSlots}
340
+ {@const slot = blockedSlots.find(s => (!s.day || s.day === (new Date(dayMs).getDay() === 0 ? 7 : new Date(dayMs).getDay())) && hour >= s.start && hour < s.end)}
341
+ {#if slot?.label}
342
+ <span class="mb-blocked-label">{slot.label}</span>
343
+ {/if}
344
+ {/if}
345
+ </div>
346
+ {/each}
347
+
348
+ <!-- Now line -->
349
+ {#if nowOffset >= 0}
350
+ <div class="mb-now" style:top="{nowOffset}px">
351
+ <span class="mb-now-label">{clock.hm}</span>
352
+ <div class="mb-now-line"></div>
353
+ </div>
354
+ {/if}
355
+
356
+ <!-- Events -->
357
+ {#each positionedEvents as p (p.ev.id)}
358
+ <button
359
+ class="mb-event"
360
+ class:mb-event--selected={selectedEventId === p.ev.id}
361
+ class:mb-event--current={p.isCurrent}
362
+ class:mb-event--next={p.isNext}
363
+ style:top="{p.top}px"
364
+ style:height="{p.height}px"
365
+ style:left={p.left}
366
+ style:width={p.width}
367
+ style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
368
+ onclick={(e) => { e.stopPropagation(); oneventclick?.(p.ev); }}
369
+ onpointerenter={() => oneventhover?.(p.ev)}
370
+ aria-label="{p.ev.title}{p.isCurrent ? `, ${L.inProgress}` : ''}{p.isNext ? `, ${L.upNext}` : ''}"
371
+ >
372
+ <div class="mb-ev-stripe"></div>
373
+ <div class="mb-ev-body">
374
+ <span class="mb-ev-title">{p.ev.title}</span>
375
+ {#if p.height > 32}
376
+ <span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
377
+ {/if}
378
+ {#if p.ev.subtitle && p.height > 48}
379
+ <span class="mb-ev-sub">{p.ev.subtitle}</span>
380
+ {/if}
381
+ {#if p.ev.tags?.length && p.height > 56}
382
+ <div class="mb-ev-tags">
383
+ {#each p.ev.tags as tag}
384
+ <span class="mb-ev-tag">{tag}</span>
385
+ {/each}
386
+ </div>
387
+ {/if}
388
+ </div>
389
+ {#if p.isCurrent}
390
+ <span class="mb-ev-live"></span>
391
+ {:else if p.isNext}
392
+ <span class="mb-ev-next-badge">{L.upNext}</span>
393
+ {/if}
394
+ </button>
395
+ {/each}
396
+ </div>
397
+ </div>
398
+ </div>
399
+
400
+ <style>
401
+ /* ─── Container ──────────────────────────────────── */
402
+ .mb {
403
+ position: relative;
404
+ display: flex;
405
+ flex-direction: column;
406
+ user-select: none;
407
+ font-variant-numeric: tabular-nums;
408
+ overflow: hidden;
409
+ background: var(--dt-bg, #fff);
410
+ -webkit-tap-highlight-color: transparent;
411
+ }
412
+ .mb--auto { overflow: visible; }
413
+
414
+ /* ─── All-day bar ────────────────────────────────── */
415
+ .mb-allday {
416
+ display: flex;
417
+ gap: 4px;
418
+ padding: 4px 8px;
419
+ overflow-x: auto;
420
+ scrollbar-width: none;
421
+ border-bottom: 1px solid var(--dt-border, rgba(0, 0, 0, 0.06));
422
+ flex-shrink: 0;
423
+ align-items: center;
424
+ }
425
+ .mb-allday::-webkit-scrollbar { display: none; }
426
+
427
+ .mb-allday-chip {
428
+ display: flex;
429
+ align-items: center;
430
+ gap: 4px;
431
+ padding: 4px 8px;
432
+ border-radius: 5px;
433
+ background: color-mix(in srgb, var(--ev-color) 12%, var(--dt-surface, #f9fafb));
434
+ border: none;
435
+ cursor: pointer;
436
+ flex-shrink: 0;
437
+ transition: background 120ms;
438
+ -webkit-tap-highlight-color: transparent;
439
+ max-width: 160px;
440
+ }
441
+ .mb-allday-chip:active {
442
+ background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #f9fafb));
443
+ }
444
+ .mb-allday-chip--selected {
445
+ box-shadow: 0 0 0 1.5px var(--ev-color);
446
+ }
447
+
448
+ .mb-allday-dot {
449
+ width: 6px;
450
+ height: 6px;
451
+ border-radius: 50%;
452
+ background: var(--ev-color);
453
+ flex-shrink: 0;
454
+ }
455
+
456
+ .mb-allday-title {
457
+ font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
458
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
459
+ white-space: nowrap;
460
+ max-width: 100px;
461
+ overflow: hidden;
462
+ text-overflow: ellipsis;
463
+ }
464
+
465
+ .mb-allday-span {
466
+ font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
467
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
468
+ }
469
+
470
+ .mb-allday-more {
471
+ font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
472
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
473
+ white-space: nowrap;
474
+ flex-shrink: 0;
475
+ padding: 0 4px;
476
+ }
477
+
478
+ /* ─── Grid ───────────────────────────────────────── */
479
+ .mb-grid {
480
+ flex: 1;
481
+ overflow-y: auto;
482
+ overflow-x: hidden;
483
+ -webkit-overflow-scrolling: touch;
484
+ scrollbar-width: thin;
485
+ scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.08)) transparent;
486
+ position: relative;
487
+ padding-top: 8px;
488
+ }
489
+ .mb--auto .mb-grid { overflow-y: visible; }
490
+
491
+ .mb-grid-inner {
492
+ position: relative;
493
+ min-width: 100%;
494
+ }
495
+
496
+ /* ─── Hour row ───────────────────────────────────── */
497
+ .mb-hour {
498
+ position: absolute;
499
+ left: 0;
500
+ right: 0;
501
+ display: flex;
502
+ align-items: flex-start;
503
+ }
504
+
505
+ .mb-hour-label {
506
+ width: 40px;
507
+ flex-shrink: 0;
508
+ font: 500 11px/1 var(--dt-mono, ui-monospace, monospace);
509
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
510
+ text-align: right;
511
+ padding-right: 8px;
512
+ padding-top: 0;
513
+ position: relative;
514
+ top: -6px;
515
+ }
516
+
517
+ .mb-hour-line {
518
+ flex: 1;
519
+ height: 1px;
520
+ background: var(--dt-border, rgba(0, 0, 0, 0.08));
521
+ }
522
+
523
+ .mb-hour--blocked {
524
+ background: repeating-linear-gradient(
525
+ -45deg,
526
+ color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 3%, transparent),
527
+ color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 3%, transparent) 4px,
528
+ transparent 4px,
529
+ transparent 8px
530
+ );
531
+ }
532
+
533
+ .mb-blocked-label {
534
+ position: absolute;
535
+ left: 44px;
536
+ top: 50%;
537
+ transform: translateY(-50%);
538
+ font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
539
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
540
+ text-transform: uppercase;
541
+ letter-spacing: 0.04em;
542
+ }
543
+
544
+ /* ─── Now line ───────────────────────────────────── */
545
+ .mb-now {
546
+ position: absolute;
547
+ left: 0;
548
+ right: 0;
549
+ z-index: 10;
550
+ display: flex;
551
+ align-items: center;
552
+ pointer-events: none;
553
+ }
554
+
555
+ .mb-now-label {
556
+ width: 40px;
557
+ flex-shrink: 0;
558
+ text-align: right;
559
+ padding-right: 6px;
560
+ font: 700 10px/1 var(--dt-mono, ui-monospace, monospace);
561
+ color: var(--dt-accent, #2563eb);
562
+ }
563
+
564
+ .mb-now-line {
565
+ flex: 1;
566
+ height: 2px;
567
+ background: var(--dt-accent, #2563eb);
568
+ box-shadow: 0 0 6px var(--dt-glow, rgba(37, 99, 235, 0.25));
569
+ position: relative;
570
+ }
571
+
572
+ .mb-now-line::before {
573
+ content: '';
574
+ position: absolute;
575
+ left: -4px;
576
+ top: -4px;
577
+ width: 10px;
578
+ height: 10px;
579
+ border-radius: 50%;
580
+ background: var(--dt-accent, #2563eb);
581
+ }
582
+
583
+ /* ─── Events ─────────────────────────────────────── */
584
+ .mb-event {
585
+ position: absolute;
586
+ z-index: 5;
587
+ border-radius: 8px;
588
+ cursor: pointer;
589
+ background: color-mix(in srgb, var(--ev-color) 12%, var(--dt-surface, #f9fafb));
590
+ border: none;
591
+ display: flex;
592
+ align-items: stretch;
593
+ overflow: hidden;
594
+ transition: box-shadow 120ms, background 120ms;
595
+ text-align: left;
596
+ padding: 0;
597
+ -webkit-tap-highlight-color: transparent;
598
+ min-height: 24px;
599
+ }
600
+ .mb-event:active {
601
+ background: color-mix(in srgb, var(--ev-color) 20%, var(--dt-surface, #f9fafb));
602
+ }
603
+ .mb-event--selected {
604
+ box-shadow: 0 0 0 2px var(--ev-color),
605
+ 0 2px 12px color-mix(in srgb, var(--ev-color) 25%, transparent);
606
+ }
607
+ .mb-event--current {
608
+ background: color-mix(in srgb, var(--ev-color) 18%, var(--dt-surface, #f9fafb));
609
+ }
610
+ .mb-event--next {
611
+ background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, #f9fafb));
612
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
613
+ }
614
+
615
+ .mb-ev-stripe {
616
+ width: 4px;
617
+ background: var(--ev-color);
618
+ flex-shrink: 0;
619
+ border-radius: 8px 0 0 8px;
620
+ }
621
+
622
+ .mb-ev-body {
623
+ flex: 1;
624
+ min-width: 0;
625
+ padding: 4px 8px;
626
+ display: flex;
627
+ flex-direction: column;
628
+ gap: 1px;
629
+ justify-content: center;
630
+ }
631
+
632
+ .mb-ev-title {
633
+ font: 600 13px/1.2 var(--dt-sans, system-ui, sans-serif);
634
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
635
+ white-space: nowrap;
636
+ overflow: hidden;
637
+ text-overflow: ellipsis;
638
+ }
639
+
640
+ .mb-ev-time {
641
+ font: 400 11px/1 var(--dt-mono, ui-monospace, monospace);
642
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
643
+ }
644
+
645
+ .mb-ev-sub {
646
+ font: 400 11px/1.1 var(--dt-sans, system-ui, sans-serif);
647
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
648
+ white-space: nowrap;
649
+ overflow: hidden;
650
+ text-overflow: ellipsis;
651
+ }
652
+
653
+ .mb-ev-tags {
654
+ display: flex;
655
+ gap: 4px;
656
+ margin-top: 2px;
657
+ }
658
+
659
+ .mb-ev-tag {
660
+ font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
661
+ color: var(--ev-color, var(--dt-accent));
662
+ background: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 15%, transparent);
663
+ padding: 2px 5px;
664
+ border-radius: 3px;
665
+ white-space: nowrap;
666
+ }
667
+
668
+ .mb-ev-live {
669
+ position: absolute;
670
+ top: 6px;
671
+ right: 6px;
672
+ width: 7px;
673
+ height: 7px;
674
+ border-radius: 50%;
675
+ background: var(--ev-color, var(--dt-accent));
676
+ animation: mb-pulse 2s ease-in-out infinite;
677
+ }
678
+ .mb-ev-next-badge {
679
+ position: absolute;
680
+ top: 4px;
681
+ right: 4px;
682
+ font: 600 8px/1 var(--dt-sans, system-ui, sans-serif);
683
+ text-transform: uppercase;
684
+ letter-spacing: 0.06em;
685
+ color: var(--ev-color, var(--dt-accent));
686
+ background: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 15%, transparent);
687
+ padding: 2px 5px;
688
+ border-radius: 3px;
689
+ white-space: nowrap;
690
+ }
691
+
692
+ @keyframes mb-pulse {
693
+ 0%, 100% { opacity: 1; }
694
+ 50% { opacity: 0.4; }
695
+ }
696
+
697
+ /* ─── Focus ──────────────────────────────────────── */
698
+ .mb-event:focus-visible {
699
+ outline: 2px solid var(--dt-accent, #2563eb);
700
+ outline-offset: 2px;
701
+ }
702
+ </style>