feat: draw calendar colours in the colour-blind-safe palette when that setting is on

This commit is contained in:
Linus Rath
2026-10-31 15:05:48 +01:00
parent 6e5980e4e0
commit 21dd5a2405
15 changed files with 110 additions and 35 deletions
@@ -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(' · ');
@@ -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)),
},
]}
>
+3 -2
View File
@@ -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));
+3 -2
View File
@@ -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);
+4 -3
View File
@@ -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({
<View
style={[
styles.calendarSwatch,
{ backgroundColor: getCalendarColor(selectedCalendar) },
{ backgroundColor: paint(getCalendarColor(selectedCalendar)) },
]}
/>
)}
@@ -614,7 +615,7 @@ export function EventModal({
<View
style={[
styles.calendarSwatch,
{ backgroundColor: getCalendarColor(c) },
{ backgroundColor: paint(getCalendarColor(c)) },
]}
/>
<Text style={styles.popoverRowText}>{c.name}</Text>
+3 -2
View File
@@ -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<CalendarEvent>, 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]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.calSwatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
@@ -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 (
<View key={sub.id} style={styles.subRow}>
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
<View style={[styles.subSwatch, { backgroundColor: sub.color ? paint(sub.color) : c.primary }]} />
<Pressable style={{ flex: 1, minWidth: 0 }} onPress={() => startEdit(sub)}>
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
<Text style={styles.subMeta} numberOfLines={1}>
+6 -4
View File
@@ -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) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
style={[styles.dot, { backgroundColor: paint(getEventColor(event, calendars)) }]}
/>
))}
{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 (
<View
key={`${event.id}-${idx}`}
@@ -331,6 +332,7 @@ function FullWeekRow({
const { t, system } = useCalendarLocale();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const paint = useCvdColor();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
<View style={[styles.fullWeekRow, { height }]}>
@@ -371,7 +373,7 @@ function FullWeekRow({
</View>
{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 })} `
+3 -2
View File
@@ -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]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.calSwatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
@@ -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,
+4 -3
View File
@@ -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,
@@ -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');
});
});
+3 -2
View File
@@ -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<Nav>();
const t = useLocaleStore((s) => s.t);
@@ -254,7 +255,7 @@ export function ContactActivity({ contact }: Props) {
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<View
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
style={[styles.eventDot, { backgroundColor: paint(getEventColor(event, calendars)) }]}
/>
<Text style={styles.eventTime}>{formatEventTime(event, t)}</Text>
<View style={styles.eventInfo}>
@@ -8,7 +8,7 @@ import {
import { format, isSameDay } from 'date-fns';
import type { Calendar, Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useColors, useCvdColor } from '../../theme/colors';
import { fetchCalendarBlobText, findEventsByUid, parseCalendarBlob } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useSettingsStore } from '../../stores/settings-store';
@@ -72,6 +72,7 @@ function trustReasonText(
export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const c = useColors();
const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
@@ -390,7 +391,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
{state !== 'done' && !existing && targetCalendar && candidates.length > 1 && (
<View>
<Pressable style={styles.calendarPicker} onPress={() => setPickerOpen((v) => !v)}>
<View style={[styles.calendarSwatch, { backgroundColor: getCalendarColor(targetCalendar) }]} />
<View style={[styles.calendarSwatch, { backgroundColor: paint(getCalendarColor(targetCalendar)) }]} />
<Text style={styles.calendarPickerText} numberOfLines={1}>{targetCalendar.name}</Text>
<ChevronDown size={14} color={c.textMuted} />
</Pressable>
@@ -402,7 +403,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
style={[styles.calendarRow, cal.id === targetCalendar.id && styles.calendarRowActive]}
onPress={() => { setCalendarId(cal.id); setPickerOpen(false); }}
>
<View style={[styles.calendarSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.calendarSwatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.calendarPickerText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
@@ -11,7 +11,7 @@ import { getCalendarColor } from '../../../lib/calendar-utils';
import type { StructuredCard } from '../../../lib/structured-mail';
import type { RootStackParamList } from '../../../navigation/types';
import { useLocaleStore } from '../../../stores/locale-store';
import { useColors } from '../../../theme/colors';
import { useColors, useCvdColor } from '../../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
import { ArrowRight, CalendarCheck, CalendarDays, CalendarPlus, CalendarX, ChevronDown } from '../../icons';
import { useBookingCalendar } from './use-booking-calendar';
@@ -27,6 +27,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
*/
export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick<Email, 'from' | 'subject'> }) {
const c = useColors();
const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<Nav>();
@@ -144,7 +145,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
accessibilityLabel={cal.name}
testID="booking-calendar-choice"
>
<View style={[styles.swatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.swatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.pickerText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}