mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: draw calendar, tag and folder colours in a colour-blind-safe palette when that setting is on
This commit is contained in:
@@ -112,3 +112,19 @@ describe('EventCard fill', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventCard colour settings', () => {
|
||||
const calendar = { id: 'cal-1', name: 'Work', color: '#22c55e' } as unknown as Calendar;
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useSettingsStore.setState({ showColorNames: false, colorBlindPalette: false });
|
||||
});
|
||||
|
||||
it("draws the event in the colour-blind-safe colour and keeps the calendar's own", () => {
|
||||
useSettingsStore.setState({ colorBlindPalette: true });
|
||||
render(<EventCard event={makeOccurrence('a', 'accepted')} calendar={calendar} variant="block" currentUserEmails={me} />);
|
||||
// #009E73, the palette's bluish green
|
||||
expect(screen.getByRole('button').style.backgroundColor).toBe('rgb(0, 158, 115)');
|
||||
expect(calendar.color).toBe('#22c55e');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participan
|
||||
import { useScrollWindow } from "@/hooks/use-scroll-window";
|
||||
import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface CalendarAgendaViewProps extends ScrollWindowViewProps {
|
||||
events: CalendarEvent[];
|
||||
@@ -55,6 +56,7 @@ export function CalendarAgendaView({
|
||||
onCreateEvent,
|
||||
}: CalendarAgendaViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
// 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).
|
||||
const intlFormatter = useDisplayDateFormatter();
|
||||
@@ -240,7 +242,7 @@ export function CalendarAgendaView({
|
||||
{group.events.map((ev) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const color = paint(getEventColor(ev, calendar));
|
||||
const start = getEventStartDate(ev);
|
||||
const end = getEventEndDate(ev);
|
||||
// iTIP CANCEL marks the attendee's copy with status "cancelled"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface CalendarDayViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -93,6 +94,7 @@ export function CalendarDayView({
|
||||
revealAt,
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tintedEvents = useSettingsStore((s) => s.calendarTintedEvents);
|
||||
const describeError = useCalendarErrorMessage();
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
@@ -649,7 +651,7 @@ export function CalendarDayView({
|
||||
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
|
||||
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
|
||||
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
const color = paint(cal?.color || "#3b82f6");
|
||||
// Drawn like a saved event, so the new event shows where
|
||||
// it will land while it is being created.
|
||||
return (
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -94,6 +95,7 @@ export function CalendarMonthView({
|
||||
onMoveEvent,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const describeError = useCalendarErrorMessage();
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
// On mobile the month view collapses events to dots unless the user opted
|
||||
@@ -469,7 +471,7 @@ export function CalendarMonthView({
|
||||
{dayEvents.slice(0, 3).map((ev) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||
const evColor = paint(ev.color || cal?.color || "#3b82f6");
|
||||
const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
|
||||
return (
|
||||
<span
|
||||
@@ -487,7 +489,7 @@ export function CalendarMonthView({
|
||||
key={`task-${task.id}`}
|
||||
data-calendar-task={task.id}
|
||||
className={cn("w-1.5 h-1.5 rounded-full border", task.progress === "completed" && "opacity-40")}
|
||||
style={{ borderColor: (calId && calendarMap.get(calId)?.color) || "#3b82f6" }}
|
||||
style={{ borderColor: paint((calId && calendarMap.get(calId)?.color) || "#3b82f6") }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -497,7 +499,7 @@ export function CalendarMonthView({
|
||||
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||
style={{ borderColor: paint(calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6") }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -512,7 +514,7 @@ export function CalendarMonthView({
|
||||
if (previewDayIdx === -1) return null;
|
||||
const previewRow = Math.max(rowCount, dayEventRows[previewDayIdx] + dayTasks[previewDayIdx].length);
|
||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
const color = paint(cal?.color || "#3b82f6");
|
||||
return (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
|
||||
<div
|
||||
|
||||
@@ -17,6 +17,7 @@ import { displayNow } from "@/lib/timezone";
|
||||
import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
|
||||
import type { CalendarSearchResults as Results } from "@/hooks/use-calendar-event-search";
|
||||
import { getEventColor } from "./event-card";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
const RESULT_SELECTOR = "[data-search-result]";
|
||||
|
||||
@@ -83,6 +84,7 @@ export function CalendarSearchResults({
|
||||
query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, onClearSearch,
|
||||
}: CalendarSearchResultsProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const intlFormatter = useDisplayDateFormatter();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const calendarMap = useMemo(() => new Map(calendars.map((c) => [c.id, c])), [calendars]);
|
||||
@@ -108,7 +110,7 @@ export function CalendarSearchResults({
|
||||
const renderRow = (ev: CalendarEvent) => {
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const color = paint(getEventColor(ev, calendar));
|
||||
const start = getEventStartDate(ev);
|
||||
const end = getEventEndDate(ev);
|
||||
const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
/**
|
||||
* Split a per-account calendar list into "owned" (the user's own) and
|
||||
@@ -92,6 +93,7 @@ export function CalendarSidebarPanel({
|
||||
multiAccountMode,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
@@ -277,7 +279,7 @@ export function CalendarSidebarPanel({
|
||||
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
const color = paint(cal.color || "#3b82f6");
|
||||
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface CalendarToolbarProps {
|
||||
selectedDate: Date;
|
||||
@@ -102,6 +103,7 @@ export function CalendarToolbar({
|
||||
onCloseSearch,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tCommon = useTranslations("common");
|
||||
const {
|
||||
weekStartsOn,
|
||||
@@ -316,7 +318,7 @@ export function CalendarToolbar({
|
||||
<div className="space-y-0.5">
|
||||
{calendars.filter(c => !c.isShared).map((cal) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
const color = paint(cal.color || "#3b82f6");
|
||||
return (
|
||||
<button
|
||||
key={cal.id}
|
||||
@@ -356,7 +358,7 @@ export function CalendarToolbar({
|
||||
<div className="space-y-0.5">
|
||||
{group.cals.map((cal) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
const color = paint(cal.color || "#3b82f6");
|
||||
return (
|
||||
<button
|
||||
key={cal.id}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -106,6 +107,7 @@ export function CalendarWeekView({
|
||||
revealAt,
|
||||
}: CalendarWeekViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tintedEvents = useSettingsStore((s) => s.calendarTintedEvents);
|
||||
const describeError = useCalendarErrorMessage();
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
@@ -575,7 +577,7 @@ export function CalendarWeekView({
|
||||
{pendingPreview?.allDay && (() => {
|
||||
const index = days.findIndex((d) => isSameDay(d, pendingPreview.start));
|
||||
if (index === -1) return null;
|
||||
const color = calendars.find((c) => c.id === pendingPreview.calendarId)?.color || "#3b82f6";
|
||||
const color = paint(calendars.find((c) => c.id === pendingPreview.calendarId)?.color || "#3b82f6");
|
||||
return (
|
||||
<div
|
||||
data-pending-preview
|
||||
@@ -833,7 +835,7 @@ export function CalendarWeekView({
|
||||
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
|
||||
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
|
||||
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
const color = paint(cal?.color || "#3b82f6");
|
||||
// Drawn like a saved event, so the new event shows where
|
||||
// it will land while it is being created.
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
@@ -117,7 +118,8 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
const paint = useCvdColor();
|
||||
const color = paint(getEventColor(event, calendar));
|
||||
const startDate = getEventStartDate(event);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
|
||||
@@ -32,6 +32,7 @@ import { eventToView } from "@/lib/plugin-projection";
|
||||
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface EventDetailPopoverProps {
|
||||
event: CalendarEvent;
|
||||
@@ -140,6 +141,7 @@ export function EventDetailPopover({
|
||||
pinned = false,
|
||||
}: EventDetailPopoverProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -154,7 +156,7 @@ export function EventDetailPopover({
|
||||
// render with the contact card's name instead of the raw email.
|
||||
const resolveContactName = useContactNameResolver();
|
||||
|
||||
const color = getEventColor(event, calendar);
|
||||
const color = paint(getEventColor(event, calendar));
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = getEventEndDate(event);
|
||||
|
||||
@@ -50,6 +50,7 @@ import { eventToView } from "@/lib/plugin-projection";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -267,6 +268,7 @@ export function EventModal({
|
||||
isMobile = false,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
@@ -988,7 +990,7 @@ export function EventModal({
|
||||
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
|
||||
const alertLabel = formatEventAlertsLabel(event, calendars, t);
|
||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||
const color = getEventColor(event, eventCalendar);
|
||||
const color = paint(getEventColor(event, eventCalendar));
|
||||
|
||||
return (
|
||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Clock, X } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
export interface QuickCreateDraft {
|
||||
start: Date;
|
||||
@@ -66,6 +67,7 @@ export function QuickCreatePopover({
|
||||
onClose,
|
||||
}: QuickCreatePopoverProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tCommon = useTranslations("common");
|
||||
const intlFormatter = useDisplayDateFormatter();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -189,7 +191,7 @@ export function QuickCreatePopover({
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="w-4 h-4 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: calendars.find((c) => c.id === draft.calendarId)?.color || "#3b82f6" }}
|
||||
style={{ backgroundColor: paint(calendars.find((c) => c.id === draft.calendarId)?.color || "#3b82f6") }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<select
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "@/components/icons";
|
||||
import type { Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface CalendarTaskChipProps {
|
||||
task: CalendarTask;
|
||||
@@ -23,8 +24,9 @@ interface CalendarTaskChipProps {
|
||||
*/
|
||||
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, touch = false, tabbable = true, className }: CalendarTaskChipProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const isCompleted = task.progress === "completed";
|
||||
const color = calendar?.color || "#3b82f6";
|
||||
const color = paint(calendar?.color || "#3b82f6");
|
||||
const title = task.title || t("tasks.no_title");
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
interface TaskListViewProps {
|
||||
tasks: CalendarTask[];
|
||||
@@ -125,6 +126,7 @@ export function TaskListView({
|
||||
onQuickCreate,
|
||||
}: TaskListViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const { dateTime: formatDate } = useDisplayDateFormatter();
|
||||
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||
@@ -249,7 +251,7 @@ export function TaskListView({
|
||||
)}
|
||||
{cal && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: cal.color || "#3b82f6" }} />
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: paint(cal.color || "#3b82f6") }} />
|
||||
{cal.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { formatDateTime, cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
|
||||
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
|
||||
|
||||
@@ -171,9 +172,10 @@ function CalendarPanel({ onClose }: { onClose: () => void }) {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = hour * HOUR_PX;
|
||||
}, [day]);
|
||||
|
||||
const paint = useCvdColor();
|
||||
const colorOf = (event: CalendarEvent) => {
|
||||
const calId = Object.keys(event.calendarIds ?? {})[0];
|
||||
return event.color || calendars.find((c) => c.id === calId)?.color || "var(--color-primary)";
|
||||
return paint(event.color || calendars.find((c) => c.id === calId)?.color || "var(--color-primary)");
|
||||
};
|
||||
|
||||
const visible = events.filter((e) => {
|
||||
|
||||
@@ -195,17 +195,20 @@ const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: bo
|
||||
|
||||
// Role tints from the theme's tokens (R11), so a theme recolours them and
|
||||
// dark mode has its own values: the chart palette for the main roles.
|
||||
// With colour-blind-safe colours on (<html data-cvd-palette="on">) each
|
||||
// colour is swapped for one of the palette in lib/cvd-palette.ts. Written
|
||||
// out in full so Tailwind generates the classes.
|
||||
const ROLE_ICON_COLOR: Record<string, string> = {
|
||||
inbox: "text-chart-1/80",
|
||||
sent: "text-chart-2/80",
|
||||
drafts: "text-chart-5/80",
|
||||
inbox: "text-chart-1/80 [[data-cvd-palette=on]_&]:text-[#0072B2]",
|
||||
sent: "text-chart-2/80 [[data-cvd-palette=on]_&]:text-[#009E73]",
|
||||
drafts: "text-chart-5/80 [[data-cvd-palette=on]_&]:text-[#CC79A7]",
|
||||
trash: "text-muted-foreground",
|
||||
junk: "text-chart-4/80",
|
||||
archive: "text-chart-3/80",
|
||||
shared: "text-info/80",
|
||||
important: "text-warning/80",
|
||||
memos: "text-warning/80",
|
||||
scheduled: "text-info/80",
|
||||
junk: "text-chart-4/80 [[data-cvd-palette=on]_&]:text-[#D55E00]",
|
||||
archive: "text-chart-3/80 [[data-cvd-palette=on]_&]:text-[#56B4E9]",
|
||||
shared: "text-info/80 [[data-cvd-palette=on]_&]:text-[#F0E442]",
|
||||
important: "text-warning/80 [[data-cvd-palette=on]_&]:text-[#E69F00]",
|
||||
memos: "text-warning/80 [[data-cvd-palette=on]_&]:text-[#E69F00]",
|
||||
scheduled: "text-info/80 [[data-cvd-palette=on]_&]:text-[#F0E442]",
|
||||
snoozed: "text-muted-foreground",
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,21 @@ describe('useKeywordFormat', () => {
|
||||
});
|
||||
|
||||
describe('tagColor', () => {
|
||||
it('draws tags in the colour-blind-safe palette while that setting is on', () => {
|
||||
useSettingsStore.setState({ colorBlindPalette: true });
|
||||
try {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
// Blue and green, which red-green colour blindness can confuse, land on
|
||||
// the palette's blue and bluish green; the stored colours stay.
|
||||
expect(result.current.tagColor('work').dot).toBe('bg-[#0072B2]');
|
||||
expect(result.current.tagColor('work/clients').dot).toBe('bg-[#009E73]');
|
||||
expect(result.current.tagColor('archive').dot).toBe('bg-[#D55E00]');
|
||||
expect(useSettingsStore.getState().emailKeywords[0].color).toBe('blue');
|
||||
} finally {
|
||||
useSettingsStore.setState({ colorBlindPalette: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a tag to its palette entry, including the new shades', () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { cvdColor } from "@/lib/cvd-palette";
|
||||
|
||||
/**
|
||||
* Maps a stored colour (calendar, event) to the colour it is drawn in: the
|
||||
* colour-blind-safe one when that setting is on, else itself. The stored
|
||||
* colour never changes.
|
||||
*/
|
||||
export function useCvdColor(): (color: string) => string {
|
||||
const enabled = useSettingsStore((s) => s.colorBlindPalette);
|
||||
return useCallback((color: string) => cvdColor(color, enabled), [enabled]);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
import { suggestKeywordColor } from "@/lib/keyword-discovery";
|
||||
import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
|
||||
import { CVD_CLASSES, cvdHue } from "@/lib/cvd-palette";
|
||||
|
||||
/**
|
||||
* Names and colours tags for the screen, bound to the user's tag settings.
|
||||
@@ -27,6 +28,7 @@ import { useAccountTags, type TagScope } from "@/hooks/use-account-tags";
|
||||
export function useKeywordFormat(scope?: TagScope | null) {
|
||||
const keywords = useAccountTags(scope).definitions;
|
||||
const nested = useSettingsStore((state) => state.nestedTags);
|
||||
const colorBlindPalette = useSettingsStore((state) => state.colorBlindPalette);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
@@ -50,9 +52,15 @@ export function useKeywordFormat(scope?: TagScope | null) {
|
||||
* the proposal names a colour the palette does not hold.
|
||||
*/
|
||||
tagColor: (id: string): KeywordColor => {
|
||||
const color = keywords.find((keyword) => keyword.id === id)?.color;
|
||||
if (color && KEYWORD_PALETTE[color]) return KEYWORD_PALETTE[color];
|
||||
return KEYWORD_PALETTE[suggestKeywordColor(id)] ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR];
|
||||
const stored = keywords.find((keyword) => keyword.id === id)?.color;
|
||||
const suggested = suggestKeywordColor(id);
|
||||
const color = stored && KEYWORD_PALETTE[stored]
|
||||
? stored
|
||||
: KEYWORD_PALETTE[suggested] ? suggested : FALLBACK_KEYWORD_COLOR;
|
||||
// Colour-blind-safe colours: the same tag, drawn in the palette
|
||||
// colour nearest its own (lib/cvd-palette.ts). The stored colour stays.
|
||||
if (colorBlindPalette) return CVD_CLASSES[cvdHue(color) ?? "grey"];
|
||||
return KEYWORD_PALETTE[color];
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -74,6 +82,6 @@ export function useKeywordFormat(scope?: TagScope | null) {
|
||||
);
|
||||
},
|
||||
}),
|
||||
[keywords, nested],
|
||||
[keywords, nested, colorBlindPalette],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CVD_COLORS, cvdColor, cvdHue } from '../cvd-palette';
|
||||
|
||||
describe('cvdHue', () => {
|
||||
it('maps the default calendar and tag colours onto separate palette colours', () => {
|
||||
expect(cvdHue('#ef4444')).toBe('vermillion');
|
||||
expect(cvdHue('#f97316')).toBe('orange');
|
||||
expect(cvdHue('#eab308')).toBe('yellow');
|
||||
expect(cvdHue('#22c55e')).toBe('bluishGreen');
|
||||
expect(cvdHue('#06b6d4')).toBe('skyBlue');
|
||||
expect(cvdHue('#3b82f6')).toBe('blue');
|
||||
expect(cvdHue('#a855f7')).toBe('reddishPurple');
|
||||
});
|
||||
|
||||
it('keeps red and green apart, the pair red-green colour blindness merges', () => {
|
||||
expect(cvdHue('#dc2626')).not.toBe(cvdHue('#16a34a'));
|
||||
expect(cvdHue('#b91c1c')).toBe('vermillion');
|
||||
expect(cvdHue('#15803d')).toBe('bluishGreen');
|
||||
});
|
||||
|
||||
it('reads tag palette names, with their light and dark shades', () => {
|
||||
expect(cvdHue('red')).toBe('vermillion');
|
||||
expect(cvdHue('teal-light')).toBe('skyBlue');
|
||||
expect(cvdHue('indigo-dark')).toBe('blue');
|
||||
expect(cvdHue('gray')).toBe('grey');
|
||||
});
|
||||
|
||||
it('reads short hex, rgb() and hsl()', () => {
|
||||
expect(cvdHue('#f00')).toBe('vermillion');
|
||||
expect(cvdHue('rgb(0, 128, 0)')).toBe('bluishGreen');
|
||||
expect(cvdHue('hsl(220, 80%, 50%)')).toBe('blue');
|
||||
});
|
||||
|
||||
it('turns greys grey and gives up on what it cannot read', () => {
|
||||
expect(cvdHue('#777777')).toBe('grey');
|
||||
expect(cvdHue('var(--color-primary)')).toBeNull();
|
||||
expect(cvdHue('')).toBeNull();
|
||||
expect(cvdHue(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cvdColor', () => {
|
||||
it('draws a colour as it is while the setting is off', () => {
|
||||
expect(cvdColor('#3b82f6', false)).toBe('#3b82f6');
|
||||
});
|
||||
|
||||
it('draws the palette colour while it is on, and leaves unreadable colours alone', () => {
|
||||
expect(cvdColor('#3b82f6', true)).toBe(CVD_COLORS.blue);
|
||||
expect(cvdColor('var(--color-primary)', true)).toBe('var(--color-primary)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The colour-blind-safe palette behind Settings > Accessibility >
|
||||
* "Colour-blind-safe colours" (colorBlindPalette).
|
||||
*
|
||||
* Tags, folders and calendars keep the colours the user picked; this only
|
||||
* changes how they are drawn. Every colour is moved to the nearest of the
|
||||
* Okabe-Ito colours by hue: eight colours chosen to stay apart for
|
||||
* protanopia, deuteranopia and tritanopia (Okabe & Ito, "Color Universal
|
||||
* Design", 2008). Two picked colours of a similar hue (blue and indigo) can
|
||||
* land on the same one; the names next to them still tell them apart.
|
||||
*/
|
||||
|
||||
export const CVD_COLORS = {
|
||||
vermillion: "#D55E00",
|
||||
orange: "#E69F00",
|
||||
yellow: "#F0E442",
|
||||
bluishGreen: "#009E73",
|
||||
skyBlue: "#56B4E9",
|
||||
blue: "#0072B2",
|
||||
reddishPurple: "#CC79A7",
|
||||
grey: "#999999",
|
||||
} as const;
|
||||
|
||||
export type CvdHue = keyof typeof CVD_COLORS;
|
||||
|
||||
/** The hues of the tag palette (stores/settings-store KEYWORD_PALETTE), as Tailwind's 500 shades. */
|
||||
const PALETTE_HUES: Record<string, string> = {
|
||||
red: "#ef4444",
|
||||
orange: "#f97316",
|
||||
amber: "#f59e0b",
|
||||
yellow: "#eab308",
|
||||
lime: "#84cc16",
|
||||
green: "#22c55e",
|
||||
teal: "#14b8a6",
|
||||
cyan: "#06b6d4",
|
||||
blue: "#3b82f6",
|
||||
indigo: "#6366f1",
|
||||
purple: "#a855f7",
|
||||
pink: "#ec4899",
|
||||
gray: "#6b7280",
|
||||
};
|
||||
|
||||
/** Hue (0-360) and saturation (0-1) of a colour, or null when it can't be read. */
|
||||
function hueAndSaturation(color: string): { hue: number; saturation: number } | null {
|
||||
let r: number, g: number, b: number;
|
||||
const value = color.trim().toLowerCase();
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(value);
|
||||
if (hex) {
|
||||
let digits = hex[1];
|
||||
if (digits.length === 3 || digits.length === 4) digits = digits.slice(0, 3).split("").map((d) => d + d).join("");
|
||||
if (digits.length !== 6 && digits.length !== 8) return null;
|
||||
r = parseInt(digits.slice(0, 2), 16);
|
||||
g = parseInt(digits.slice(2, 4), 16);
|
||||
b = parseInt(digits.slice(4, 6), 16);
|
||||
} else {
|
||||
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(value);
|
||||
const hsl = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/.exec(value);
|
||||
if (rgb) {
|
||||
[r, g, b] = [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
||||
} else if (hsl) {
|
||||
return { hue: Number(hsl[1]) % 360, saturation: Number(hsl[2]) / 100 };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const delta = max - min;
|
||||
const lightness = (max + min) / 2;
|
||||
const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === rn) hue = ((gn - bn) / delta) % 6;
|
||||
else if (max === gn) hue = (bn - rn) / delta + 2;
|
||||
else hue = (rn - gn) / delta + 4;
|
||||
hue *= 60;
|
||||
if (hue < 0) hue += 360;
|
||||
}
|
||||
return { hue, saturation };
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette colour for `color`: a CSS colour (#hex, rgb(), hsl()) or a
|
||||
* tag palette name ("red", "teal-light"). Null when it can't be read.
|
||||
*/
|
||||
export function cvdHue(color: string | null | undefined): CvdHue | null {
|
||||
if (!color) return null;
|
||||
const paletteName = color.replace(/-(light|dark)$/, "");
|
||||
const parsed = hueAndSaturation(PALETTE_HUES[paletteName] ?? color);
|
||||
if (!parsed) return null;
|
||||
const { hue, saturation } = parsed;
|
||||
if (saturation < 0.15) return "grey";
|
||||
if (hue < 18 || hue >= 345) return "vermillion";
|
||||
if (hue < 44) return "orange";
|
||||
if (hue < 70) return "yellow";
|
||||
if (hue < 165) return "bluishGreen";
|
||||
if (hue < 200) return "skyBlue";
|
||||
if (hue < 255) return "blue";
|
||||
return "reddishPurple";
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour to draw `color` in: itself, or its colour-blind-safe
|
||||
* replacement when `enabled`. A colour it can't read is drawn as it is.
|
||||
*/
|
||||
export function cvdColor(color: string, enabled: boolean): string {
|
||||
if (!enabled) return color;
|
||||
const hue = cvdHue(color);
|
||||
return hue ? CVD_COLORS[hue] : color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind classes for each palette colour, written out so Tailwind
|
||||
* generates them (it only finds classes that appear verbatim). Used where
|
||||
* a colour is drawn through classes (tag lozenges, folder icons) instead of
|
||||
* an inline style. Text stays in the text colour: several of these colours
|
||||
* are too light to read as text.
|
||||
*/
|
||||
export const CVD_CLASSES: Record<CvdHue, { dot: string; icon: string; fill: string; border: string; text: string; rowTint: string }> = {
|
||||
vermillion: { dot: "bg-[#D55E00]", icon: "text-[#D55E00]", fill: "bg-[#D55E00]/15", border: "border-[#D55E00]", text: "text-foreground", rowTint: "bg-[#D55E00]/10" },
|
||||
orange: { dot: "bg-[#E69F00]", icon: "text-[#E69F00]", fill: "bg-[#E69F00]/15", border: "border-[#E69F00]", text: "text-foreground", rowTint: "bg-[#E69F00]/10" },
|
||||
yellow: { dot: "bg-[#F0E442]", icon: "text-[#F0E442]", fill: "bg-[#F0E442]/20", border: "border-[#F0E442]", text: "text-foreground", rowTint: "bg-[#F0E442]/15" },
|
||||
bluishGreen: { dot: "bg-[#009E73]", icon: "text-[#009E73]", fill: "bg-[#009E73]/15", border: "border-[#009E73]", text: "text-foreground", rowTint: "bg-[#009E73]/10" },
|
||||
skyBlue: { dot: "bg-[#56B4E9]", icon: "text-[#56B4E9]", fill: "bg-[#56B4E9]/15", border: "border-[#56B4E9]", text: "text-foreground", rowTint: "bg-[#56B4E9]/10" },
|
||||
blue: { dot: "bg-[#0072B2]", icon: "text-[#0072B2]", fill: "bg-[#0072B2]/15", border: "border-[#0072B2]", text: "text-foreground", rowTint: "bg-[#0072B2]/10" },
|
||||
reddishPurple: { dot: "bg-[#CC79A7]", icon: "text-[#CC79A7]", fill: "bg-[#CC79A7]/15", border: "border-[#CC79A7]", text: "text-foreground", rowTint: "bg-[#CC79A7]/10" },
|
||||
grey: { dot: "bg-[#999999]", icon: "text-[#999999]", fill: "bg-[#999999]/15", border: "border-[#999999]", text: "text-foreground", rowTint: "bg-[#999999]/10" },
|
||||
};
|
||||
Reference in New Issue
Block a user