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.
This commit is contained in:
Roland 2026-08-01 15:07:48 -06:00
parent b5f278eff4
commit 9bfa1eaab2
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 92 additions and 1 deletions

View file

@ -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(
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
<MantineProvider
theme={mantineTheme}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme={isDarkTheme(theme) ? 'dark' : 'light'}
>
{mediaFilter ? <MediaFilterDefs/> : null}
{component}
</MantineProvider>,

View file

@ -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(<>
<Notifications position='top-right' limit={5}/>
<button type='button' onClick={() => toast({
type: 'error', title: 'Download failed',
description: 'HTTP 403 from the remote server after 3 attempts.',
})}>Raise one</button>
</>, {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`, () => {