From 70508f34202cb3e9dbd29e37812adfa5ec043884 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:17:20 +0100
Subject: [PATCH] fix: make Tentative, Accepted and other status text readable
on light backgrounds
---
.../calendar/__tests__/rsvp-buttons.test.tsx | 30 +++++++
.../calendar/calendar-load-error-notice.tsx | 2 +-
components/calendar/event-detail-popover.tsx | 73 +---------------
components/calendar/event-modal.tsx | 69 ++-------------
components/calendar/rsvp-buttons.tsx | 85 +++++++++++++++++++
5 files changed, 127 insertions(+), 132 deletions(-)
create mode 100644 components/calendar/__tests__/rsvp-buttons.test.tsx
create mode 100644 components/calendar/rsvp-buttons.tsx
diff --git a/components/calendar/__tests__/rsvp-buttons.test.tsx b/components/calendar/__tests__/rsvp-buttons.test.tsx
new file mode 100644
index 000000000..195874b81
--- /dev/null
+++ b/components/calendar/__tests__/rsvp-buttons.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { ParticipantStatusBadge, RsvpButtons } from '../rsvp-buttons';
+
+// "Tentative" (#eab308) and "Accepted" (#22c55e) text on white measured
+// about 1.9:1 and 2.3:1, and the chosen Tentative was white on yellow. Text
+// uses the darker status shades; a chosen answer gets dark text.
+
+describe('participation status colours', () => {
+ it('writes answers in readable status shades, not the light fills', () => {
+ render();
+ const maybe = screen.getByRole('button', { name: 'participants.tentative' });
+ expect(maybe.className).toContain('text-amber-600');
+ expect(maybe.className).not.toMatch(/\btext-warning\b/);
+ expect(screen.getByRole('button', { name: 'participants.accepted' }).className).toContain('text-green-700');
+ });
+
+ it('puts dark text on the chosen yellow answer', () => {
+ render();
+ const maybe = screen.getByRole('button', { name: 'participants.tentative' });
+ expect(maybe).toHaveAttribute('aria-pressed', 'true');
+ expect(maybe.className).toContain('text-neutral-950');
+ expect(maybe.className).not.toContain('text-warning-foreground');
+ });
+
+ it('colours a participant\'s answer the same way', () => {
+ render();
+ expect(screen.getByText('participants.accepted').className).toContain('text-green-700');
+ });
+});
diff --git a/components/calendar/calendar-load-error-notice.tsx b/components/calendar/calendar-load-error-notice.tsx
index b296f7ecc..b12856326 100644
--- a/components/calendar/calendar-load-error-notice.tsx
+++ b/components/calendar/calendar-load-error-notice.tsx
@@ -18,7 +18,7 @@ export function CalendarLoadErrorNotice({ what = "events", onRetry }: CalendarLo
const t = useTranslations("calendar.status");
return (
-
+
{what === "tasks" ? t("tasks_load_failed") : t("events_load_failed")}
))}
{participants.length > 5 && (
@@ -652,47 +653,7 @@ export function EventDetailPopover({
{t("participants.rsvp_label")}
-
-
-
-
-
+
)}
@@ -701,29 +662,3 @@ export function EventDetailPopover({
return createPortal(popover, document.body);
}
-
-function ParticipantStatusBadge({
- status,
- t,
-}: {
- status: CalendarParticipant["participationStatus"];
- t: ReturnType;
-}) {
- const colors: Record = {
- accepted: "text-success",
- declined: "text-destructive",
- tentative: "text-warning",
- "needs-action": "text-muted-foreground",
- };
- const labels: Record = {
- accepted: "participants.accepted",
- declined: "participants.declined",
- tentative: "participants.tentative",
- "needs-action": "participants.needs_action",
- };
- return (
-
- {t(labels[status] || labels["needs-action"])}
-
- );
-}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index dd264c6f1..509eccb1c 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -6,8 +6,9 @@ import { toast } from "@/stores/toast-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LinkifiedText } from "@/components/ui/linkified-text";
-import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons";
+import { X, Trash2, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
+import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
import { format, parseISO, addHours, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor";
@@ -1052,7 +1053,7 @@ export function EventModal({
({t("participants.organizer").toLowerCase()})
)}
-
+
))}
@@ -1209,7 +1210,7 @@ export function EventModal({
({t("participants.organizer").toLowerCase()})
)}
-
+
))}
@@ -1385,7 +1386,7 @@ export function EventModal({
it, Stalwart 1.0 refuses to save more participants - the
organizer entry a save adds counts too. */}
{maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && (
-
+
{t("participants.limit_warning", { max: maxParticipants })}
@@ -1660,65 +1661,9 @@ function RsvpBar({ status, onRespond, t }: {
t: ReturnType;
}) {
return (
-
+
{t("participants.rsvp_label")}
-
-
-
-
-
+
);
}
-
-function StatusBadge({ status, t }: {
- status: CalendarParticipant['participationStatus'];
- t: ReturnType
;
-}) {
- // The organizer is marked by the "(组织者)" suffix on the name; the badge
- // shows their participation status (organizers default to accepted) rather
- // than repeating the organizer label.
- const colors: Record = {
- accepted: "text-success",
- declined: "text-destructive",
- tentative: "text-warning",
- "needs-action": "text-muted-foreground",
- };
- const labels: Record = {
- accepted: "participants.accepted",
- declined: "participants.declined",
- tentative: "participants.tentative",
- "needs-action": "participants.needs_action",
- };
- return {t(labels[status] || labels["needs-action"])};
-}
diff --git a/components/calendar/rsvp-buttons.tsx b/components/calendar/rsvp-buttons.tsx
new file mode 100644
index 000000000..0b3aac608
--- /dev/null
+++ b/components/calendar/rsvp-buttons.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Button, type ButtonSize } from "@/components/ui/button";
+import { Check } from "@/components/icons";
+import { cn } from "@/lib/utils";
+import type { CalendarParticipant } from "@/lib/jmap/types";
+
+type Status = CalendarParticipant["participationStatus"];
+
+/**
+ * Text in a participation status colour. The status fills (#22c55e,
+ * #eab308) are too light for text on a light ground, so text uses the
+ * darker shades (APP.md status colours).
+ */
+export const STATUS_TEXT: Record = {
+ accepted: "text-green-700 dark:text-green-400",
+ declined: "text-red-600 dark:text-red-400",
+ tentative: "text-amber-600 dark:text-amber-400",
+ "needs-action": "text-muted-foreground",
+};
+
+const STATUS_LABEL: Record = {
+ accepted: "participants.accepted",
+ declined: "participants.declined",
+ tentative: "participants.tentative",
+ "needs-action": "participants.needs_action",
+};
+
+/** A participant's answer, as a short coloured word. */
+export function ParticipantStatusBadge({ status, className }: { status: Status; className?: string }) {
+ const t = useTranslations("calendar");
+ return (
+
+ {t(STATUS_LABEL[status] ?? STATUS_LABEL["needs-action"])}
+
+ );
+}
+
+// The chosen answer is filled; dark text keeps it readable on the light
+// green and yellow fills.
+const CHOSEN: Record = {
+ accepted: "bg-success hover:bg-success/90 text-neutral-950 border-transparent",
+ tentative: "bg-warning hover:bg-warning/90 text-neutral-950 border-transparent",
+ declined: "bg-destructive hover:bg-destructive/90 text-destructive-foreground border-transparent",
+};
+
+const ANSWERS: { status: Status; label: string }[] = [
+ { status: "accepted", label: "participants.accepted" },
+ { status: "tentative", label: "participants.tentative" },
+ { status: "declined", label: "participants.declined" },
+];
+
+/** The three answers to an invitation, the current one filled. */
+export function RsvpButtons({
+ status,
+ onRespond,
+ size = "sm",
+}: {
+ status: Status | null;
+ onRespond: (status: Status) => void;
+ size?: ButtonSize;
+}) {
+ const t = useTranslations("calendar");
+ return (
+
+ {ANSWERS.map((answer) => {
+ const chosen = status === answer.status;
+ return (
+
+ );
+ })}
+
+ );
+}