@remit/ui 0.0.113 → 0.0.115

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 (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -0,0 +1,141 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ type CustomRecurrence,
5
+ dayOfMonthLabel,
6
+ defaultCustomRecurrence,
7
+ defaultEndDate,
8
+ formatCustomRecurrence,
9
+ ordinalWeekdayLabel,
10
+ readCustomRecurrence,
11
+ repeatChoices,
12
+ } from "./recurrence.js";
13
+
14
+ /** Thursday 11 June 2026. */
15
+ const DATE = "2026-06-11";
16
+
17
+ const rule = (patch: Partial<CustomRecurrence> = {}): CustomRecurrence => ({
18
+ ...defaultCustomRecurrence(DATE),
19
+ ...patch,
20
+ });
21
+
22
+ describe("repeatChoices", () => {
23
+ it("derives the monthly choice from the date's own weekday", () => {
24
+ assert.ok(
25
+ repeatChoices(DATE, "").includes("Every month on the second Thursday"),
26
+ );
27
+ });
28
+
29
+ it("appends the hour when there is one", () => {
30
+ assert.ok(repeatChoices(DATE, "09:15").includes("Every day, 09:15"));
31
+ });
32
+
33
+ it("offers weekdays only when the date is one", () => {
34
+ assert.ok(!repeatChoices("2026-06-13", "").includes("Every weekday"));
35
+ });
36
+ });
37
+
38
+ describe("defaultCustomRecurrence", () => {
39
+ it("starts on the event's own weekday", () => {
40
+ assert.deepEqual(defaultCustomRecurrence(DATE).weekdays, [4]);
41
+ });
42
+
43
+ it("survives a date it cannot read", () => {
44
+ assert.deepEqual(defaultCustomRecurrence("").weekdays, []);
45
+ });
46
+ });
47
+
48
+ describe("formatCustomRecurrence", () => {
49
+ it("reads a rule in words, never as an RRULE", () => {
50
+ const text = formatCustomRecurrence(
51
+ rule({
52
+ interval: 2,
53
+ weekdays: [1, 4],
54
+ ends: { kind: "onDate", date: "2026-10-03" },
55
+ }),
56
+ DATE,
57
+ "",
58
+ );
59
+ assert.equal(text, "Every 2 weeks on Monday and Thursday, until 3 October");
60
+ });
61
+
62
+ it("drops the interval when it is one", () => {
63
+ assert.equal(
64
+ formatCustomRecurrence(rule({ weekdays: [4] }), DATE, ""),
65
+ "Every week on Thursday",
66
+ );
67
+ });
68
+
69
+ it("counts an ending that is counted", () => {
70
+ assert.equal(
71
+ formatCustomRecurrence(
72
+ rule({ unit: "day", ends: { kind: "afterCount", count: 13 } }),
73
+ DATE,
74
+ "",
75
+ ),
76
+ "Every day, 13 times",
77
+ );
78
+ });
79
+
80
+ it("says which of the two monthly readings is meant", () => {
81
+ assert.equal(
82
+ formatCustomRecurrence(rule({ unit: "month" }), DATE, ""),
83
+ "Every month on day 11",
84
+ );
85
+ assert.equal(
86
+ formatCustomRecurrence(
87
+ rule({ unit: "month", monthlyMode: "weekdayOfMonth" }),
88
+ DATE,
89
+ "",
90
+ ),
91
+ "Every month on the second Thursday",
92
+ );
93
+ });
94
+
95
+ it("carries the hour", () => {
96
+ assert.equal(
97
+ formatCustomRecurrence(rule({ unit: "year" }), DATE, "09:15"),
98
+ "Every year on 11 June, 09:15",
99
+ );
100
+ });
101
+ });
102
+
103
+ describe("readCustomRecurrence", () => {
104
+ for (const seed of [
105
+ rule({ interval: 2, weekdays: [1, 4] }),
106
+ rule({ interval: 3, unit: "day" }),
107
+ rule({ unit: "month", monthlyMode: "weekdayOfMonth" }),
108
+ rule({ unit: "year" }),
109
+ rule({ weekdays: [0, 2, 6], ends: { kind: "afterCount", count: 13 } }),
110
+ rule({ ends: { kind: "onDate", date: "2026-10-03" } }),
111
+ ])
112
+ it(`reopens on "${formatCustomRecurrence(seed, DATE, "")}"`, () => {
113
+ const text = formatCustomRecurrence(seed, DATE, "09:15");
114
+ const read = readCustomRecurrence(text, DATE);
115
+ assert.ok(read);
116
+ assert.equal(formatCustomRecurrence(read, DATE, "09:15"), text);
117
+ });
118
+
119
+ it("declines a sentence it did not write", () => {
120
+ assert.equal(readCustomRecurrence("Every weekday, 09:15", DATE), undefined);
121
+ assert.equal(
122
+ readCustomRecurrence("whenever Jane is free", DATE),
123
+ undefined,
124
+ );
125
+ assert.equal(
126
+ readCustomRecurrence("Every week on Blursday", DATE),
127
+ undefined,
128
+ );
129
+ });
130
+ });
131
+
132
+ describe("labels", () => {
133
+ it("offers an end date a year out", () => {
134
+ assert.equal(defaultEndDate(DATE), "2027-06-11");
135
+ });
136
+
137
+ it("names the two readings of the same day", () => {
138
+ assert.equal(dayOfMonthLabel(DATE), "day 11");
139
+ assert.equal(ordinalWeekdayLabel(DATE), "the second Thursday");
140
+ });
141
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Repeat rules in the words a person would say them.
3
+ *
4
+ * A rule is carried and shown as that sentence — "Every weekday, 09:15" — and
5
+ * never as an RRULE the reader has to decode. The picker offers the handful of
6
+ * rules a date can plausibly repeat by, worked out from the date itself, so
7
+ * "every month" means the second Wednesday when that is the Wednesday you
8
+ * clicked rather than a number nobody chose.
9
+ */
10
+
11
+ const WEEKDAY_NAMES = [
12
+ "Sunday",
13
+ "Monday",
14
+ "Tuesday",
15
+ "Wednesday",
16
+ "Thursday",
17
+ "Friday",
18
+ "Saturday",
19
+ ];
20
+
21
+ const MONTH_NAMES = [
22
+ "January",
23
+ "February",
24
+ "March",
25
+ "April",
26
+ "May",
27
+ "June",
28
+ "July",
29
+ "August",
30
+ "September",
31
+ "October",
32
+ "November",
33
+ "December",
34
+ ];
35
+
36
+ const ORDINALS = ["first", "second", "third", "fourth", "last"];
37
+
38
+ /** No repeat at all — the value a one-off carries. */
39
+ export const NO_REPEAT = "";
40
+
41
+ /**
42
+ * The rules on offer for a date, widest interval last. `startTime` is appended
43
+ * when there is one, because "every weekday" without an hour is only half a
44
+ * rule.
45
+ */
46
+ export function repeatChoices(date: string, startTime: string): string[] {
47
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return [];
48
+ const [year, month, dayOfMonth] = date.split("-").map(Number);
49
+ const when = new Date(Date.UTC(year, month - 1, dayOfMonth));
50
+ const weekday = WEEKDAY_NAMES[when.getUTCDay()];
51
+ const ordinal = ORDINALS[Math.min(Math.floor((dayOfMonth - 1) / 7), 4)];
52
+ const clock = startTime === "" ? "" : `, ${startTime}`;
53
+ const onWeekdays = when.getUTCDay() >= 1 && when.getUTCDay() <= 5;
54
+
55
+ return [
56
+ `Every day${clock}`,
57
+ ...(onWeekdays ? [`Every weekday${clock}`] : []),
58
+ `Every week on ${weekday}${clock}`,
59
+ `Every month on the ${ordinal} ${weekday}${clock}`,
60
+ `Every year on ${dayOfMonth} ${MONTH_NAMES[month - 1]}${clock}`,
61
+ ];
62
+ }
63
+
64
+ /* ------------------------------------------------------------------ */
65
+ /* A rule the offered ones do not cover */
66
+ /* ------------------------------------------------------------------ */
67
+
68
+ export type RecurrenceUnit = "day" | "week" | "month" | "year";
69
+
70
+ /**
71
+ * A monthly rule can mean two different things about the same date, and which
72
+ * one is meant is never derivable — the 14th and the second Tuesday coincide
73
+ * this month and diverge the next.
74
+ */
75
+ export type MonthlyMode = "dayOfMonth" | "weekdayOfMonth";
76
+
77
+ export type RecurrenceEnd =
78
+ | { kind: "never" }
79
+ | { kind: "onDate"; date: string }
80
+ | { kind: "afterCount"; count: number };
81
+
82
+ export interface CustomRecurrence {
83
+ /** How many units between occurrences. One means every one. */
84
+ interval: number;
85
+ unit: RecurrenceUnit;
86
+ /** Weekday indexes, Sunday first. Read only when the unit is a week. */
87
+ weekdays: number[];
88
+ /** Read only when the unit is a month. */
89
+ monthlyMode: MonthlyMode;
90
+ ends: RecurrenceEnd;
91
+ }
92
+
93
+ /** Single-letter weekday labels, Sunday first, for a row of toggles. */
94
+ export const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
95
+
96
+ export const weekdayName = (index: number): string => WEEKDAY_NAMES[index];
97
+
98
+ function parseDate(date: string): Date | undefined {
99
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return undefined;
100
+ const [year, month, day] = date.split("-").map(Number);
101
+ return new Date(Date.UTC(year, month - 1, day));
102
+ }
103
+
104
+ /** The event's own day is what a custom rule starts from. */
105
+ export function defaultCustomRecurrence(date: string): CustomRecurrence {
106
+ const when = parseDate(date);
107
+ return {
108
+ interval: 1,
109
+ unit: "week",
110
+ weekdays: when ? [when.getUTCDay()] : [],
111
+ monthlyMode: "dayOfMonth",
112
+ ends: { kind: "never" },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * What the "on" ending offers before anyone picks a day: a year out, so the
118
+ * field reads as a date rather than as an empty box.
119
+ */
120
+ export function defaultEndDate(date: string): string {
121
+ const when = parseDate(date);
122
+ if (!when) return date;
123
+ when.setUTCFullYear(when.getUTCFullYear() + 1);
124
+ return when.toISOString().slice(0, 10);
125
+ }
126
+
127
+ /** "the second Tuesday", derived from the date the event sits on. */
128
+ export function ordinalWeekdayLabel(date: string): string {
129
+ const when = parseDate(date);
130
+ if (!when) return "";
131
+ const dayOfMonth = when.getUTCDate();
132
+ return `the ${ORDINALS[Math.min(Math.floor((dayOfMonth - 1) / 7), 4)]} ${
133
+ WEEKDAY_NAMES[when.getUTCDay()]
134
+ }`;
135
+ }
136
+
137
+ /** "day 14", the other reading of the same date. */
138
+ export function dayOfMonthLabel(date: string): string {
139
+ const when = parseDate(date);
140
+ return when ? `day ${when.getUTCDate()}` : "";
141
+ }
142
+
143
+ /** "3 October" — an end date read the way the rule reads it back. */
144
+ export function endDateLabel(date: string): string {
145
+ const when = parseDate(date);
146
+ return when
147
+ ? `${when.getUTCDate()} ${MONTH_NAMES[when.getUTCMonth()]}`
148
+ : date;
149
+ }
150
+
151
+ function joinWords(parts: string[]): string {
152
+ if (parts.length <= 1) return parts.join("");
153
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
154
+ }
155
+
156
+ /**
157
+ * The rule as a sentence. Everything the editor can express reads back in
158
+ * words — an RRULE is never shown, and never typed.
159
+ */
160
+ export function formatCustomRecurrence(
161
+ rule: CustomRecurrence,
162
+ date: string,
163
+ startTime: string,
164
+ ): string {
165
+ const every =
166
+ rule.interval <= 1
167
+ ? `Every ${rule.unit}`
168
+ : `Every ${rule.interval} ${rule.unit}s`;
169
+
170
+ let on = "";
171
+ if (rule.unit === "week" && rule.weekdays.length > 0)
172
+ on = ` on ${joinWords(
173
+ [...rule.weekdays].sort((a, b) => a - b).map(weekdayName),
174
+ )}`;
175
+ if (rule.unit === "month")
176
+ on = ` on ${
177
+ rule.monthlyMode === "dayOfMonth"
178
+ ? dayOfMonthLabel(date)
179
+ : ordinalWeekdayLabel(date)
180
+ }`;
181
+ if (rule.unit === "year") {
182
+ const when = parseDate(date);
183
+ if (when)
184
+ on = ` on ${when.getUTCDate()} ${MONTH_NAMES[when.getUTCMonth()]}`;
185
+ }
186
+
187
+ const clock = startTime === "" ? "" : `, ${startTime}`;
188
+ const ends =
189
+ rule.ends.kind === "onDate"
190
+ ? `, until ${endDateLabel(rule.ends.date)}`
191
+ : rule.ends.kind === "afterCount"
192
+ ? `, ${rule.ends.count} times`
193
+ : "";
194
+
195
+ return `${every}${on}${clock}${ends}`;
196
+ }
197
+
198
+ /**
199
+ * The inverse of `formatCustomRecurrence`, so reopening the editor shows the
200
+ * rule the event already carries. A sentence it did not write — one of the
201
+ * offered choices, or a rule read out of a mail — returns nothing, and the
202
+ * editor starts from the event's own day instead.
203
+ */
204
+ export function readCustomRecurrence(
205
+ text: string,
206
+ date: string,
207
+ ): CustomRecurrence | undefined {
208
+ let rest = text.trim();
209
+ let ends: RecurrenceEnd = { kind: "never" };
210
+
211
+ const times = rest.match(/,\s*(\d+) times$/);
212
+ if (times) {
213
+ ends = { kind: "afterCount", count: Number(times[1]) };
214
+ rest = rest.slice(0, times.index);
215
+ }
216
+
217
+ const until = rest.match(/,\s*until (\d{1,2}) ([A-Z][a-z]+)$/);
218
+ if (until) {
219
+ const endDate = isoForDayMonth(Number(until[1]), until[2], date);
220
+ if (endDate === "") return undefined;
221
+ ends = { kind: "onDate", date: endDate };
222
+ rest = rest.slice(0, until.index);
223
+ }
224
+
225
+ rest = rest.replace(/,\s*\d{2}:\d{2}$/, "");
226
+
227
+ const head = rest.match(
228
+ /^Every (?:(\d+) )?(day|week|month|year)s?(?: on (.+))?$/,
229
+ );
230
+ if (!head) return undefined;
231
+
232
+ const interval = head[1] === undefined ? 1 : Number(head[1]);
233
+ const unit = head[2] as RecurrenceUnit;
234
+ const on = head[3] ?? "";
235
+ const base = { ...defaultCustomRecurrence(date), interval, unit, ends };
236
+
237
+ if (unit === "week") {
238
+ if (on === "") return base;
239
+ const names = on.split(/,\s*|\s+and\s+/);
240
+ const weekdays = names.map((name) => WEEKDAY_NAMES.indexOf(name));
241
+ if (weekdays.some((index) => index === -1)) return undefined;
242
+ return { ...base, weekdays };
243
+ }
244
+
245
+ if (unit === "month") {
246
+ if (/^day \d{1,2}$/.test(on)) return { ...base, monthlyMode: "dayOfMonth" };
247
+ if (/^the \w+ [A-Z][a-z]+$/.test(on))
248
+ return { ...base, monthlyMode: "weekdayOfMonth" };
249
+ return on === "" ? base : undefined;
250
+ }
251
+
252
+ return base;
253
+ }
254
+
255
+ /** "3 October" against the event's own year, rolling forward if it has passed. */
256
+ function isoForDayMonth(day: number, month: string, from: string): string {
257
+ const index = MONTH_NAMES.indexOf(month);
258
+ const start = parseDate(from);
259
+ if (index === -1 || !start) return "";
260
+ const sameYear = new Date(Date.UTC(start.getUTCFullYear(), index, day));
261
+ const when =
262
+ sameYear < start
263
+ ? new Date(Date.UTC(start.getUTCFullYear() + 1, index, day))
264
+ : sameYear;
265
+ return when.toISOString().slice(0, 10);
266
+ }
@@ -0,0 +1,25 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /**
4
+ * A media query read from JS and kept in step with the browser's answer. The
5
+ * same conditions the CSS gates on — the desktop query above all — decide
6
+ * behaviour that has no CSS form: which surface a menu opens in, whether a
7
+ * gesture is bound at all.
8
+ */
9
+ export const useMatchMedia = (query: string): boolean => {
10
+ const [matches, setMatches] = useState(() => {
11
+ if (typeof window === "undefined" || !window.matchMedia) return false;
12
+ return window.matchMedia(query).matches;
13
+ });
14
+
15
+ useEffect(() => {
16
+ if (typeof window === "undefined" || !window.matchMedia) return;
17
+ const media = window.matchMedia(query);
18
+ setMatches(media.matches);
19
+ const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
20
+ media.addEventListener("change", handler);
21
+ return () => media.removeEventListener("change", handler);
22
+ }, [query]);
23
+
24
+ return matches;
25
+ };
package/src/rich-text.ts CHANGED
@@ -24,6 +24,11 @@ export {
24
24
  PlainTextEditor,
25
25
  type PlainTextEditorProps,
26
26
  } from "./components/plain-text-editor.js";
27
+ export {
28
+ type CorrectionMenuAnchor,
29
+ RichTextCorrectionMenu,
30
+ type RichTextCorrectionMenuProps,
31
+ } from "./components/rich-text-correction-menu.js";
27
32
  export {
28
33
  htmlToMarkdown,
29
34
  markdownToHtml,
@@ -42,11 +47,18 @@ export type {
42
47
  ProviderStatus,
43
48
  SpellcheckOptions,
44
49
  SpellProvider,
50
+ SuggestRequest,
51
+ SuggestResponse,
45
52
  } from "./components/rich-text-spellcheck.js";
46
53
  export {
47
54
  openSpellProvider,
48
55
  type SpellWorkerPort,
49
56
  } from "./components/rich-text-spellcheck-provider.js";
57
+ export {
58
+ normaliseWord,
59
+ SUGGESTION_LIMIT,
60
+ suggestionsFor,
61
+ } from "./components/rich-text-spellcheck-words.js";
50
62
  export {
51
63
  type ComposeCaret,
52
64
  EMPTY_RICH_TEXT,
package/src/tokens.css CHANGED
@@ -75,6 +75,26 @@
75
75
  /* focus ring */
76
76
  --color-ring: var(--ring);
77
77
 
78
+ /* Per-calendar identity. One calendar owns one hue, and the hue is the
79
+ only way two calendars are told apart on a grid, so these are the one
80
+ place the UI needs colour the semantic palette cannot supply. Six hues,
81
+ spaced far enough apart to survive a 4px event chip, each steering clear
82
+ of the accent green, the cyan and the danger coral so a calendar never
83
+ reads as a status. `-soft` is the chip fill; the base is the marker and
84
+ the text on that fill. */
85
+ --color-cal-1: var(--cal-1);
86
+ --color-cal-1-soft: var(--cal-1-soft);
87
+ --color-cal-2: var(--cal-2);
88
+ --color-cal-2-soft: var(--cal-2-soft);
89
+ --color-cal-3: var(--cal-3);
90
+ --color-cal-3-soft: var(--cal-3-soft);
91
+ --color-cal-4: var(--cal-4);
92
+ --color-cal-4-soft: var(--cal-4-soft);
93
+ --color-cal-5: var(--cal-5);
94
+ --color-cal-5-soft: var(--cal-5-soft);
95
+ --color-cal-6: var(--cal-6);
96
+ --color-cal-6-soft: var(--cal-6-soft);
97
+
78
98
  /* radii — slight rounding only: hairline borders + subtle radius */
79
99
  --radius-xs: 2px;
80
100
  --radius-sm: 3px;
@@ -169,6 +189,20 @@
169
189
  --spell-mark: oklch(0.52 0.19 320);
170
190
 
171
191
  --ring: oklch(0.55 0.14 150);
192
+
193
+ /* calendar hues: blue, teal, pink, orange, moss, violet */
194
+ --cal-1: oklch(0.53 0.13 250);
195
+ --cal-1-soft: oklch(0.925 0.04 250);
196
+ --cal-2: oklch(0.53 0.1 175);
197
+ --cal-2-soft: oklch(0.925 0.035 175);
198
+ --cal-3: oklch(0.53 0.14 320);
199
+ --cal-3-soft: oklch(0.925 0.04 320);
200
+ --cal-4: oklch(0.53 0.13 45);
201
+ --cal-4-soft: oklch(0.925 0.04 45);
202
+ --cal-5: oklch(0.53 0.11 130);
203
+ --cal-5-soft: oklch(0.925 0.04 130);
204
+ --cal-6: oklch(0.53 0.14 285);
205
+ --cal-6-soft: oklch(0.925 0.04 285);
172
206
  }
173
207
 
174
208
  /* ---------- DARK: deep slate-teal, lifted off black ---------- */
@@ -205,6 +239,21 @@
205
239
  --spell-mark: oklch(0.76 0.16 320);
206
240
 
207
241
  --ring: oklch(0.78 0.16 150);
242
+
243
+ /* same six hues, lifted for the slate ground and desaturated so a full
244
+ week of chips does not glow */
245
+ --cal-1: oklch(0.79 0.11 250);
246
+ --cal-1-soft: oklch(0.33 0.055 250);
247
+ --cal-2: oklch(0.79 0.09 175);
248
+ --cal-2-soft: oklch(0.33 0.05 175);
249
+ --cal-3: oklch(0.79 0.12 320);
250
+ --cal-3-soft: oklch(0.33 0.055 320);
251
+ --cal-4: oklch(0.79 0.11 45);
252
+ --cal-4-soft: oklch(0.33 0.055 45);
253
+ --cal-5: oklch(0.79 0.1 130);
254
+ --cal-5-soft: oklch(0.33 0.05 130);
255
+ --cal-6: oklch(0.79 0.12 285);
256
+ --cal-6-soft: oklch(0.33 0.055 285);
208
257
  }
209
258
 
210
259
  html {
@@ -229,6 +278,20 @@ html.dark,
229
278
  padding-left: env(safe-area-inset-left, 0px);
230
279
  }
231
280
 
281
+ /* A number field that carries its own steppers still gets the browser's, and
282
+ two sets of arrows on one field is a field nobody trusts. */
283
+ @utility own-steppers {
284
+ & input[type="number"] {
285
+ appearance: textfield;
286
+ }
287
+
288
+ & input[type="number"]::-webkit-outer-spin-button,
289
+ & input[type="number"]::-webkit-inner-spin-button {
290
+ appearance: none;
291
+ margin: 0;
292
+ }
293
+ }
294
+
232
295
  body {
233
296
  margin: 0;
234
297
  background-color: var(--color-canvas);