@nomideusz/svelte-calendar 0.10.0 → 0.11.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.
@@ -97,12 +97,16 @@ function handleEventClick(ev) {
97
97
  oneventclick?.(ev);
98
98
  }
99
99
  let containerWidth = $state(
100
- typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? MOBILE_BREAKPOINT - 1 : 0
100
+ typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? window.innerWidth : 0
101
101
  );
102
102
  const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
103
103
  const useMobile = $derived(
104
104
  mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp)
105
105
  );
106
+ const HEADER_STACK_BREAKPOINT = 520;
107
+ const stackHeader = $derived(
108
+ useMobile && containerWidth > 0 && containerWidth < HEADER_STACK_BREAKPOINT
109
+ );
106
110
  let calEl = $state();
107
111
  let probedTheme = $state("");
108
112
  const needsProbe = $derived(theme === auto && autoTheme !== false);
@@ -480,7 +484,8 @@ const navCtx = $derived({
480
484
 
481
485
  <!-- ─── Mobile header (flow layout, no absolute) ─── -->
482
486
  {:else if useMobile && (showNavigation || (showModePills && modes.length > 1) || dateLabel)}
483
- <div class="cal-m-hd">
487
+ {@const titleBelow = stackHeader && !!dateLabel}
488
+ <div class="cal-m-hd" class:cal-m-hd--stack={stackHeader} class:cal-m-hd--titled={titleBelow}>
484
489
  <div class="cal-m-left">
485
490
  {#if showModePills && modes.length > 1}
486
491
  <div class="cal-m-pills" role="radiogroup" aria-label={L.viewMode}>
@@ -500,7 +505,9 @@ const navCtx = $derived({
500
505
  {/if}
501
506
  </div>
502
507
 
503
- <span class="cal-m-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
508
+ {#if !titleBelow}
509
+ <span class="cal-m-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
510
+ {/if}
504
511
 
505
512
  <div class="cal-m-right">
506
513
  {#if navigationSnippet}
@@ -525,6 +532,11 @@ const navCtx = $derived({
525
532
  {/if}
526
533
  </div>
527
534
  </div>
535
+ {#if titleBelow}
536
+ <div class="cal-m-titlebar">
537
+ <span class="cal-m-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
538
+ </div>
539
+ {/if}
528
540
 
529
541
  <!-- ─── Desktop header ─── -->
530
542
  {:else if showNavigation || (showModePills && modes.length > 1) || dateLabel}
@@ -740,7 +752,9 @@ const navCtx = $derived({
740
752
  transition: background 100ms, color 100ms;
741
753
  }
742
754
 
743
- .cal-pill:hover {
755
+ /* :not(--active) — the hover rule otherwise outranks the active color,
756
+ and iOS keeps :hover stuck after a tap (dark text on the accent). */
757
+ .cal-pill:hover:not(.cal-pill--active) {
744
758
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
745
759
  }
746
760
 
@@ -806,6 +820,26 @@ const navCtx = $derived({
806
820
  min-height: 44px;
807
821
  }
808
822
 
823
+ /* Narrow containers: the date label moves to its own row (.cal-m-titlebar),
824
+ so the controls row spreads pills and nav to the edges. */
825
+ .cal-m-hd--stack {
826
+ justify-content: space-between;
827
+ }
828
+ .cal-m-hd--titled {
829
+ border-bottom: none;
830
+ padding-bottom: 2px;
831
+ }
832
+ .cal-m-titlebar {
833
+ display: flex;
834
+ justify-content: center;
835
+ padding: 0 8px 8px;
836
+ border-bottom: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
837
+ flex-shrink: 0;
838
+ }
839
+ .cal-m-titlebar .cal-m-title {
840
+ flex: 0 1 auto;
841
+ }
842
+
809
843
  .cal-m-left,
810
844
  .cal-m-right {
811
845
  display: flex;
@@ -867,7 +901,7 @@ const navCtx = $derived({
867
901
  transition: background 100ms, color 100ms;
868
902
  -webkit-tap-highlight-color: transparent;
869
903
  }
870
- .cal-m-pill:hover {
904
+ .cal-m-pill:hover:not(.cal-m-pill--active) {
871
905
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
872
906
  }
873
907
  .cal-m-pill--active {
@@ -162,15 +162,17 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
162
162
  <EventContent event={ev}>
163
163
  <span class="ag-compact-row-dot"></span>
164
164
  <span class="ag-compact-row-time">{fmt(ev.start)}</span>
165
- <span class="ag-compact-row-title">{ev.title}</span>
166
- {#if ev.subtitle}
167
- <span class="ag-compact-row-sub">{ev.subtitle}</span>
168
- {/if}
169
- {#if ev.tags?.length}
170
- {#each ev.tags as tag}
171
- <span class="ag-compact-row-tag">{tag}</span>
172
- {/each}
173
- {/if}
165
+ <div class="ag-compact-row-main">
166
+ <span class="ag-compact-row-title">{ev.title}</span>
167
+ {#if ev.subtitle}
168
+ <span class="ag-compact-row-sub">{ev.subtitle}</span>
169
+ {/if}
170
+ {#if ev.tags?.length}
171
+ {#each ev.tags as tag}
172
+ <span class="ag-compact-row-tag">{tag}</span>
173
+ {/each}
174
+ {/if}
175
+ </div>
174
176
  <span class="ag-compact-row-dur">{duration(ev)}</span>
175
177
  </EventContent>
176
178
  </button>
@@ -256,10 +258,12 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
256
258
  <EventContent event={ev}>
257
259
  <span class="ag-compact-row-dot"></span>
258
260
  <span class="ag-compact-row-time">{fmt(ev.start)}</span>
259
- <span class="ag-compact-row-title">{ev.title}</span>
260
- {#if ev.subtitle}
261
- <span class="ag-compact-row-sub">{ev.subtitle}</span>
262
- {/if}
261
+ <div class="ag-compact-row-main">
262
+ <span class="ag-compact-row-title">{ev.title}</span>
263
+ {#if ev.subtitle}
264
+ <span class="ag-compact-row-sub">{ev.subtitle}</span>
265
+ {/if}
266
+ </div>
263
267
  </EventContent>
264
268
  </button>
265
269
  {:else}
@@ -715,7 +719,11 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
715
719
  font-size: 16px;
716
720
  font-weight: 700;
717
721
  }
718
- .ag-card--plan .ag-card-sub {
722
+ /* Everything under the title aligns past the order number — the
723
+ subtitle, location, time and tags share one left edge. */
724
+ .ag-card--plan .ag-card-sub,
725
+ .ag-card--plan .ag-card-loc,
726
+ .ag-card--plan .ag-card-meta {
719
727
  padding-left: 22px;
720
728
  }
721
729
  .ag-card--plan .ag-card-tags {
@@ -1065,6 +1073,9 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
1065
1073
  border-radius: 6px;
1066
1074
  padding-left: 8px;
1067
1075
  padding-right: 8px;
1076
+ margin-left: -8px;
1077
+ margin-right: -8px;
1078
+ width: calc(100% + 16px);
1068
1079
  }
1069
1080
  .ag-log-check {
1070
1081
  font-size: 10px;
@@ -1126,8 +1137,13 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
1126
1137
  .ag-compact-row--selected {
1127
1138
  background: color-mix(in srgb, var(--ev-color) 10%, transparent);
1128
1139
  border-radius: 4px;
1140
+ /* Highlight gutter comes from negative margins so the row's content
1141
+ stays aligned with its unselected siblings (no tap-shift). */
1129
1142
  padding-left: 6px;
1130
1143
  padding-right: 6px;
1144
+ margin-left: -6px;
1145
+ margin-right: -6px;
1146
+ width: calc(100% + 12px);
1131
1147
  }
1132
1148
  .ag-compact-row:hover .ag-compact-row-title,
1133
1149
  .ag-compact-row:active .ag-compact-row-title { color: var(--dt-text); }
@@ -1156,11 +1172,43 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
1156
1172
  flex-shrink: 0;
1157
1173
  line-height: 1.4;
1158
1174
  }
1175
+ /* Title + subtitle + tags cluster. One line while it fits; on mobile the
1176
+ metadata wraps to a second line under the title instead of crushing it. */
1177
+ .ag-compact-row-main {
1178
+ display: flex;
1179
+ align-items: baseline;
1180
+ gap: 8px;
1181
+ flex: 1;
1182
+ min-width: 0;
1183
+ }
1184
+ .ag--mobile .ag-compact-row-main {
1185
+ flex-wrap: wrap;
1186
+ row-gap: 2px;
1187
+ }
1188
+ /* Mobile: size the title by its content when deciding line breaks — a long
1189
+ title claims the first line whole (ellipsizing only against the full row)
1190
+ and pushes subtitle/tags down instead of truncating at 35%. */
1191
+ .ag--mobile .ag-compact-row-title {
1192
+ flex-basis: auto;
1193
+ }
1194
+ /* On its own wrapped line the subtitle gets the full width */
1195
+ .ag--mobile .ag-compact-row-sub {
1196
+ max-width: 100%;
1197
+ }
1198
+ /* Wrapped rows are two lines tall — center-aligning the dot floats it
1199
+ between lines; pin it optically to the first (title) line instead. */
1200
+ .ag--mobile .ag-compact-row-dot {
1201
+ align-self: flex-start;
1202
+ margin-top: 8px;
1203
+ }
1159
1204
  .ag-compact-row-title {
1160
1205
  font-size: 12px;
1161
1206
  font-weight: 500;
1162
1207
  color: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 82%, transparent);
1163
1208
  flex: 1;
1209
+ /* The title is the row's identity — never let subtitle/tags/duration
1210
+ squeeze it out on narrow screens (flex: 1 alone resolves to 0px). */
1211
+ min-width: 35%;
1164
1212
  white-space: nowrap;
1165
1213
  overflow: hidden;
1166
1214
  text-overflow: ellipsis;
@@ -1178,7 +1226,8 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
1178
1226
  .ag-compact-row-sub {
1179
1227
  font-size: 10px;
1180
1228
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1181
- flex-shrink: 1;
1229
+ flex-shrink: 3;
1230
+ min-width: 0;
1182
1231
  max-width: 45%;
1183
1232
  white-space: nowrap;
1184
1233
  overflow: hidden;
@@ -1192,7 +1241,10 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
1192
1241
  padding: 1px 4px;
1193
1242
  border-radius: 3px;
1194
1243
  white-space: nowrap;
1195
- flex-shrink: 0;
1244
+ flex-shrink: 1;
1245
+ min-width: 2.5em;
1246
+ overflow: hidden;
1247
+ text-overflow: ellipsis;
1196
1248
  }
1197
1249
  .ag-compact-row--cancelled { opacity: 0.5; }
1198
1250
  .ag-compact-row--cancelled .ag-compact-row-title { text-decoration: line-through; }
@@ -54,6 +54,10 @@ let expandedDays = $state([]);
54
54
  function toggleDayExpand(ms) {
55
55
  expandedDays = expandedDays.includes(ms) ? expandedDays.filter((m) => m !== ms) : [...expandedDays, ms];
56
56
  }
57
+ let expandedPast = $state([]);
58
+ function togglePastExpand(ms) {
59
+ expandedPast = expandedPast.includes(ms) ? expandedPast.filter((m) => m !== ms) : [...expandedPast, ms];
60
+ }
57
61
  const fmt = (d) => fmtTime(d, locale);
58
62
  const eta = (ms) => timeUntilMs(ms, clock.tick, L);
59
63
  const prog = (ev) => progress(ev, clock.tick);
@@ -178,6 +182,59 @@ const weekDays = $derived.by(() => {
178
182
  </button>
179
183
  {/snippet}
180
184
 
185
+ <!-- ═══ Shared compact row snippet ═══ -->
186
+ {#snippet compactRow(ev: TimelineEvent, showLoc: boolean, done: boolean)}
187
+ <button
188
+ type="button"
189
+ class="ag-compact"
190
+ class:ag-compact--selected={selectedEventId === ev.id}
191
+ class:ag-compact--done={done}
192
+ class:ag-compact--cancelled={ev.status === 'cancelled'}
193
+ class:ag-compact--tentative={ev.status === 'tentative'}
194
+ class:ag-compact--full={ev.status === 'full'}
195
+ class:ag-compact--limited={ev.status === 'limited'}
196
+ style:--ev-color={ev.color || 'var(--dt-accent)'}
197
+ aria-label="{ev.title}{done ? `, ${L.completed}` : ''}, {fmt(ev.start)}, {duration(ev)}"
198
+ onclick={() => handleClick(ev)}
199
+ onpointerenter={() => oneventhover?.(ev)}
200
+ >
201
+ <EventContent event={ev}>
202
+ <span class="ag-compact-dot"></span>
203
+ <span class="ag-compact-time">{fmt(ev.start)}</span>
204
+ <div class="ag-compact-main">
205
+ <span class="ag-compact-title">{ev.title}</span>
206
+ {#if showLoc && ev.location}
207
+ <span class="ag-compact-loc">{ev.location}</span>
208
+ {/if}
209
+ {#if ev.subtitle}
210
+ <span class="ag-compact-sub">{ev.subtitle}</span>
211
+ {/if}
212
+ {#if ev.tags?.length}
213
+ {#each ev.tags as tag}
214
+ <span class="ag-compact-tag">{tag}</span>
215
+ {/each}
216
+ {/if}
217
+ </div>
218
+ <span class="ag-compact-dur">{duration(ev)}</span>
219
+ </EventContent>
220
+ </button>
221
+ {/snippet}
222
+
223
+ <!-- ═══ "✓ N completed" disclosure toggle ═══ -->
224
+ {#snippet pastToggle(ms: number, count: number, summary: boolean)}
225
+ {@const open = expandedPast.includes(ms)}
226
+ <button
227
+ type="button"
228
+ class="ag-wday-past-line ag-past-toggle"
229
+ class:ag-wday-past-line--summary={summary}
230
+ aria-expanded={open}
231
+ onclick={() => togglePastExpand(ms)}
232
+ >
233
+ ✓ {L.nCompleted(count)}
234
+ <svg class="ag-past-chevron" class:ag-past-chevron--open={open} viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
235
+ </button>
236
+ {/snippet}
237
+
181
238
  <!-- svelte-ignore a11y_no_static_element_interactions -->
182
239
  <div
183
240
  class="ag ag--week"
@@ -207,7 +264,14 @@ const weekDays = $derived.by(() => {
207
264
  {/if}
208
265
  </div>
209
266
  {#if day.timedEvents.length > 0}
210
- <div class="ag-wday-past-line ag-wday-past-line--summary">✓ {L.nCompleted(day.timedEvents.length)}</div>
267
+ {@render pastToggle(day.ms, day.timedEvents.length, true)}
268
+ {#if expandedPast.includes(day.ms)}
269
+ <div class="ag-wday-compact">
270
+ {#each day.timedEvents as ev (ev.id)}
271
+ {@render compactRow(ev, false, true)}
272
+ {/each}
273
+ </div>
274
+ {/if}
211
275
  {:else if day.events.length === 0}
212
276
  <div class="ag-wday-past-line ag-wday-past-line--summary">{L.noEvents}</div>
213
277
  {/if}
@@ -269,34 +333,7 @@ const weekDays = $derived.by(() => {
269
333
  <!-- Compact: minimal dot + time + title rows for all days -->
270
334
  <div class="ag-wday-compact">
271
335
  {#each day.timedEvents as ev (ev.id)}
272
- <button
273
- type="button"
274
- class="ag-compact"
275
- class:ag-compact--selected={selectedEventId === ev.id}
276
- class:ag-compact--cancelled={ev.status === 'cancelled'}
277
- class:ag-compact--tentative={ev.status === 'tentative'}
278
- class:ag-compact--full={ev.status === 'full'}
279
- class:ag-compact--limited={ev.status === 'limited'}
280
- style:--ev-color={ev.color || 'var(--dt-accent)'}
281
- aria-label="{ev.title}, {fmt(ev.start)}, {duration(ev)}"
282
- onclick={() => handleClick(ev)}
283
- onpointerenter={() => oneventhover?.(ev)}
284
- >
285
- <EventContent event={ev}>
286
- <span class="ag-compact-dot"></span>
287
- <span class="ag-compact-time">{fmt(ev.start)}</span>
288
- <span class="ag-compact-title">{ev.title}</span>
289
- {#if ev.subtitle}
290
- <span class="ag-compact-sub">{ev.subtitle}</span>
291
- {/if}
292
- {#if ev.tags?.length}
293
- {#each ev.tags as tag}
294
- <span class="ag-compact-tag">{tag}</span>
295
- {/each}
296
- {/if}
297
- <span class="ag-compact-dur">{duration(ev)}</span>
298
- </EventContent>
299
- </button>
336
+ {@render compactRow(ev, false, false)}
300
337
  {/each}
301
338
  </div>
302
339
  {:else if equalDays}
@@ -337,7 +374,12 @@ const weekDays = $derived.by(() => {
337
374
  </div>
338
375
  {/each}
339
376
  {#if day.pastEvents.length > 0}
340
- <div class="ag-wday-past-line">✓ {L.nCompleted(day.pastEvents.length)}</div>
377
+ {@render pastToggle(day.ms, day.pastEvents.length, false)}
378
+ {#if expandedPast.includes(day.ms)}
379
+ {#each day.pastEvents as ev (ev.id)}
380
+ {@render compactRow(ev, false, true)}
381
+ {/each}
382
+ {/if}
341
383
  {/if}
342
384
  </div>
343
385
  {:else}
@@ -345,37 +387,7 @@ const weekDays = $derived.by(() => {
345
387
  {@const dayExpanded = expandedDays.includes(day.ms)}
346
388
  <div class="ag-wday-compact">
347
389
  {#each (dayExpanded ? day.timedEvents : day.timedEvents.slice(0, 4)) as ev (ev.id)}
348
- <button
349
- type="button"
350
- class="ag-compact"
351
- class:ag-compact--selected={selectedEventId === ev.id}
352
- class:ag-compact--cancelled={ev.status === 'cancelled'}
353
- class:ag-compact--tentative={ev.status === 'tentative'}
354
- class:ag-compact--full={ev.status === 'full'}
355
- class:ag-compact--limited={ev.status === 'limited'}
356
- style:--ev-color={ev.color || 'var(--dt-accent)'}
357
- aria-label="{ev.title}, {fmt(ev.start)}, {duration(ev)}"
358
- onclick={() => handleClick(ev)}
359
- onpointerenter={() => oneventhover?.(ev)}
360
- >
361
- <EventContent event={ev}>
362
- <span class="ag-compact-dot"></span>
363
- <span class="ag-compact-time">{fmt(ev.start)}</span>
364
- <span class="ag-compact-title">{ev.title}</span>
365
- {#if ev.location}
366
- <span class="ag-compact-loc">{ev.location}</span>
367
- {/if}
368
- {#if ev.subtitle}
369
- <span class="ag-compact-sub">{ev.subtitle}</span>
370
- {/if}
371
- {#if ev.tags?.length}
372
- {#each ev.tags as tag}
373
- <span class="ag-compact-tag">{tag}</span>
374
- {/each}
375
- {/if}
376
- <span class="ag-compact-dur">{duration(ev)}</span>
377
- </EventContent>
378
- </button>
390
+ {@render compactRow(ev, true, false)}
379
391
  {/each}
380
392
  {#if day.timedEvents.length > 4}
381
393
  <button
@@ -421,7 +433,8 @@ const weekDays = $derived.by(() => {
421
433
  .ag-card,
422
434
  .ag-allday-chip,
423
435
  .ag-compact,
424
- .ag-compact-more {
436
+ .ag-compact-more,
437
+ .ag-past-toggle {
425
438
  font: inherit;
426
439
  color: inherit;
427
440
  text-align: left;
@@ -439,10 +452,15 @@ const weekDays = $derived.by(() => {
439
452
  overflow-y: auto;
440
453
  overflow-x: hidden;
441
454
  box-sizing: border-box;
442
- padding-top: 8px;
455
+ /* No padding-top here: the sticky day headers pin at the scrollport
456
+ edge, and container padding would leave a see-through band above
457
+ them where scrolled cards bleed out. */
443
458
  scrollbar-width: thin;
444
459
  scrollbar-color: var(--dt-border) transparent;
445
460
  }
461
+ .ag-wday:first-child .ag-wday-head {
462
+ padding-top: 12px;
463
+ }
446
464
  .ag--auto .ag-body {
447
465
  overflow-y: visible;
448
466
  }
@@ -678,6 +696,10 @@ const weekDays = $derived.by(() => {
678
696
  top: 0;
679
697
  background: var(--dt-bg, #fff);
680
698
  z-index: 1;
699
+ /* Own compositor layer: without it, fast (async) scrolling repaints
700
+ the pinned header a frame late and a gap flashes above it. */
701
+ transform: translateZ(0);
702
+ will-change: transform;
681
703
  }
682
704
  .ag-wday-head-left {
683
705
  display: flex;
@@ -759,6 +781,42 @@ const weekDays = $derived.by(() => {
759
781
  .ag-wday-past-line--summary {
760
782
  padding: 0 20px 8px;
761
783
  }
784
+ /* "✓ N completed" is a disclosure — tap to reveal the finished events */
785
+ .ag-past-toggle {
786
+ display: inline-flex;
787
+ align-items: center;
788
+ gap: 5px;
789
+ cursor: pointer;
790
+ min-height: 32px;
791
+ transition: color 150ms;
792
+ -webkit-tap-highlight-color: transparent;
793
+ }
794
+ .ag-past-toggle:hover,
795
+ .ag-past-toggle:active {
796
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
797
+ }
798
+ .ag-past-toggle:focus-visible {
799
+ outline: none;
800
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
801
+ border-radius: 4px;
802
+ }
803
+ .ag-past-chevron {
804
+ transition: transform 120ms;
805
+ }
806
+ .ag-past-chevron--open {
807
+ transform: rotate(180deg);
808
+ }
809
+ @media (prefers-reduced-motion: reduce) {
810
+ .ag-past-chevron { transition: none; }
811
+ }
812
+ /* Revealed completed events: dim + strike, single token layer */
813
+ .ag-compact--done .ag-compact-title {
814
+ text-decoration: line-through;
815
+ text-decoration-color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
816
+ }
817
+ .ag-compact--done .ag-compact-dot {
818
+ opacity: 0.5;
819
+ }
762
820
 
763
821
  /* Compact day events */
764
822
  .ag-wday-compact {
@@ -776,8 +834,13 @@ const weekDays = $derived.by(() => {
776
834
  .ag-compact--selected {
777
835
  background: color-mix(in srgb, var(--ev-color) 10%, transparent);
778
836
  border-radius: 4px;
837
+ /* Highlight gutter comes from negative margins so the row's content
838
+ stays aligned with its unselected siblings (no tap-shift). */
779
839
  padding-left: 6px;
780
840
  padding-right: 6px;
841
+ margin-left: -6px;
842
+ margin-right: -6px;
843
+ width: calc(100% + 12px);
781
844
  }
782
845
  .ag-compact:hover .ag-compact-title,
783
846
  .ag-compact:active .ag-compact-title {
@@ -809,12 +872,45 @@ const weekDays = $derived.by(() => {
809
872
  white-space: nowrap;
810
873
  line-height: 1.4;
811
874
  }
875
+ /* Title + location + subtitle + tags cluster. One line while it fits; on
876
+ mobile the metadata wraps to a second line instead of crushing the title. */
877
+ .ag-compact-main {
878
+ display: flex;
879
+ align-items: baseline;
880
+ gap: 6px;
881
+ flex: 1;
882
+ min-width: 0;
883
+ }
884
+ .ag--mobile .ag-compact-main {
885
+ flex-wrap: wrap;
886
+ row-gap: 2px;
887
+ }
888
+ /* Mobile: size the title by its content when deciding line breaks — a long
889
+ title claims the first line whole (ellipsizing only against the full row)
890
+ and pushes location/subtitle/tags down instead of truncating at 35%. */
891
+ .ag--mobile .ag-compact-title {
892
+ flex-basis: auto;
893
+ }
894
+ /* On their own wrapped line the metadata gets the full width — the tight
895
+ desktop caps would truncate it beside empty space. */
896
+ .ag--mobile .ag-compact-loc,
897
+ .ag--mobile .ag-compact-sub {
898
+ max-width: 100%;
899
+ }
900
+ /* Wrapped rows are two/three lines tall — center-aligning the dot floats
901
+ it between lines; pin it optically to the first (title) line instead. */
902
+ .ag--mobile .ag-compact-dot {
903
+ align-self: flex-start;
904
+ margin-top: 8px;
905
+ }
812
906
  .ag-compact-title {
813
907
  font-size: 12px;
814
908
  font-weight: 500;
815
909
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
816
910
  flex: 1;
817
- min-width: 0;
911
+ /* The title is the row's identity — never let subtitle/tags/duration
912
+ squeeze it out on narrow screens (min-width: 0 resolves to 0px). */
913
+ min-width: 35%;
818
914
  white-space: nowrap;
819
915
  overflow: hidden;
820
916
  text-overflow: ellipsis;
@@ -832,7 +928,8 @@ const weekDays = $derived.by(() => {
832
928
  .ag-compact-sub {
833
929
  font-size: 10px;
834
930
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
835
- flex-shrink: 0;
931
+ flex-shrink: 3;
932
+ min-width: 0;
836
933
  white-space: nowrap;
837
934
  overflow: hidden;
838
935
  text-overflow: ellipsis;
@@ -842,7 +939,8 @@ const weekDays = $derived.by(() => {
842
939
  .ag-compact-loc {
843
940
  font-size: 10px;
844
941
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
845
- flex-shrink: 0;
942
+ flex-shrink: 3;
943
+ min-width: 0;
846
944
  white-space: nowrap;
847
945
  overflow: hidden;
848
946
  text-overflow: ellipsis;
@@ -870,7 +968,8 @@ const weekDays = $derived.by(() => {
870
968
  padding: 1px 4px;
871
969
  border-radius: 3px;
872
970
  white-space: nowrap;
873
- flex-shrink: 0;
971
+ flex-shrink: 1;
972
+ min-width: 2.5em;
874
973
  max-width: 80px;
875
974
  overflow: hidden;
876
975
  text-overflow: ellipsis;
@@ -398,6 +398,7 @@ $effect(() => {
398
398
  ontouchstart={swipe.ontouchstart}
399
399
  ontouchmove={swipe.ontouchmove}
400
400
  ontouchend={swipe.ontouchend}
401
+ ontouchcancel={swipe.ontouchcancel}
401
402
  >
402
403
  <div
403
404
  class="mb-swipe"
@@ -504,17 +505,20 @@ $effect(() => {
504
505
  <div class="mb-ev-stripe"></div>
505
506
  <div class="mb-ev-body">
506
507
  <EventContent event={p.ev}>
508
+ <!-- Line thresholds must track real content height (title 18 +
509
+ time 13 + sub 14 + loc 12 + tags 19 + padding 8) — showing
510
+ a line the block can't hold clips text at both ends. -->
507
511
  <span class="mb-ev-title">{p.ev.title}</span>
508
512
  {#if p.height > 32}
509
513
  <span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
510
514
  {/if}
511
- {#if p.ev.subtitle && p.height > 48}
515
+ {#if p.ev.subtitle && p.height > 56}
512
516
  <span class="mb-ev-sub">{p.ev.subtitle}</span>
513
517
  {/if}
514
- {#if p.ev.location && p.height > 56}
518
+ {#if p.ev.location && p.height > 72}
515
519
  <span class="mb-ev-loc">{p.ev.location}</span>
516
520
  {/if}
517
- {#if p.ev.tags?.length && p.height > 56}
521
+ {#if p.ev.tags?.length && p.height > 88}
518
522
  <div class="mb-ev-tags">
519
523
  {#each p.ev.tags as tag}
520
524
  <span class="mb-ev-tag">{tag}</span>
@@ -751,6 +755,9 @@ $effect(() => {
751
755
 
752
756
  .mb-hour-label {
753
757
  width: 40px;
758
+ /* border-box keeps the label inside the 40px gutter that events
759
+ start at — content-box pushed digits flush under the event edge */
760
+ box-sizing: border-box;
754
761
  flex-shrink: 0;
755
762
  font: 500 11px/1 var(--dt-mono, ui-monospace, monospace);
756
763
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
@@ -801,6 +808,7 @@ $effect(() => {
801
808
 
802
809
  .mb-now-label {
803
810
  width: 40px;
811
+ box-sizing: border-box;
804
812
  flex-shrink: 0;
805
813
  text-align: right;
806
814
  padding-right: 6px;
@@ -143,6 +143,7 @@ function handleDayKeydown(e, dayMs) {
143
143
  ontouchstart={swipe.ontouchstart}
144
144
  ontouchmove={swipe.ontouchmove}
145
145
  ontouchend={swipe.ontouchend}
146
+ ontouchcancel={swipe.ontouchcancel}
146
147
  >
147
148
  <!-- Vertical day list -->
148
149
  <div
@@ -251,6 +252,7 @@ function handleDayKeydown(e, dayMs) {
251
252
  .mw-list {
252
253
  flex: 1;
253
254
  overflow-y: auto;
255
+ overflow-x: hidden;
254
256
  overscroll-behavior: contain;
255
257
  -webkit-overflow-scrolling: touch;
256
258
  scrollbar-width: thin;
@@ -270,6 +272,10 @@ function handleDayKeydown(e, dayMs) {
270
272
  align-items: center;
271
273
  gap: 12px;
272
274
  position: relative;
275
+ /* border-box: width 100% + padding otherwise overflows the list by
276
+ 24px, which iOS turns into a horizontal pan that clips the date
277
+ column off the left edge */
278
+ box-sizing: border-box;
273
279
  padding: 10px 12px;
274
280
  background: transparent;
275
281
  transition: background 120ms;
@@ -24,5 +24,6 @@ export interface SwipeHandlers {
24
24
  ontouchstart: (e: TouchEvent) => void;
25
25
  ontouchmove: (e: TouchEvent) => void;
26
26
  ontouchend: () => void;
27
+ ontouchcancel: () => void;
27
28
  }
28
29
  export declare function createSwipe(cb: SwipeCallbacks): SwipeHandlers;
@@ -60,5 +60,14 @@ export function createSwipe(cb) {
60
60
  dx = 0;
61
61
  cb.onend(dir);
62
62
  },
63
+ ontouchcancel() {
64
+ // The browser took over the gesture (scroll, edge-swipe, system UI).
65
+ // Without this, the view stays stuck at the last swipe offset.
66
+ if (!tracking)
67
+ return;
68
+ tracking = false;
69
+ dx = 0;
70
+ cb.onend(0);
71
+ },
63
72
  };
64
73
  }