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