@nomideusz/svelte-calendar 1.4.0 → 1.4.2

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://badgen.net/npm/v/@nomideusz/svelte-calendar)](https://www.npmjs.com/package/@nomideusz/svelte-calendar) [![license](https://badgen.net/badge/license/MIT/blue)](https://github.com/nomideusz/svelte-calendar/blob/main/LICENSE)
4
4
 
5
- A themeable **Svelte 5** calendar: day and week **Planner** time grids, a multi-week **Scroll** view, **Agenda** lists, a **Month** grid and touch-first **Mobile** layouts, with drag-and-drop, timezones, a headless API, an embeddable web component, and a smart auto-theme that adapts to any page.
5
+ A themeable **Svelte 5** calendar: day and week **Planner** time grids, a multi-week **Scroll** view, **Agenda** lists, a **Month** grid and touch-first **Mobile** layouts, with drag to move, resize and create events, timezones, a headless API, an embeddable web component, and a smart auto-theme that adapts to any page.
6
6
 
7
7
  **[Live demo → svelte-calendar.xyz](https://svelte-calendar.xyz/)**
8
8
 
@@ -28,24 +28,37 @@ Requirements:
28
28
  const today = new Date();
29
29
  const at = (hour) => new Date(today.getFullYear(), today.getMonth(), today.getDate(), hour);
30
30
 
31
- const adapter = createMemoryAdapter([
31
+ let events = $state([
32
32
  { id: '1', title: 'Team Sync', start: at(9), end: at(10) },
33
33
  { id: '2', title: 'Lunch', start: at(12), end: at(13) },
34
34
  ]);
35
+ // A new adapter whenever the events change; the calendar reloads without blanking
36
+ const adapter = $derived(createMemoryAdapter(events));
37
+
38
+ // Drag on empty time (or press Enter on a day column) to create
39
+ function create({ start, end }) {
40
+ events = [...events, { id: crypto.randomUUID(), title: 'New event', start, end }];
41
+ }
42
+ // Drag an event to move it, or its edge to resize it
43
+ function move(event, start, end) {
44
+ events = events.map((e) => (e.id === event.id ? { ...e, start, end } : e));
45
+ }
35
46
  </script>
36
47
 
37
- <Calendar {adapter} view="week-planner" />
48
+ <Calendar {adapter} view="week-planner" oneventcreate={create} oneventmove={move} />
38
49
  ```
39
50
 
40
- That's it: eight built-in views and drag-and-drop with live previews, out of the box. The default `auto` theme probes your page's background, accent color and fonts, so the calendar adapts to any design. Events without a `color` draw in ink; to color them automatically, give the adapter a [`palette`](#event-colors).
51
+ That's a working, editable calendar. The calendar stores nothing on create: without `oneventcreate`, dragging on empty time does nothing. Moves and resizes write to the adapter by themselves, and `oneventmove` keeps your own data in step, so a move survives the next new adapter. The default `auto` theme probes your page's background, accent color and fonts, so the calendar adapts to any design. Events without a `color` draw in ink; to color them automatically, give the adapter a [`palette`](#event-colors).
41
52
 
42
53
  ## Views
43
54
 
55
+ Without `view` the calendar opens on `day-planner`, the first registered view. The Quick Start, the demo, the widget and `createCalendar` open on `week-planner`, so pass `view="week-planner"` for the week grid.
56
+
44
57
  The default registry (`defaultViews`) holds eight views. Six are the ones you choose with `view`; the two `*-mobile` views are what the others swap to in a narrow container (see [Mobile](#mobile)), so the demo's view list shows six.
45
58
 
46
59
  | View ID | Mode | What it is |
47
60
  |---------|------|------------|
48
- | `day-planner` | day | Vertical time grid, one column. Opens when `view` is unset (the first registered view). The examples here, the demo, the widget and `createCalendar` open on `week-planner`. |
61
+ | `day-planner` | day | Vertical time grid, one column. The default when `view` is unset. |
49
62
  | `week-planner` | week | The same time grid, one column per day (`days`, default 7). |
50
63
  | `week-scroll` | week | Weeks stacked in one infinite vertical scroller (Hey-Calendar style): each chip sits at the height of its start time, month names run down the side. Drag to move, drop onto a day; it scrolls under a drag. |
51
64
  | `day-agenda` | day | Live list for one day: now / up next / done. |
@@ -112,7 +125,7 @@ The component receives `CalendarViewProps` (plus your `props`); declare only the
112
125
  |------|------|-------|
113
126
  | `events` | `TimelineEvent[]` | Everything loaded for the current range |
114
127
  | `style` | `string` | The active `--dt-*` theme string |
115
- | `height` | `number \| null` | Currently always `null`: size to the container |
128
+ | `height` | `number \| null` | Always `null`: the view fills its container, sized by the Calendar's own [`height`](#display) (default `600`) |
116
129
  | `mode` | `'day' \| 'week' \| 'month'` | |
117
130
  | `mondayStart` | `boolean` | |
118
131
  | `locale` | `string \| undefined` | |
@@ -320,7 +333,7 @@ Disable specific dates:
320
333
  />
321
334
  ```
322
335
 
323
- Disabled dates refuse creating or moving events into those days. Existing events stay visible and clickable, which suits holidays or fully booked days.
336
+ Disabled dates refuse creating or moving events into those days, which suits holidays or fully booked days. In the planner, agenda and mobile views their existing events stay visible and clickable; `month-grid` dims a disabled day, and its chips ignore clicks.
324
337
 
325
338
  Custom day headers and hover previews:
326
339
 
@@ -567,55 +580,7 @@ The calendar reads its events through an adapter. Which one:
567
580
  | [`createCompositeAdapter(adapters)`](#composite-adapter) | One calendar shows several sources, e.g. a recurring timetable plus one-off events. |
568
581
  | [Custom `CalendarAdapter`](#custom-adapter) | None of the above fits: implement `fetchEvents`, plus the writes you need. |
569
582
 
570
- ## Recurring Schedules
571
-
572
- For fixed repeating events (class timetables, office hours):
573
-
574
- ```svelte
575
- <script>
576
- import { Calendar, createRecurringAdapter } from '@nomideusz/svelte-calendar';
577
-
578
- const adapter = createRecurringAdapter([
579
- { id: '1', title: 'Yoga', dayOfWeek: 1, startTime: '07:00', endTime: '08:30' },
580
- { id: '2', title: 'Standup', frequency: 'daily', startTime: '09:00', endTime: '09:15',
581
- startDate: '2026-03-01', until: '2026-03-31' },
582
- { id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
583
- startTime: '10:00', endTime: '11:00' },
584
- { id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
585
- ]);
586
- </script>
587
-
588
- <Calendar {adapter} view="week-planner" />
589
- ```
590
-
591
- <details>
592
- <summary>RecurringEvent fields</summary>
593
-
594
- | Field | Type | Default | Description |
595
- |-------|------|---------|-------------|
596
- | `id` | `string` | *required* | Rule identifier |
597
- | `title` | `string` | *required* | Event title |
598
- | `startTime` | `string` | *required* | Start time `"HH:MM"`, 24-hour |
599
- | `endTime` | `string` | *required* | End time `"HH:MM"`. Earlier than `startTime` → ends the next day (overnight). |
600
- | `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
601
- | `interval` | `number` | `1` | Every N periods (`2` + weekly = biweekly). Needs `startDate` when > 1. |
602
- | `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
603
- | `dayOfMonth` | `number` | `1` | Day of month (1–31) for monthly; clamped to shorter months. |
604
- | `startDate` | `string` | — | First possible occurrence `"YYYY-MM-DD"` |
605
- | `until` | `string` | — | Last possible occurrence `"YYYY-MM-DD"` |
606
- | `count` | `number` | — | Max occurrences from `startDate` (requires it). With `until`, the stricter wins. |
607
- | `excludeDates` | `string[]` | — | `"YYYY-MM-DD"` dates this rule skips |
608
- | `color` | `string` | — | Bar color; else from `palette`, else ink (`--dt-event`) |
609
- | `subtitle` / `tags` / `category` / `location` / `resourceId` | | — | Copied to every occurrence |
610
- | `data` | `Record<string, unknown>` | — | Copied to every occurrence, plus `recurringId` |
611
-
612
- </details>
613
-
614
- Options (`RecurringAdapterOptions`): `mondayStart` (default `true`), `palette` (colors rules without a `color`; off by default), `movable` (default `false`).
615
-
616
- Occurrences get the id `` `${rule.id}--YYYYMMDD` `` and `data.recurringId`. The adapter projects and stores nothing, so by default every occurrence carries `data.readOnly` and can't be dragged (it can still be clicked). With `movable: true` the views allow the drag and the adapter refuses the write with a read-only error, so `oneventmove` fires and the host stores the move: add the date to the rule's `excludeDates` and keep the moved occurrence with your one-off events (for example the memory side of a [composite adapter](#composite-adapter)).
617
-
618
- ## REST Adapter
583
+ ### REST Adapter
619
584
 
620
585
  Connect to any REST API:
621
586
 
@@ -640,7 +605,7 @@ const adapter = createRestAdapter({
640
605
 
641
606
  The adapter calls `GET {baseUrl}/events?start=…&end=…` (ISO strings), `POST /events`, `PATCH /events/:id` and `DELETE /events/:id`, sending JSON with your `headers`. By default a list response must be an array of events and a single response one event; `start`/`end` are parsed into `Date`s. Override with `mapEvents(data)` for lists and `mapEvent(data)` for single events. A non-2xx response or invalid JSON throws.
642
607
 
643
- ## JMAP Adapter
608
+ ### JMAP Adapter
644
609
 
645
610
  Read events from a [JMAP Calendars](https://jmap.io/) server (Fastmail, Stalwart, Cyrus):
646
611
 
@@ -665,7 +630,7 @@ const adapter = createJmapAdapter(client, {
665
630
 
666
631
  Read-only. Expands recurrences server-side and handles all-day events, ISO-duration ends and per-calendar colors (pass `calendars: [{ id, name, color }]`, or a function returning them).
667
632
 
668
- ## Mapped Adapter
633
+ ### Mapped Adapter
669
634
 
670
635
  Wrap any static array of external records (class schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping (or a `mapEvent` function for full control) and the adapter handles parsing, colors, tags and status:
671
636
 
@@ -708,7 +673,7 @@ const adapter = createMappedAdapter(rawData, {
708
673
 
709
674
  Mapped adapters are read-only by default (writes throw a read-only error, so drags reach `oneventmove`). Set `readOnly: false` to allow writes; supply `onMutate: { onCreate, onUpdate, onDelete }` to persist them (each returns the stored source record, which is mapped again), or omit it to keep changes in memory.
710
675
 
711
- ## Composite Adapter
676
+ ### Composite Adapter
712
677
 
713
678
  Merge several adapters into one, e.g. a recurring weekly schedule plus one-off events:
714
679
 
@@ -733,7 +698,7 @@ const adapter = createCompositeAdapter([recurring, memory], {
733
698
  });
734
699
  ```
735
700
 
736
- ## Custom Adapter
701
+ ### Custom Adapter
737
702
 
738
703
  Implement the `CalendarAdapter` interface to connect any data source. Only `fetchEvents` is required:
739
704
 
@@ -765,7 +730,7 @@ const adapter = withInitialEvents(createRestAdapter({ baseUrl: '/api' }), data.e
765
730
 
766
731
  The first load is answered synchronously from the seed; every later load (navigation, refresh) goes to the wrapped adapter.
767
732
 
768
- ### Refusing a write
733
+ #### Refusing a write
769
734
 
770
735
  The Calendar tells two refusals apart when `updateEvent` throws:
771
736
 
@@ -777,6 +742,54 @@ The Calendar tells two refusals apart when `updateEvent` throws:
777
742
 
778
743
  An adapter without `updateEvent` counts as read-only. Plain `Error`s whose message contains `read-only` (lower case) or `not found` (any case) are read the same way, so older adapters keep working. `isReadOnlyError(e)` and `isNotFoundError(e)` test both forms.
779
744
 
745
+ ## Recurring Schedules
746
+
747
+ For fixed repeating events (class timetables, office hours):
748
+
749
+ ```svelte
750
+ <script>
751
+ import { Calendar, createRecurringAdapter } from '@nomideusz/svelte-calendar';
752
+
753
+ const adapter = createRecurringAdapter([
754
+ { id: '1', title: 'Yoga', dayOfWeek: 1, startTime: '07:00', endTime: '08:30' },
755
+ { id: '2', title: 'Standup', frequency: 'daily', startTime: '09:00', endTime: '09:15',
756
+ startDate: '2026-03-01', until: '2026-03-31' },
757
+ { id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
758
+ startTime: '10:00', endTime: '11:00' },
759
+ { id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
760
+ ]);
761
+ </script>
762
+
763
+ <Calendar {adapter} view="week-planner" />
764
+ ```
765
+
766
+ <details>
767
+ <summary>RecurringEvent fields</summary>
768
+
769
+ | Field | Type | Default | Description |
770
+ |-------|------|---------|-------------|
771
+ | `id` | `string` | *required* | Rule identifier |
772
+ | `title` | `string` | *required* | Event title |
773
+ | `startTime` | `string` | *required* | Start time `"HH:MM"`, 24-hour |
774
+ | `endTime` | `string` | *required* | End time `"HH:MM"`. Earlier than `startTime` → ends the next day (overnight). |
775
+ | `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
776
+ | `interval` | `number` | `1` | Every N periods (`2` + weekly = biweekly). Needs `startDate` when > 1. |
777
+ | `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
778
+ | `dayOfMonth` | `number` | `1` | Day of month (1–31) for monthly; clamped to shorter months. |
779
+ | `startDate` | `string` | — | First possible occurrence `"YYYY-MM-DD"` |
780
+ | `until` | `string` | — | Last possible occurrence `"YYYY-MM-DD"` |
781
+ | `count` | `number` | — | Max occurrences from `startDate` (requires it). With `until`, the stricter wins. |
782
+ | `excludeDates` | `string[]` | — | `"YYYY-MM-DD"` dates this rule skips |
783
+ | `color` | `string` | — | Bar color; else from `palette`, else ink (`--dt-event`) |
784
+ | `subtitle` / `tags` / `category` / `location` / `resourceId` | | — | Copied to every occurrence |
785
+ | `data` | `Record<string, unknown>` | — | Copied to every occurrence, plus `recurringId` |
786
+
787
+ </details>
788
+
789
+ Options (`RecurringAdapterOptions`): `mondayStart` (default `true`), `palette` (colors rules without a `color`; off by default), `movable` (default `false`).
790
+
791
+ Occurrences get the id `` `${rule.id}--YYYYMMDD` `` and `data.recurringId`. The adapter projects and stores nothing, so by default every occurrence carries `data.readOnly` and can't be dragged (it can still be clicked). With `movable: true` the views allow the drag and the adapter refuses the write with a read-only error, so `oneventmove` fires and the host stores the move: add the date to the rule's `excludeDates` and keep the moved occurrence with your one-off events (for example the memory side of a [composite adapter](#composite-adapter)).
792
+
780
793
  ## Headless API
781
794
 
782
795
  For full control over rendering, skip `<Calendar>` and drive everything from reactive state. `createCalendar()` returns computed layouts, navigation actions, drag helpers and the raw engines. Zero DOM: bring your own UI. Call it during component initialisation.
@@ -1057,7 +1070,7 @@ Every `<Calendar>` prop (`CalendarProps`), grouped by what it controls.
1057
1070
  | `minDuration` | `number` | — | Minimum duration in minutes; clamps creates and resizes |
1058
1071
  | `maxDuration` | `number` | — | Maximum duration in minutes; clamps creates and resizes |
1059
1072
  | `blockedSlots` | `BlockedSlot[]` | — | `{ day?, start, end, label? }` hour ranges that can't be booked (hatched in planner views) |
1060
- | `disabledDates` | `Date[]` | — | Dates that refuse creates/moves; existing events stay visible and clickable |
1073
+ | `disabledDates` | `Date[]` | — | Dates that refuse creates/moves; existing events stay visible and clickable (dimmed and inert in `month-grid`) |
1061
1074
 
1062
1075
  ### View and navigation
1063
1076
 
@@ -1127,6 +1140,16 @@ Every `<Calendar>` prop (`CalendarProps`), grouped by what it controls.
1127
1140
  | `oneventhover` | `(event) => void` | — | Pointer enters an event |
1128
1141
  | `onerror` | `(error: Error) => void` | — | Load and commit failures |
1129
1142
 
1143
+ ## Troubleshooting
1144
+
1145
+ - **Dragging on empty time does nothing.** Creating needs an `oneventcreate` handler (and no `readOnly`); see the [Quick Start](#quick-start).
1146
+ - **A created event doesn't appear.** The calendar stores nothing on create: add the event to your data and hand the Calendar a new adapter (`$derived(createMemoryAdapter(events))`).
1147
+ - **Moved events jump back after the next change.** A new adapter is built from your array, so write each move into it in `oneventmove`.
1148
+ - **The `auto` theme picked the wrong colours.** It probes the host page around the calendar, and probes again when `<html>`/`<body>` change class, style or `data-theme`, when the system scheme flips and when a background transition ends. Steer it with [`autoTheme`](#smart-auto-theme), or set `theme` to a preset or your own tokens to opt out.
1149
+ - **Events are all one colour.** Since 1.4 events without a `color` draw in ink; give the adapter a [`palette`](#event-colors).
1150
+ - **The calendar stops at 600px.** That's the default `height`; pass a number, or `'auto'` to grow with the content.
1151
+ - **The phone layout shows on a wide page (or the desktop one in a narrow column).** The switch reads the calendar's container, not the window; force it with `mobile={false}` or `mobile={true}`.
1152
+
1130
1153
  ## Versioning
1131
1154
 
1132
1155
  The package follows [semver](https://semver.org/) from 1.0:
@@ -623,6 +623,28 @@ function handleShortcuts(e) {
623
623
  else viewState.prev();
624
624
  }
625
625
  }
626
+ // Each pill group is one tab stop (radio-group pattern): arrows (reading
627
+ // direction), Home and End move the selection and focus with it.
628
+ function pillKeys(e) {
629
+ const radios = [...e.currentTarget.querySelectorAll("[role=\"radio\"]")];
630
+ const i = radios.indexOf(e.target);
631
+ if (i < 0) return;
632
+ const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
633
+ const step = {
634
+ ArrowRight: rtl ? -1 : 1,
635
+ ArrowLeft: rtl ? 1 : -1,
636
+ ArrowDown: 1,
637
+ ArrowUp: -1
638
+ };
639
+ let n;
640
+ if (e.key === "Home") n = 0;
641
+ else if (e.key === "End") n = radios.length - 1;
642
+ else if (e.key in step) n = (i + step[e.key] + radios.length) % radios.length;
643
+ else return;
644
+ e.preventDefault();
645
+ radios[n].focus();
646
+ radios[n].click();
647
+ }
626
648
  /** Header context for custom header snippet */
627
649
  const headerCtx = $derived({
628
650
  dateLabel,
@@ -671,7 +693,9 @@ const navCtx = $derived({
671
693
  <span class="cal-sr" role="status" aria-live="polite" aria-atomic="true">{noteText || dateLabel}</span>
672
694
  {#if showModePills && !useMobile && labelsForMode.length > 1}
673
695
  <!-- View-type switcher (e.g. Planner ↔ Agenda) for the current mode -->
674
- <div class="cal-pills cal-pills--labels" role="radiogroup" aria-label={L.viewMode}>
696
+ {@const activeLabel = labelsForMode.includes(activeView?.label ?? '') ? activeView?.label : labelsForMode[0]}
697
+ <!-- svelte-ignore a11y_interactive_supports_focus -- the checked radio is the tab stop; the group only routes its arrow keys -->
698
+ <div class="cal-pills cal-pills--labels" role="radiogroup" aria-label={L.viewMode} onkeydown={pillKeys}>
675
699
  {#each labelsForMode as label (label)}
676
700
  <button
677
701
  type="button"
@@ -679,6 +703,7 @@ const navCtx = $derived({
679
703
  class:cal-pill--active={activeView?.label === label}
680
704
  role="radio"
681
705
  aria-checked={activeView?.label === label}
706
+ tabindex={label === activeLabel ? 0 : -1}
682
707
  onclick={() => switchLabel(label)}
683
708
  >
684
709
  {viewLabel(label)}
@@ -687,7 +712,9 @@ const navCtx = $derived({
687
712
  </div>
688
713
  {/if}
689
714
  {#if showModePills && modes.length > 1}
690
- <div class="cal-pills" role="radiogroup" aria-label={L.viewMode}>
715
+ {@const activeMode = modes.includes(viewState.mode) ? viewState.mode : modes[0]}
716
+ <!-- svelte-ignore a11y_interactive_supports_focus -- the checked radio is the tab stop; the group only routes its arrow keys -->
717
+ <div class="cal-pills" role="radiogroup" aria-label={L.viewMode} onkeydown={pillKeys}>
691
718
  {#each modes as g (g)}
692
719
  <button
693
720
  type="button"
@@ -695,6 +722,7 @@ const navCtx = $derived({
695
722
  class:cal-pill--active={viewState.mode === g}
696
723
  role="radio"
697
724
  aria-checked={viewState.mode === g}
725
+ tabindex={g === activeMode ? 0 : -1}
698
726
  onclick={() => switchMode(g)}
699
727
  >
700
728
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -952,6 +980,9 @@ const navCtx = $derived({
952
980
  .cal-hd {
953
981
  --cal-ctl-h: 44px;
954
982
  }
983
+ .cal-pill {
984
+ min-width: 44px;
985
+ }
955
986
  }
956
987
 
957
988
  @media (prefers-reduced-motion: reduce) {
@@ -98,6 +98,10 @@ export declare function setDefaultLocale(tag: string): void;
98
98
  /** Get the current default locale */
99
99
  export declare function getDefaultLocale(): string;
100
100
  export declare function is24HourLocale(locale?: string): boolean;
101
+ /** A number in the locale's own digits ("٧" in ar-SA), optionally zero-padded. */
102
+ export declare function fmtNum(n: number, locale?: string, minDigits?: number): string;
103
+ /** Day-of-month in the locale's digits: "19", "١٩". */
104
+ export declare function fmtDayNum(ms: number, locale?: string): string;
101
105
  /** Format hour index (0-23) as compact label: 12h ("12a", "1p") or 24h ("0", "13") */
102
106
  export declare function fmtH(h: number, locale?: string): string;
103
107
  /**
@@ -137,6 +141,7 @@ export declare function fmtWeekRange(weekStartMs: number, locale?: string, weekE
137
141
  *
138
142
  * 12-hour locales → "9a", "12:30p"
139
143
  * 24-hour locales → "9:00", "14:30"
144
+ * other 12-hour locales → Intl's own, in the locale's digits ("٧:٠٠ ص")
140
145
  */
141
146
  export declare function fmtTime(d: Date, locale?: string): string;
142
147
  /**
@@ -106,16 +106,61 @@ export function is24HourLocale(locale) {
106
106
  hourCycleCache.set(loc, is24);
107
107
  return is24;
108
108
  }
109
+ // ─── Numerals ───────────────────────────────────────────
110
+ const numCache = new Map();
111
+ /** A number in the locale's own digits ("٧" in ar-SA), optionally zero-padded. */
112
+ export function fmtNum(n, locale, minDigits = 1) {
113
+ const loc = locale ?? defaultLocale;
114
+ const key = `${loc}|${minDigits}`;
115
+ let f = numCache.get(key);
116
+ if (!f) {
117
+ f = new Intl.NumberFormat(loc, { useGrouping: false, minimumIntegerDigits: minDigits });
118
+ numCache.set(key, f);
119
+ }
120
+ return f.format(n);
121
+ }
122
+ /** Day-of-month in the locale's digits: "19", "١٩". */
123
+ export function fmtDayNum(ms, locale) {
124
+ return fmtNum(new Date(ms).getDate(), locale);
125
+ }
126
+ /**
127
+ * Whether the compact "9a" / "12:30p" style fits the locale: a 12-hour clock
128
+ * whose day periods are Latin AM/PM. Other 12-hour locales (ar "ص"/"م")
129
+ * get Intl's own day period instead.
130
+ */
131
+ const compactCache = new Map();
132
+ function isCompact12h(locale) {
133
+ const loc = locale ?? defaultLocale;
134
+ if (is24HourLocale(loc))
135
+ return false;
136
+ let c = compactCache.get(loc);
137
+ if (c === undefined) {
138
+ const period = new Intl.DateTimeFormat(loc, { hour: 'numeric', hour12: true })
139
+ .formatToParts(new Date(2000, 0, 1, 9))
140
+ .find((p) => p.type === 'dayPeriod')?.value ?? '';
141
+ c = /^a\W*m\W*$/i.test(period);
142
+ compactCache.set(loc, c);
143
+ }
144
+ return c;
145
+ }
146
+ const dtfCache = new Map();
147
+ function dtf(locale, withMinutes) {
148
+ const loc = locale ?? defaultLocale;
149
+ const key = `${loc}|${withMinutes}`;
150
+ let f = dtfCache.get(key);
151
+ if (!f) {
152
+ f = new Intl.DateTimeFormat(loc, withMinutes ? { hour: 'numeric', minute: '2-digit' } : { hour: 'numeric' });
153
+ dtfCache.set(key, f);
154
+ }
155
+ return f;
156
+ }
109
157
  /** Format hour index (0-23) as compact label: 12h ("12a", "1p") or 24h ("0", "13") */
110
158
  export function fmtH(h, locale) {
111
- if (is24HourLocale(locale)) {
112
- return String(h);
113
- }
114
- if (h === 0)
115
- return '12a';
116
- if (h === 12)
117
- return '12p';
118
- return h < 12 ? h + 'a' : h - 12 + 'p';
159
+ if (is24HourLocale(locale))
160
+ return fmtNum(h, locale);
161
+ if (!isCompact12h(locale))
162
+ return dtf(locale, false).format(new Date(2000, 0, 1, h));
163
+ return fmtNum(h % 12 || 12, locale) + (h < 12 ? 'a' : 'p');
119
164
  }
120
165
  /**
121
166
  * Hour index (0-23) split for a timetable gutter: the numeral and the small
@@ -123,8 +168,15 @@ export function fmtH(h, locale) {
123
168
  */
124
169
  export function fmtHParts(h, locale) {
125
170
  if (is24HourLocale(locale))
126
- return [String(h), '00'];
127
- return [String(h % 12 || 12), h < 12 ? 'am' : 'pm'];
171
+ return [fmtNum(h, locale), fmtNum(0, locale, 2)];
172
+ if (!isCompact12h(locale)) {
173
+ const parts = dtf(locale, false).formatToParts(new Date(2000, 0, 1, h));
174
+ return [
175
+ parts.find((p) => p.type === 'hour')?.value ?? fmtNum(h % 12 || 12, locale),
176
+ parts.find((p) => p.type === 'dayPeriod')?.value ?? '',
177
+ ];
178
+ }
179
+ return [fmtNum(h % 12 || 12, locale), h < 12 ? 'am' : 'pm'];
128
180
  }
129
181
  /** Short weekday name for a timestamp: "Mon", "Tue", etc. */
130
182
  export function weekdayShort(ms, locale) {
@@ -198,18 +250,17 @@ export function fmtWeekRange(weekStartMs, locale, weekEndMs) {
198
250
  *
199
251
  * 12-hour locales → "9a", "12:30p"
200
252
  * 24-hour locales → "9:00", "14:30"
253
+ * other 12-hour locales → Intl's own, in the locale's digits ("٧:٠٠ ص")
201
254
  */
202
255
  export function fmtTime(d, locale) {
203
- if (is24HourLocale(locale)) {
204
- const h = d.getHours();
205
- const m = d.getMinutes();
206
- return `${h}:${String(m).padStart(2, '0')}`;
207
- }
208
256
  const h = d.getHours();
209
257
  const m = d.getMinutes();
258
+ if (is24HourLocale(locale))
259
+ return `${fmtNum(h, locale)}:${fmtNum(m, locale, 2)}`;
260
+ if (!isCompact12h(locale))
261
+ return dtf(locale, true).format(d);
210
262
  const suffix = h >= 12 ? 'p' : 'a';
211
- const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
212
- return `${h12}:${String(m).padStart(2, '0')}${suffix}`;
263
+ return `${fmtNum(h % 12 || 12, locale)}:${fmtNum(m, locale, 2)}${suffix}`;
213
264
  }
214
265
  /**
215
266
  * Format the duration between two Dates as a compact string.
@@ -5,10 +5,10 @@
5
5
  - Relative labels ("Today", "Tomorrow") for day views via fmtDay
6
6
  - Short/long weekday + date number for week views
7
7
  -->
8
- <script lang="ts">import { weekdayShort, weekdayLong, monthShort, fmtDay } from "../core/locale.js";
8
+ <script lang="ts">import { weekdayShort, weekdayLong, monthShort, fmtDay, fmtDayNum } from "../core/locale.js";
9
9
  import { sod } from "../core/time.js";
10
10
  let { dayMs, todayMs = sod(Date.now()), format = "short", isToday = false, isPast = false } = $props();
11
- const dayNum = $derived(new Date(dayMs).getDate());
11
+ const dayNum = $derived(fmtDayNum(dayMs));
12
12
  </script>
13
13
 
14
14
  <div
@@ -53,7 +53,7 @@ function handleKeydown(e) {
53
53
  background: var(--dt-accent-dim, rgba(29, 79, 215, 0.12));
54
54
  }
55
55
  .es:focus-visible {
56
- outline: 2px solid var(--dt-accent, #1d4fd7);
56
+ outline: 2px solid var(--dt-text, #0f0f0f);
57
57
  outline-offset: 2px;
58
58
  border-color: var(--dt-accent-dim, rgba(29, 79, 215, 0.12));
59
59
  }
@@ -588,7 +588,20 @@ export function observeHostTheme(el, callback, options = {}) {
588
588
  attributes: true,
589
589
  attributeFilter: ['class', 'style', 'data-theme', 'data-mode', 'color-scheme'],
590
590
  });
591
- // 3. Re-probe once the document settles. A probe during initial load can
591
+ // 3. Hosts often crossfade their ground (`body { transition: background 300ms }`),
592
+ // so the rAF probe above reads a mid-transition colour and keeps the old
593
+ // host's light/dark. Re-probe when a colour transition on <html>/<body> ends.
594
+ const onTransitionEnd = (e) => {
595
+ const t = e.target;
596
+ if (t !== document.documentElement && t !== document.body)
597
+ return;
598
+ const prop = e.propertyName;
599
+ if (prop === undefined || /^(background|color)/.test(prop))
600
+ scheduleUpdate();
601
+ };
602
+ document.addEventListener('transitionend', onTransitionEnd);
603
+ document.addEventListener('animationend', onTransitionEnd);
604
+ // 4. Re-probe once the document settles. A probe during initial load can
592
605
  // read pre-stylesheet values (wrong background → wrong light/dark call);
593
606
  // late webfonts change computed font stacks. Both are one-shot events.
594
607
  if (document.readyState !== 'complete') {
@@ -602,5 +615,7 @@ export function observeHostTheme(el, callback, options = {}) {
602
615
  mql?.removeEventListener('change', onScheme);
603
616
  window.removeEventListener('load', scheduleUpdate);
604
617
  observer.disconnect();
618
+ document.removeEventListener('transitionend', onTransitionEnd);
619
+ document.removeEventListener('animationend', onTransitionEnd);
605
620
  };
606
621
  }
@@ -12,8 +12,8 @@
12
12
  * Clean numbered schedule list.
13
13
  */
14
14
  import { createClock } from "../../core/clock.svelte.js";
15
- import { sod, addDaysMs, dayNum, isAllDay } from "../../core/time.js";
16
- import { weekdayLong, monthLong } from "../../core/locale.js";
15
+ import { sod, addDaysMs, isAllDay } from "../../core/time.js";
16
+ import { weekdayLong, monthLong, fmtDayNum } from "../../core/locale.js";
17
17
  import { useCalendarContext } from "../shared/context.svelte.js";
18
18
  import EventContent from "../shared/EventContent.svelte";
19
19
  import { fmtTime, duration, timeUntilMs, progress, groupIntoSlots } from "../shared/format.js";
@@ -136,7 +136,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
136
136
  <!-- ─── In-view date header (only when the chrome doesn't label the day) ─── -->
137
137
  {#if !hideDayHead}
138
138
  <div class="ag-day-head" class:ag-day-head--today={!equalDays && isToday}>
139
- <span class="ag-day-head-num">{dayNum(dayMs)}</span>
139
+ <span class="ag-day-head-num">{fmtDayNum(dayMs, locale)}</span>
140
140
  {#if !equalDays && isToday}
141
141
  <span class="ag-day-head-badge">{L.today}</span>
142
142
  {:else if !equalDays && isTomorrow}
@@ -562,6 +562,15 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
562
562
  color: var(--dt-btn-text, #fff);
563
563
  cursor: pointer;
564
564
  }
565
+ @media (pointer: coarse) {
566
+ .ag-allday-chip {
567
+ min-height: 44px;
568
+ }
569
+ .ag-q-done-item {
570
+ min-height: 44px;
571
+ align-items: center;
572
+ }
573
+ }
565
574
  .ag-allday-chip:hover,
566
575
  .ag-allday-chip:active {
567
576
  filter: brightness(1.08);
@@ -820,7 +829,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
820
829
  }
821
830
  .ag-q-done-toggle:focus-visible {
822
831
  outline: none;
823
- box-shadow: 0 0 0 2px var(--dt-accent, #1d4fd7);
832
+ box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
824
833
  }
825
834
  .ag-q-now-sub {
826
835
  font: 400 12.5px/1.2 var(--dt-sans, system-ui, sans-serif);
@@ -9,8 +9,8 @@
9
9
  * Answers: "What's coming up and when do I need to be ready?"
10
10
  */
11
11
  import { createClock } from "../../core/clock.svelte.js";
12
- import { sod, addDaysMs, startOfWeek, dayNum, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
13
- import { weekdayLong, monthLong } from "../../core/locale.js";
12
+ import { sod, addDaysMs, startOfWeek, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
13
+ import { weekdayLong, monthLong, fmtDayNum } from "../../core/locale.js";
14
14
  import { useCalendarContext } from "../shared/context.svelte.js";
15
15
  import EventContent from "../shared/EventContent.svelte";
16
16
  import { fmtTime, duration, timeUntilMs, progress, groupIntoSlots } from "../shared/format.js";
@@ -165,7 +165,7 @@ const weekDays = $derived.by(() => {
165
165
  ms,
166
166
  dayName: weekdayLong(ms, locale),
167
167
  month: monthLong(ms, locale),
168
- num: dayNum(ms),
168
+ num: fmtDayNum(ms, locale),
169
169
  tier,
170
170
  // A dateless weekly template (showDates=false) has no today to mark.
171
171
  isToday: showDates && ms === todayMs,
@@ -796,9 +796,9 @@ const weekDays = $derived.by(() => {
796
796
  }
797
797
  .ag-wday-head--click:focus-visible,
798
798
  .ag-wday-empty--click:focus-visible {
799
- outline: 2px solid var(--dt-accent, #1d4fd7);
799
+ outline: 2px solid var(--dt-text, #0f0f0f);
800
800
  outline-offset: -2px;
801
- border-radius: 4px;
801
+ border-radius: 0;
802
802
  }
803
803
  .ag-wday-head {
804
804
  display: flex;
@@ -844,8 +844,11 @@ const weekDays = $derived.by(() => {
844
844
  .ag-wday .ag-wday-badge--muted {
845
845
  color: var(--dt-text-2, rgba(15, 15, 15, 0.66));
846
846
  }
847
+ /* Set like the weekday beside it, so the heading reads in one case */
847
848
  .ag-wday-date {
848
- font: 400 12.5px/1 var(--dt-sans, system-ui, sans-serif);
849
+ font: 400 12px/1 var(--dt-sans, system-ui, sans-serif);
850
+ letter-spacing: 0.08em;
851
+ text-transform: uppercase;
849
852
  color: var(--dt-text-2, rgba(15, 15, 15, 0.66));
850
853
  }
851
854
 
@@ -908,8 +911,8 @@ const weekDays = $derived.by(() => {
908
911
  }
909
912
  .ag-past-toggle:focus-visible {
910
913
  outline: none;
911
- box-shadow: 0 0 0 2px var(--dt-accent, #1d4fd7);
912
- border-radius: 4px;
914
+ box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
915
+ border-radius: 0;
913
916
  }
914
917
  .ag-past-chevron {
915
918
  transition: transform 120ms;
@@ -1081,7 +1084,7 @@ const weekDays = $derived.by(() => {
1081
1084
  }
1082
1085
  .ag-compact-more:focus-visible {
1083
1086
  outline: none;
1084
- box-shadow: 0 0 0 2px var(--dt-accent, #1d4fd7);
1087
+ box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
1085
1088
  border-radius: 0;
1086
1089
  }
1087
1090
 
@@ -793,7 +793,7 @@ $effect(() => {
793
793
  }
794
794
  .mb-allday-more:focus-visible {
795
795
  outline: none;
796
- box-shadow: 0 0 0 2px var(--dt-accent, #1d4fd7);
796
+ box-shadow: 0 0 0 2px var(--dt-text, #0f0f0f);
797
797
  }
798
798
 
799
799
  /* ─── Grid ───────────────────────────────────────── */
@@ -811,7 +811,7 @@ $effect(() => {
811
811
  .mb--auto .mb-grid { overflow-y: visible; }
812
812
  .mb-grid:focus-visible {
813
813
  outline: none;
814
- box-shadow: inset 0 0 0 2px var(--dt-accent, #1d4fd7);
814
+ box-shadow: inset 0 0 0 2px var(--dt-text, #0f0f0f);
815
815
  }
816
816
 
817
817
  .mb-grid-inner {
@@ -843,6 +843,8 @@ $effect(() => {
843
843
  align-items: flex-start;
844
844
  }
845
845
 
846
+ /* The first hour has no room above its line: it sits on it, unclipped */
847
+ .mb-hour:first-child .mb-hour-label { top: 0; }
846
848
  .mb-hour-label--under {
847
849
  visibility: hidden;
848
850
  }