From 21dd5a240544c53e3b3c3d99a5b1d4b2a893ac3f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:05:48 +0100
Subject: [PATCH 1/4] feat: draw calendar colours in the colour-blind-safe
palette when that setting is on
---
.../calendar/CalendarSearchSheet.tsx | 5 +-
.../calendar/CalendarSidebarDrawer.tsx | 7 ++-
src/components/calendar/EventCard.tsx | 5 +-
src/components/calendar/EventDetailSheet.tsx | 5 +-
src/components/calendar/EventModal.tsx | 7 ++-
src/components/calendar/ICalImportSheet.tsx | 5 +-
.../calendar/ICalSubscriptionSheet.tsx | 5 +-
src/components/calendar/MonthView.tsx | 10 ++--
src/components/calendar/TasksSheet.tsx | 5 +-
.../calendar/TimeGridScrollView.tsx | 8 ++-
src/components/calendar/WeekView.tsx | 7 ++-
.../__tests__/calendar-cvd-colours.test.ts | 59 +++++++++++++++++++
src/components/contacts/ContactActivity.tsx | 5 +-
.../email/CalendarInvitationBanner.tsx | 7 ++-
.../booking-calendar-actions.tsx | 5 +-
15 files changed, 110 insertions(+), 35 deletions(-)
create mode 100644 src/components/calendar/__tests__/calendar-cvd-colours.test.ts
diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx
index c2a7e085..e249218b 100644
--- a/src/components/calendar/CalendarSearchSheet.tsx
+++ b/src/components/calendar/CalendarSearchSheet.tsx
@@ -14,7 +14,7 @@ import { format } from 'date-fns';
import { ArrowLeft, MapPin, Repeat, Search, X } from '../icons';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDay } from '../../lib/calendar-system';
@@ -62,6 +62,7 @@ export function CalendarSearchSheet({
onClose,
}: CalendarSearchSheetProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t, system } = useCalendarLocale();
const loaded = useCalendarStore((s) => s.events);
@@ -134,7 +135,7 @@ export function CalendarSearchSheet({
const start = getEventStartDate(event);
const end = getEventEndDate(event);
const calendar = calendars.find((cal) => cal.id === getPrimaryCalendarId(event));
- const color = getEventColor(event, calendars);
+ const color = paint(getEventColor(event, calendars));
const inactive = isInactiveEvent(event, currentUserEmails);
const place = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const meta = [place, calendar?.name].filter(Boolean).join(' · ');
diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx
index f725a8f6..5900e2f1 100644
--- a/src/components/calendar/CalendarSidebarDrawer.tsx
+++ b/src/components/calendar/CalendarSidebarDrawer.tsx
@@ -16,7 +16,7 @@ import {
} from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
@@ -309,6 +309,7 @@ function Section({
isSubscriptionCalendar?: (calendarId: string) => boolean;
}) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
// Neighbours a calendar can move past: the server calendars of this section.
@@ -352,8 +353,8 @@ function Section({
style={[
styles.swatch,
{
- backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
- borderColor: getCalendarColor(cal),
+ backgroundColor: visible ? paint(getCalendarColor(cal)) : 'transparent',
+ borderColor: paint(getCalendarColor(cal)),
},
]}
>
diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx
index fd6f9055..0ada84b6 100644
--- a/src/components/calendar/EventCard.tsx
+++ b/src/components/calendar/EventCard.tsx
@@ -4,7 +4,7 @@ import { Clock, MapPin, Users } from '../icons';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
@@ -38,9 +38,10 @@ function participantCount(event: CalendarEvent): number {
*/
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
- const color = getEventColor(event, calendars);
+ const color = paint(getEventColor(event, calendars));
const range = eventTimeRange(event);
const { start, end } = range;
const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day));
diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx
index 3a52bcc1..975b5f48 100644
--- a/src/components/calendar/EventDetailSheet.tsx
+++ b/src/components/calendar/EventDetailSheet.tsx
@@ -39,7 +39,7 @@ import {
import { format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
@@ -140,6 +140,7 @@ export function EventDetailSheet({
onSaveNote,
}: EventDetailSheetProps) {
const c = useColors();
+ const paint = useCvdColor();
const { locale, t } = useCalendarLocale();
const [rsvpBusy, setRsvpBusy] = React.useState(false);
const [noteOpen, setNoteOpen] = React.useState(false);
@@ -200,7 +201,7 @@ export function EventDetailSheet({
if (!event) return null;
- const color = getEventColor(event, calendars);
+ const color = paint(getEventColor(event, calendars));
const calendarId = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event, timeFormat, locale);
diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx
index 7c45d440..9dd5175f 100644
--- a/src/components/calendar/EventModal.tsx
+++ b/src/components/calendar/EventModal.tsx
@@ -36,7 +36,7 @@ import type {
VirtualLocation,
} from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import {
getCalendarColor,
getPrimaryCalendarId,
@@ -189,6 +189,7 @@ export function EventModal({
onClose,
}: EventModalProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
@@ -588,7 +589,7 @@ export function EventModal({
)}
@@ -614,7 +615,7 @@ export function EventModal({
{c.name}
diff --git a/src/components/calendar/ICalImportSheet.tsx b/src/components/calendar/ICalImportSheet.tsx
index 9ae1b344..b112c323 100644
--- a/src/components/calendar/ICalImportSheet.tsx
+++ b/src/components/calendar/ICalImportSheet.tsx
@@ -10,7 +10,7 @@ import { uploadBlob } from '../../api/blob';
import { parseCalendarBlob } from '../../api/calendar';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { ImportRefusedError, type ImportResult } from '../../stores/calendar-store';
@@ -58,6 +58,7 @@ function eventDateLabel(event: Partial, locale: Locale): string |
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
@@ -308,7 +309,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
-
+
{cal.name}
))}
diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx
index 4350f39f..f05f87f9 100644
--- a/src/components/calendar/ICalSubscriptionSheet.tsx
+++ b/src/components/calendar/ICalSubscriptionSheet.tsx
@@ -7,7 +7,7 @@ import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
import { formatDistanceToNow } from 'date-fns';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { CALENDAR_COLOR_PALETTE, calendarColorName } from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
import {
@@ -37,6 +37,7 @@ interface Props {
export function ICalSubscriptionSheet({ visible, onClose }: Props) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const { locale, t } = useCalendarLocale();
@@ -257,7 +258,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
const busy = !!syncing[sub.id];
return (
-
+
startEdit(sub)}>
{sub.name}
diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx
index 0d2868f1..f9bdda85 100644
--- a/src/components/calendar/MonthView.tsx
+++ b/src/components/calendar/MonthView.tsx
@@ -10,7 +10,7 @@ import {
} from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import {
buildEventDayIndex,
@@ -157,6 +157,7 @@ function MonthWeekRowInner({
const { width } = useWindowDimensions();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
+ const paint = useCvdColor();
const chipTime = monthChipShowsTime(true, width / 7);
if (variant === 'full') {
return (
@@ -241,7 +242,7 @@ function MonthWeekRowInner({
{visible.map((event, idx) => (
))}
{overflow > 0 && (
@@ -255,7 +256,7 @@ function MonthWeekRowInner({
// Solid bars in the calendar colour; declined and
// cancelled events outlined (repos/branding/APP.md).
const inactive = isInactiveEvent(event, currentUserEmails);
- const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted);
+ const colors = eventBlockColors(paint(getEventColor(event, calendars)), inactive, c, tinted);
return (
s.calendarTintedEvents);
+ const paint = useCvdColor();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
@@ -371,7 +373,7 @@ function FullWeekRow({
{dayEvents.slice(0, shown).map((event, idx) => {
const inactive = isInactiveEvent(event, currentUserEmails);
- const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted);
+ const colors = eventBlockColors(paint(getEventColor(event, calendars)), inactive, c, tinted);
// No time on events that fill the whole day.
const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx
index 046502ed..0b0b7be3 100644
--- a/src/components/calendar/TasksSheet.tsx
+++ b/src/components/calendar/TasksSheet.tsx
@@ -27,7 +27,7 @@ import {
import { format, isBefore, startOfDay } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { getCalendarColor, getTaskDueDisplayDate, timePattern, type TimeFormat } from '../../lib/calendar-utils';
import { displayNow, isDisplayToday } from '../../lib/calendar-timezone';
import {
@@ -109,6 +109,7 @@ export function TasksSheet({
onDelete,
}: TasksSheetProps) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
@@ -373,7 +374,7 @@ export function TasksSheet({
onPress={() => setEditor((e) => ({ ...e, calendarId: cal.id }))}
style={[styles.calChip, cal.id === editor.calendarId && styles.calChipActive]}
>
-
+
{cal.name}
))}
diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx
index a5c3b2e0..7c3f2a05 100644
--- a/src/components/calendar/TimeGridScrollView.tsx
+++ b/src/components/calendar/TimeGridScrollView.tsx
@@ -18,7 +18,7 @@ import {
import { differenceInCalendarDays, format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayNumberLabel } from '../../lib/calendar-system';
@@ -125,6 +125,7 @@ function TimeGridScrollViewInner({
draftResetKey = 0,
}: TimeGridScrollViewProps) {
const c = useColors();
+ const paint = useCvdColor();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -455,7 +456,7 @@ function TimeGridScrollViewInner({
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
- getEventColor(segment.event, calendars),
+ paint(getEventColor(segment.event, calendars)),
isInactiveEvent(segment.event, currentUserEmails),
c,
tinted,
@@ -620,6 +621,7 @@ const DayColumn = React.memo(function DayColumn({
const { locale, t } = useCalendarLocale();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
+ const paint = useCvdColor();
const handleLongPress = React.useCallback(
(e: GestureResponderEvent) => {
onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
@@ -650,7 +652,7 @@ const DayColumn = React.memo(function DayColumn({
? minutesToTimeLabel(startMinutes, timeFormat)
: null}
colors={eventBlockColors(
- getEventColor(event, calendars),
+ paint(getEventColor(event, calendars)),
isInactiveEvent(event, currentUserEmails),
colors,
tinted,
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
index fb47be4b..2d47f51d 100644
--- a/src/components/calendar/WeekView.tsx
+++ b/src/components/calendar/WeekView.tsx
@@ -10,7 +10,7 @@ import { addDays, format, isSameDay, startOfWeek } from 'date-fns';
import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayNumberLabel } from '../../lib/calendar-system';
@@ -68,6 +68,7 @@ function WeekViewInner({
currentUserEmails,
}: WeekViewProps) {
const c = useColors();
+ const paint = useCvdColor();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -213,7 +214,7 @@ function WeekViewInner({
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
- getEventColor(segment.event, calendars),
+ paint(getEventColor(segment.event, calendars)),
isInactiveEvent(segment.event, currentUserEmails),
c,
tinted,
@@ -289,7 +290,7 @@ function WeekViewInner({
? minutesToTimeLabel(startMinutes, timeFormat)
: null}
colors={eventBlockColors(
- getEventColor(event, calendars),
+ paint(getEventColor(event, calendars)),
isInactiveEvent(event, currentUserEmails),
c,
tinted,
diff --git a/src/components/calendar/__tests__/calendar-cvd-colours.test.ts b/src/components/calendar/__tests__/calendar-cvd-colours.test.ts
new file mode 100644
index 00000000..e3bf4a95
--- /dev/null
+++ b/src/components/calendar/__tests__/calendar-cvd-colours.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { cvdColor } from '../../../lib/cvd-palette';
+
+// Checked on the source: the suite has no React Native renderer. Every place
+// that draws a calendar's colour runs it through useCvdColor() first, so the
+// colour-blind-safe setting recolours calendars as its description promises
+// (webmail cf4d02b49). The colour pickers keep the raw colours.
+const src = path.resolve(__dirname, '../../..');
+const DRAWERS = [
+ 'components/calendar/EventCard.tsx',
+ 'components/calendar/EventDetailSheet.tsx',
+ 'components/calendar/MonthView.tsx',
+ 'components/calendar/WeekView.tsx',
+ 'components/calendar/TimeGridScrollView.tsx',
+ 'components/calendar/CalendarSearchSheet.tsx',
+ 'components/calendar/CalendarSidebarDrawer.tsx',
+ 'components/calendar/EventModal.tsx',
+ 'components/calendar/TasksSheet.tsx',
+ 'components/calendar/ICalImportSheet.tsx',
+ 'components/contacts/ContactActivity.tsx',
+ 'components/email/CalendarInvitationBanner.tsx',
+ 'components/email/structured-mail/booking-calendar-actions.tsx',
+];
+
+function colourCalls(source: string): string[] {
+ return source
+ .split(/\r?\n/)
+ .filter((line) => !/^\s*import\b/.test(line) && /\bget(Event|Calendar)Color\(/.test(line))
+ .map((line) => line.trim());
+}
+
+describe('calendar colours in the colour-blind-safe palette', () => {
+ it.each(DRAWERS)('%s paints every calendar colour it draws', (file) => {
+ const source = fs.readFileSync(path.join(src, file), 'utf8');
+ expect(source).toMatch(/const paint = useCvdColor\(\);/);
+ const calls = colourCalls(source);
+ expect(calls.length).toBeGreaterThan(0);
+ for (const line of calls) {
+ expect(line).toMatch(/paint\(get(Event|Calendar)Color\(/);
+ }
+ });
+
+ it('keeps the raw colour in the calendar editor, which is a colour picker', () => {
+ const source = fs.readFileSync(path.join(src, 'components/calendar/CalendarEditSheet.tsx'), 'utf8');
+ expect(source).not.toMatch(/useCvdColor/);
+ });
+
+ it('paints a subscription swatch through the hook', () => {
+ const source = fs.readFileSync(path.join(src, 'components/calendar/ICalSubscriptionSheet.tsx'), 'utf8');
+ expect(source).toMatch(/paint\(sub\.color\)/);
+ });
+
+ it('maps a calendar green to the palette green only when the setting is on', () => {
+ expect(cvdColor('#22c55e', false)).toBe('#22c55e');
+ expect(cvdColor('#22c55e', true).toLowerCase()).not.toBe('#22c55e');
+ });
+});
diff --git a/src/components/contacts/ContactActivity.tsx b/src/components/contacts/ContactActivity.tsx
index 803dfdf5..b76e8219 100644
--- a/src/components/contacts/ContactActivity.tsx
+++ b/src/components/contacts/ContactActivity.tsx
@@ -17,7 +17,7 @@ import { singleLine } from '../../lib/single-line';
import { prefetchMessage } from '../../lib/email-detail-cache';
import SenderAvatar from '../SenderAvatar';
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
-import { useColors } from '../../theme/colors';
+import { useColors, useCvdColor } from '../../theme/colors';
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
@@ -92,6 +92,7 @@ interface Props {
export function ContactActivity({ contact }: Props) {
const c = useColors();
+ const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation