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`, () => {