From 568e9f341863c9f35cb8f5d8946c093381fc0db0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 18:08:11 +0000 Subject: [PATCH] fix(web): raise month picker day contrast in dark theme Sidebar days inherited react-datepicker's black text, which met contrast on Light Beach and failed on Dark Abyss. Paint unselected numbers with theme text and cover both themes in the datepicker a11y check. Co-authored-by: Tyler Dane --- e2e/accessibility/datepicker-a11y.spec.ts | 67 ++++++++++++------- .../web/src/common/styles/theme-css.test.ts | 12 ++++ .../src/components/DatePicker/DatePicker.tsx | 4 +- .../Sidebar/MonthPicker/MonthPicker.test.tsx | 9 +++ packages/web/src/index.css | 38 ++++++++--- 5 files changed, 95 insertions(+), 35 deletions(-) diff --git a/e2e/accessibility/datepicker-a11y.spec.ts b/e2e/accessibility/datepicker-a11y.spec.ts index 238018b2ee..b81cf64997 100644 --- a/e2e/accessibility/datepicker-a11y.spec.ts +++ b/e2e/accessibility/datepicker-a11y.spec.ts @@ -7,32 +7,51 @@ import { const MIN_NORMAL_TEXT_CONTRAST = 4.5; -test("sidebar datepicker meets baseline accessibility and contrast checks", async ({ - page, -}) => { - await prepareCalendarPage(page); - await ensureSidebarOpen(page); - - const sidebar = page.locator("#sidebar"); - const monthPicker = sidebar.getByRole("group", { name: "Date navigation" }); - await expect(monthPicker).toBeVisible(); - - // Scoped to #sidebar (not a whole-page scan): this test owns the - // datepicker's targeted contrast regression below, and scoping keeps that - // pairing - the axe pass and the per-date contrast check - about the same - // element on every run. - await expectNoAxeViolations(page, { - include: "#sidebar", - checkpoint: "sidebar datepicker", - }); +const THEMES = [ + { name: "light", theme: "light-beach", stored: null }, + { name: "dark", theme: "dark-abyss", stored: "dark-abyss" }, +] as const; + +test.describe("sidebar datepicker", () => { + for (const { name, theme, stored } of THEMES) { + test(`${name} theme meets baseline accessibility and contrast checks`, async ({ + page, + }) => { + if (stored) { + await page.addInitScript((value) => { + localStorage.setItem("compass.theme", value); + }, stored); + } + + await prepareCalendarPage(page); + await ensureSidebarOpen(page); + + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); - const days = monthPicker.locator( - ".react-datepicker__day:not(.react-datepicker__day--disabled)", - ); - await expect(days.first()).toBeVisible(); + const sidebar = page.locator("#sidebar"); + const monthPicker = sidebar.getByRole("group", { + name: "Date navigation", + }); + await expect(monthPicker).toBeVisible(); - await expectDateContrast(days, "default"); - await expectDateContrast(days, "hover"); + // Scoped to #sidebar (not a whole-page scan): this test owns the + // datepicker's targeted contrast regression below, and scoping keeps that + // pairing - the axe pass and the per-date contrast check - about the same + // element on every run. + await expectNoAxeViolations(page, { + include: "#sidebar", + checkpoint: `sidebar datepicker ${name}`, + }); + + const days = monthPicker.locator( + ".react-datepicker__day:not(.react-datepicker__day--disabled)", + ); + await expect(days.first()).toBeVisible(); + + await expectDateContrast(days, "default"); + await expectDateContrast(days, "hover"); + }); + } }); const expectDateContrast = async ( diff --git a/packages/web/src/common/styles/theme-css.test.ts b/packages/web/src/common/styles/theme-css.test.ts index ee2e5a758a..8369ea9def 100644 --- a/packages/web/src/common/styles/theme-css.test.ts +++ b/packages/web/src/common/styles/theme-css.test.ts @@ -218,4 +218,16 @@ describe("Tailwind theme CSS", () => { expect(match?.[1]?.toLowerCase()).toBe(hex.toLowerCase()); } }); + + it("paints sidebar month-picker days with theme text, not inherited black", () => { + // react-datepicker sets color: #000 on .react-datepicker. A sidebar + // `color: inherit` picked that up and failed WCAG on Dark Abyss. + expect(indexCss).toContain(".c-month-picker .c-date-picker"); + expect(indexCss).toMatch( + /\.c-month-picker \.c-date-picker[\s\S]*& \.react-datepicker__day \{\s*color: var\(--text\);/, + ); + expect(indexCss).not.toMatch( + /data-view="sidebar"\] \.react-datepicker__day[\s\S]{0,120}color:\s*inherit/, + ); + }); }); diff --git a/packages/web/src/components/DatePicker/DatePicker.tsx b/packages/web/src/components/DatePicker/DatePicker.tsx index 880a309a0b..eeb645ad56 100644 --- a/packages/web/src/components/DatePicker/DatePicker.tsx +++ b/packages/web/src/components/DatePicker/DatePicker.tsx @@ -120,7 +120,7 @@ export const DatePicker: React.FC = (datePickerProps) => { // When the picker bg has the same polarity as the theme's surfaces, the // theme's standard --text already contrasts with it; a mismatched bg (e.g. // the light event-fill picker on the dark theme) needs --on-accent, the - // token that flips polarity. The CSS keys off this via [data-dark]. + // token that flips polarity. The CSS keys off this via data-dark="true"|"false". const usesThemeText = isDarkBackground === isDarkTheme; const headerColor = view === "sidebar" @@ -143,7 +143,7 @@ export const DatePicker: React.FC = (datePickerProps) => {
diff --git a/packages/web/src/components/Sidebar/MonthPicker/MonthPicker.test.tsx b/packages/web/src/components/Sidebar/MonthPicker/MonthPicker.test.tsx index e03d70e955..a7ae5b0a77 100644 --- a/packages/web/src/components/Sidebar/MonthPicker/MonthPicker.test.tsx +++ b/packages/web/src/components/Sidebar/MonthPicker/MonthPicker.test.tsx @@ -75,6 +75,15 @@ describe("MonthPicker", () => { ).toHaveAttribute("data-pointer-action", POINTER_ACTIONS.datePick); }); + it("marks the calendar as using theme text so day numbers follow --text", () => { + renderPicker(); + + expect(document.querySelector(".c-date-picker")).toHaveAttribute( + "data-dark", + "true", + ); + }); + it("moves a week at a time with every arrow key and opens the week with Enter", async () => { const user = userEvent.setup({ skipHover: true }); const onSelectDate = mock(); diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 6ba200b027..8eb1804418 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -767,9 +767,15 @@ /* * Keep this unlayered so it can override react-datepicker's unlayered CSS. * Rules inside a Tailwind @utility lose to unlayered library defaults. + * @apply lives in its own rule: Tailwind can layer apply-expanded + * declarations, and nesting day colors in that same rule would lose to + * react-datepicker's unlayered `color: #000`. */ .c-date-picker { @apply select-none rounded-xs border-0 shadow-[0_4px_4px_var(--color-shadow-default)]; +} + +.c-date-picker { background-color: var(--date-picker-bg); font-size: 11px; --date-picker-hover-bg: color-mix( @@ -842,13 +848,13 @@ & .react-datepicker__day-name { margin: 0; - color: var(--on-accent); + color: var(--text-muted); font-size: 11px; - opacity: 0.8; } - &[data-dark="true"] .react-datepicker__day-name { - color: var(--text); + &[data-dark="false"] .react-datepicker__day-name { + color: var(--on-accent); + opacity: 0.8; } &[data-view="sidebar"] .react-datepicker__day-name { @@ -861,12 +867,12 @@ margin: 0; border: 0; border-radius: 50%; - color: var(--on-accent); + color: var(--text); line-height: 1.5rem; } - &[data-dark="true"] .react-datepicker__day { - color: var(--text); + &[data-dark="false"] .react-datepicker__day { + color: var(--on-accent); } &[data-view="sidebar"] .react-datepicker__day { @@ -885,11 +891,13 @@ outline-offset: 1px; } - /* Sidebar days are keyboard targets, not click targets: no hover affordance. */ + /* Sidebar days are keyboard targets, not click targets: no hover + affordance. Use --text (not inherit): react-datepicker sets `color: #000` + on .react-datepicker, so inherit painted black numbers on Dark Abyss. */ &[data-view="sidebar"] .react-datepicker__day, &[data-view="sidebar"] .react-datepicker__day:hover { background-color: transparent; - color: inherit; + color: var(--text); cursor: default; } @@ -1213,6 +1221,18 @@ & .react-datepicker__month { overflow: visible; } + & .react-datepicker__day-name { + color: var(--text-muted); + } + & .react-datepicker__day { + color: var(--text); + } + & .react-datepicker__day--today:not(.react-datepicker__day--selected) { + color: var(--accent); + } + & .react-datepicker__day--outside-month { + color: var(--text-muted); + } & .react-datepicker__day--selected { position: relative; isolation: isolate;