From 0d3916cfccf23f7a93a0bfa59a9c0b2e720359ca Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:50:47 +0100 Subject: [PATCH] feat: add a light event colors setting that shows calendar events tinted with a color bar, as in Outlook --- .../calendar/__tests__/event-card.test.tsx | 24 ++++++++++++++- components/calendar/calendar-day-view.tsx | 10 +++---- components/calendar/calendar-week-view.tsx | 9 +++--- components/calendar/event-card.tsx | 30 +++++++++++++++---- components/settings/calendar-settings.tsx | 11 +++++++ lib/settings-search.ts | 1 + locales/ar/common.json | 2 ++ locales/ca/common.json | 2 ++ locales/cs/common.json | 2 ++ locales/da/common.json | 2 ++ locales/de/common.json | 2 ++ locales/en/common.json | 2 ++ locales/es/common.json | 2 ++ locales/et/common.json | 2 ++ locales/fa/common.json | 2 ++ locales/fr/common.json | 2 ++ locales/he/common.json | 2 ++ locales/hu/common.json | 2 ++ locales/it/common.json | 2 ++ locales/ja/common.json | 2 ++ locales/ko/common.json | 2 ++ locales/lv/common.json | 2 ++ locales/mn/common.json | 2 ++ locales/nb/common.json | 2 ++ locales/nl/common.json | 2 ++ locales/pl/common.json | 2 ++ locales/pt/common.json | 2 ++ locales/ro/common.json | 2 ++ locales/ru/common.json | 2 ++ locales/sk/common.json | 2 ++ locales/th/common.json | 2 ++ locales/tr/common.json | 2 ++ locales/uk/common.json | 2 ++ locales/zh-TW/common.json | 2 ++ locales/zh/common.json | 2 ++ stores/settings-store.ts | 4 +++ 36 files changed, 130 insertions(+), 17 deletions(-) diff --git a/components/calendar/__tests__/event-card.test.tsx b/components/calendar/__tests__/event-card.test.tsx index 890b8e97f..2d76501b7 100644 --- a/components/calendar/__tests__/event-card.test.tsx +++ b/components/calendar/__tests__/event-card.test.tsx @@ -1,6 +1,7 @@ import { render, screen, cleanup } from '@testing-library/react'; -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; import { EventCard } from '../event-card'; +import { useSettingsStore } from '@/stores/settings-store'; import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; // #1110: an invitation the user declined - the whole series or one occurrence @@ -89,4 +90,25 @@ describe('EventCard fill', () => { expect(shadow).toContain('0 0 0 2px var(--color-primary)'); expect(shadow).toContain('inset 0 0 0 1px'); }); + + describe('with light event colours', () => { + beforeEach(() => useSettingsStore.setState({ calendarTintedEvents: true })); + afterEach(() => useSettingsStore.setState({ calendarTintedEvents: false })); + + it.each(['chip', 'span', 'block'] as const)('tints the %s and draws the calendar colour as a bar at its start', (variant) => { + render(); + const card = screen.getByRole('button'); + expect(card).toHaveClass('start-bar'); + expect(card.style.getPropertyValue('--start-bar')).toBe('#22c55e'); + expect(card.style.backgroundColor).not.toBe('rgb(34, 197, 94)'); + expect(card.style.color).toBe('var(--color-foreground)'); + }); + + it('keeps the outline of a declined event, without a bar', () => { + render(); + const card = screen.getByRole('button'); + expect(card).not.toHaveClass('start-bar'); + expect(card.style.boxShadow).toContain('inset 0 0 0 1px'); + }); + }); }); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index db225598a..3158d35dd 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -5,7 +5,8 @@ import { useTranslations } from "next-intl"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { format, isSameDay, eachDayOfInterval, differenceInCalendarDays } from "date-fns"; import { cn } from "@/lib/utils"; -import { EventCard } from "./event-card"; +import { useSettingsStore } from "@/stores/settings-store"; +import { EventCard, eventFillClass, eventFillStyle } from "./event-card"; import { CalendarTaskChip } from "./task-chip"; import { QuickEventInput } from "./quick-event-input"; import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls"; @@ -21,7 +22,6 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from " import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message"; -import { readableTextOn } from "@/lib/color-transform"; import { PluginDayLabel } from "./plugin-day-label"; interface CalendarDayViewProps extends ScrollWindowViewProps { @@ -93,6 +93,7 @@ export function CalendarDayView({ revealAt, }: CalendarDayViewProps) { const t = useTranslations("calendar"); + const tintedEvents = useSettingsStore((s) => s.calendarTintedEvents); const describeError = useCalendarErrorMessage(); // Grid days / event dates are display dates (local fields = wall-clock in // the user's zone); the app-wide formatter would shift them again (#755). @@ -654,12 +655,11 @@ export function CalendarDayView({ return (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 20618acfb..6e54b307b 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -7,7 +7,7 @@ import { startOfWeek, format, isSameDay, eachDayOfInterval, } from "date-fns"; import { cn } from "@/lib/utils"; -import { EventCard } from "./event-card"; +import { EventCard, eventFillClass, eventFillStyle } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; import { CalendarTaskChip } from "./task-chip"; import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls"; @@ -24,7 +24,6 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from " import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message"; -import { readableTextOn } from "@/lib/color-transform"; import { PluginDayLabel } from "./plugin-day-label"; interface CalendarWeekViewProps extends ScrollWindowViewProps { @@ -107,6 +106,7 @@ export function CalendarWeekView({ revealAt, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); + const tintedEvents = useSettingsStore((s) => s.calendarTintedEvents); const describeError = useCalendarErrorMessage(); // Grid days / event dates are display dates (local fields = wall-clock in // the user's zone); the app-wide formatter would shift them again (#755). @@ -839,12 +839,11 @@ export function CalendarWeekView({ return (
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index d5dacd07a..d2b1b3efa 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -55,11 +55,14 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string { // Events are solid blocks of their calendar colour; declined and cancelled // ones drop the fill and keep a 1px border in it (repos/branding/APP.md). +// With light event colours on (the Outlook look, as on the printout) an +// event is a light tint of its colour with a bar in it at the start +// (eventFillClass) and text in the text colour. // Everything is drawn with box-shadow, so the selection ring joins the list // here and the browser's focus outline stays free. -function eventFillStyle( +export function eventFillStyle( color: string, - { inactive, separated, selected }: { inactive: boolean; separated?: boolean; selected?: boolean }, + { inactive, separated, selected, tinted }: { inactive: boolean; separated?: boolean; selected?: boolean; tinted?: boolean }, ): CSSProperties { const shadows: string[] = []; if (selected) shadows.push("0 0 0 2px var(--color-primary)"); @@ -67,10 +70,21 @@ function eventFillStyle( if (inactive) shadows.push(`inset 0 0 0 1px ${color}`); const fill: CSSProperties = inactive ? { backgroundColor: "var(--color-background)", color: `color-mix(in srgb, ${color} 55%, var(--color-foreground))` } - : { backgroundColor: color, color: readableTextOn(color) }; + : tinted + ? { + backgroundColor: `color-mix(in srgb, ${color} 20%, var(--color-background))`, + color: "var(--color-foreground)", + ["--start-bar" as string]: color, + } + : { backgroundColor: color, color: readableTextOn(color) }; return shadows.length > 0 ? { ...fill, boxShadow: shadows.join(", ") } : fill; } +/** The class that draws the light style's bar; declined and cancelled events keep their outline. */ +export function eventFillClass(tinted: boolean, inactive: boolean): string | undefined { + return tinted && !inactive ? "start-bar" : undefined; +} + function parseDuration(duration: string | undefined): number { if (!duration) return 0; let totalMinutes = 0; @@ -107,6 +121,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM const startDate = getEventStartDate(event); const timeFormat = useSettingsStore((state) => state.timeFormat); const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); + const tinted = useSettingsStore((state) => state.calendarTintedEvents); const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const calendarName = calendar?.name || ""; @@ -193,9 +208,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM "min-h-[44px] sm:min-h-0", "hover:opacity-90 transition-opacity", isBeingDragged && "opacity-50", + eventFillClass(tinted, isInactive), className )} - style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }} + style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected, tinted }), ...style }} > {/* The whole width, aligned to the title's own start: a short English title in Arabic sat at the right, a cut one filled to the left. */} @@ -221,9 +237,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM continuesBefore && "rounded-s-none", continuesAfter && "rounded-e-none pe-2", isBeingDragged && "opacity-50", + eventFillClass(tinted, isInactive), className )} - style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }} + style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected, tinted }), ...style }} >
{showTimeInMonthView && !event.showWithoutTime && ( @@ -259,9 +276,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM continuesAfter && "rounded-b-none", continuesBefore && "pe-4", isBeingDragged && "opacity-50", + eventFillClass(tinted, isInactive), className )} - style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected }), ...style }} + style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected, tinted }), ...style }} > {continuesBefore &&