From 9bfa1eaab29186d9a15d0addffdc3eb240895141 Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 1 Aug 2026 15:07:48 -0600 Subject: [PATCH] test: prove the toast contrast fix in a browser, and fix the harness that hid it The title contrast was verified by hand and by nothing else. jsdom cannot check it: it resolves no `var()`, so the title's colour there is the literal string `var(--mantine-color-white)` and every contrast assertion passes. Six browser tests now cover it -- title and description at WCAG AA in each of the four themes, the title staying heavier than the message where night and amber drop its muting, and the toast taking `--panel` for its surface. The three that can fail were checked against the fix removed: 1.13:1 in dark, night and amber. Light passes either way, correctly, because Mantine's light-scheme title uses `--mantine-color-black`, which the bridge maps to `--text`. Getting there took three corrections, all of them mine: * `cy.mountUI` never set `forceColorScheme`, so every theme rendered against Mantine's LIGHT scheme. Mantine keys its own rules on data-mantine-color-scheme, and ThemeProvider sets it from the theme -- night and amber ride on dark. Any per-theme test could therefore pass on a page that was broken in the app. This is the harness the tag contrast tests use too. * The support file never imported `@mantine/notifications/styles.css`, which App.js does, so a Notification rendered unstyled and had no surface or title colour of Mantine's to be wrong about. * The spec's own helper mounted a second time to raise the toast, which reset the theme to the default. All four per-theme cases ran in light. For the record, the chain the fix breaks: `.m_3feedf16` (the title) in dark scheme is `color: var(--mantine-color-white)`, the bridge aliases that to `--btn-text`, and `--btn-text` is the text drawn on a filled button -- near-black in dark, night and amber. Mantine writes it through `:where(...)`, which has zero specificity, so a single-class override plus `html` outranks it. --- app/cypress/support/component.js | 17 +++++- app/src/components/ui/ui-layout.cy.js | 76 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/app/cypress/support/component.js b/app/cypress/support/component.js index 910ae720..c9feffc5 100644 --- a/app/cypress/support/component.js +++ b/app/cypress/support/component.js @@ -19,12 +19,16 @@ import '../../src/index.css'; import '../../src/themes/fonts.css'; import '../../src/themes/tokens.css'; import '@mantine/core/styles.css'; +// App.js loads this too. Without it a Notification renders unstyled here, so a toast had no +// surface and no title colour of Mantine's -- and its contrast test could not fail. +import '@mantine/notifications/styles.css'; // react-router v7 ships these directly; `react-router-dom` is not installed. import {MemoryRouter, Route, Routes} from "react-router"; import {QueryProvider} from "../../src/hooks/customHooks"; import {TagsProvider} from "../../src/Tags"; import {MantineProvider} from "@mantine/core"; import {cssVariablesResolver, mantineTheme} from "../../src/themes/mantine"; +import {isDarkTheme} from "../../src/themes/names"; import {MediaFilterDefs} from "../../src/themes/MediaFilterDefs"; import React from "react"; @@ -75,6 +79,13 @@ Cypress.Commands.add('mountWithRouter', (component, options) => { * `theme` stamps data-theme the way ThemeProvider does; `mediaFilter` stamps * data-media-filter and mounts the SVG filter definitions, since a filter referenced but * never defined silently does nothing. + * + * `forceColorScheme` matters as much as the token table, and was missing. Mantine keys its + * own rules on data-mantine-color-scheme, which ThemeProvider sets from the theme -- night and + * amber ride on dark. Without it every theme rendered against Mantine's LIGHT scheme here, so + * a per-theme test could pass on a page that was broken in the app: the toast title, which + * Mantine paints with `--mantine-color-white` in dark scheme only, resolved correctly by + * accident and its contrast test could not fail. */ Cypress.Commands.add('mountUI', (component, options = {}) => { const {theme = 'light', mediaFilter, ...mountOptions} = options; @@ -85,7 +96,11 @@ Cypress.Commands.add('mountUI', (component, options = {}) => { else delete html.dataset.mediaFilter; return cy.mount( - + {mediaFilter ? : null} {component} , diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index fe863931..b8140ea7 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -3,8 +3,17 @@ import { ActionInput, Button, Header, PathInput, Statistic, StatisticGroup, TabBar, tabClassName, TextInput, } from './index'; import {contrastingColor} from '../Common'; +import {Notifications} from '@mantine/notifications'; +import {toast} from './toast'; import {themeNames} from '../../themes/names'; +/* `--panel` is authored as a hex; computed backgrounds come back as rgb(). */ +const hexToRgb = (hex) => { + const value = hex.replace('#', ''); + const [r, g, b] = [0, 2, 4].map(i => parseInt(value.slice(i, i + 2), 16)); + return `rgb(${r}, ${g}, ${b})`; +}; + /* * Layout and theming assertions for the component library, run in a real browser. * @@ -543,6 +552,73 @@ describe('tabs', () => { }); }); +describe('a toast is readable in every theme', () => { + /* + * Mantine paints a dark-scheme notification title with `--mantine-color-white`, and the + * bridge aliases that to `--btn-text` -- the text drawn on a filled button, which is + * near-black in dark, night and amber. The title measured about 1.1:1 against the toast's + * own surface: present, and invisible. + * + * Only a browser can catch that. jsdom resolves no `var()`, so the title's colour there is + * the literal string `var(--mantine-color-white)` and every contrast check passes. + */ + + /* + * Mounts once, with the theme, and raises a toast. Mounting a second time to raise it -- + * which is how this was first written -- resets the theme to the default, so all four + * per-theme cases silently ran in light and none of them could fail. + */ + const showToast = (theme) => { + cy.mountUI(<> + + + , {theme}); + cy.contains('button', 'Raise one').click(); + cy.get('[class*="mantine-Notification-root"]').should('exist'); + }; + + themeNames.forEach((theme) => { + it(`reads its title and message in ${theme}`, () => { + showToast(theme); + + // WCAG AA for both. The description is the message itself, so it is held to the + // same bar as the title rather than treated as decoration. + cy.contrastRatio('[class*="mantine-Notification-title"]').should('be.at.least', 4.5); + cy.contrastRatio('[class*="mantine-Notification-description"]').should('be.at.least', 4.5); + }); + }); + + it('keeps the title more prominent than the message', () => { + /* + * Night and amber drop the description's muting, because `--muted` on a panel is 2.0:1 + * in night. Weight has to carry the hierarchy once colour cannot. + */ + showToast('night'); + + cy.get('[class*="mantine-Notification-title"]').then(($title) => { + cy.get('[class*="mantine-Notification-description"]').should(($description) => { + const weight = (el) => parseInt(getComputedStyle(el).fontWeight, 10); + expect(weight($title[0]), 'title weight') + .to.be.greaterThan(weight($description[0])); + }); + }); + }); + + it('takes its surface from the theme, not from Mantine', () => { + // A toast is a panel. Left to Mantine it would be `--mantine-color-body`, which is not + // one of our tokens and does not follow night or amber. + showToast('night'); + + cy.get('[class*="mantine-Notification-root"]').should(($toast) => { + const panel = getComputedStyle(document.documentElement).getPropertyValue('--panel').trim(); + expect(getComputedStyle($toast[0]).backgroundColor).to.equal(hexToRgb(panel)); + }); + }); +}); + describe('theme tokens actually resolve', () => { themeNames.forEach((theme) => { it(`paints text and borders from ${theme}'s table`, () => {