fix: make Tentative, Accepted and other status text readable on light backgrounds

This commit is contained in:
Linus Rath
2026-10-28 04:17:20 +01:00
parent b925b16737
commit 70508f3420
5 changed files with 127 additions and 132 deletions
@@ -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(<RsvpButtons status={null} onRespond={vi.fn()} />);
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(<RsvpButtons status="tentative" onRespond={vi.fn()} />);
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(<ParticipantStatusBadge status="accepted" />);
expect(screen.getByText('participants.accepted').className).toContain('text-green-700');
});
});
@@ -18,7 +18,7 @@ export function CalendarLoadErrorNotice({ what = "events", onRetry }: CalendarLo
const t = useTranslations("calendar.status");
return (
<div role="alert" className="flex items-center gap-2 border-b border-border bg-warning/10 px-4 py-2 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-warning" />
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<span className="min-w-0 flex-1">{what === "tasks" ? t("tasks_load_failed") : t("events_load_failed")}</span>
<Button variant="outline" size="sm" onClick={onRetry}>
{t("retry")}
+4 -69
View File
@@ -27,6 +27,7 @@ import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { eventToView } from "@/lib/plugin-projection";
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
import { isEditableEventTarget } from "@/lib/keyboard";
interface EventDetailPopoverProps {
@@ -407,7 +408,7 @@ export function EventDetailPopover({
{event.title || t("events.no_title")}
</h3>
{event.status === "tentative" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-amber-600 dark:text-amber-400">
{t("detail.tentative")}
</span>
)}
@@ -546,7 +547,7 @@ export function EventDetailPopover({
</span>
)}
</span>
<ParticipantStatusBadge status={p.status} t={t} />
<ParticipantStatusBadge status={p.status} className="text-[10px]" />
</div>
))}
{participants.length > 5 && (
@@ -652,47 +653,7 @@ export function EventDetailPopover({
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("participants.rsvp_label")}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => onRsvp("accepted")}
className={
userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"
}
>
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => onRsvp("tentative")}
className={
userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"
}
>
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => onRsvp("declined")}
className={
userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"
}
>
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.declined")}
</Button>
</div>
<RsvpButtons status={userCurrentStatus} onRespond={onRsvp} size="xs" />
</div>
)}
@@ -701,29 +662,3 @@ export function EventDetailPopover({
return createPortal(popover, document.body);
}
function ParticipantStatusBadge({
status,
t,
}: {
status: CalendarParticipant["participationStatus"];
t: ReturnType<typeof useTranslations>;
}) {
const colors: Record<string, string> = {
accepted: "text-success",
declined: "text-destructive",
tentative: "text-warning",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
accepted: "participants.accepted",
declined: "participants.declined",
tentative: "participants.tentative",
"needs-action": "participants.needs_action",
};
return (
<span className={`text-[10px] flex-shrink-0 ${colors[status] || ""}`}>
{t(labels[status] || labels["needs-action"])}
</span>
);
}
+7 -62
View File
@@ -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({
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} t={t} />
<ParticipantStatusBadge status={p.status} />
</div>
))}
</div>
@@ -1209,7 +1210,7 @@ export function EventModal({
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} t={t} />
<ParticipantStatusBadge status={p.status} />
</div>
))}
</div>
@@ -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 && (
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="mt-px h-3.5 w-3.5 flex-shrink-0" />
{t("participants.limit_warning", { max: maxParticipants })}
</p>
@@ -1660,65 +1661,9 @@ function RsvpBar({ status, onRespond, t }: {
t: ReturnType<typeof useTranslations>;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={status === "accepted" ? "default" : "outline"}
onClick={() => onRespond("accepted")}
className={status === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{status === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={status === "tentative" ? "default" : "outline"}
onClick={() => onRespond("tentative")}
className={status === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{status === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={status === "declined" ? "default" : "ghost"}
onClick={() => onRespond("declined")}
className={status === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{status === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
<RsvpButtons status={status} onRespond={onRespond} />
</div>
);
}
function StatusBadge({ status, t }: {
status: CalendarParticipant['participationStatus'];
t: ReturnType<typeof useTranslations>;
}) {
// 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<string, string> = {
accepted: "text-success",
declined: "text-destructive",
tentative: "text-warning",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
accepted: "participants.accepted",
declined: "participants.declined",
tentative: "participants.tentative",
"needs-action": "participants.needs_action",
};
return <span className={`text-xs ${colors[status] || ""}`}>{t(labels[status] || labels["needs-action"])}</span>;
}
+85
View File
@@ -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<string, string> = {
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<string, string> = {
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 (
<span className={cn("text-xs flex-shrink-0", STATUS_TEXT[status] ?? "", className)}>
{t(STATUS_LABEL[status] ?? STATUS_LABEL["needs-action"])}
</span>
);
}
// The chosen answer is filled; dark text keeps it readable on the light
// green and yellow fills.
const CHOSEN: Record<string, string> = {
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 (
<div className="flex flex-wrap gap-2">
{ANSWERS.map((answer) => {
const chosen = status === answer.status;
return (
<Button
key={answer.status}
size={size}
variant="outline"
aria-pressed={chosen}
onClick={() => onRespond(answer.status)}
className={chosen ? CHOSEN[answer.status] : STATUS_TEXT[answer.status]}
>
{chosen && <Check className="w-4 h-4 me-1" />}
{t(answer.label)}
</Button>
);
})}
</div>
);
}