diff --git a/app/cypress/support/component.js b/app/cypress/support/component.js index dcb3f7b8..910ae720 100644 --- a/app/cypress/support/component.js +++ b/app/cypress/support/component.js @@ -116,6 +116,48 @@ Cypress.Commands.add('shouldNotOverlap', {prevSubject: false}, (firstSelector, s }); }); +/** + * Contrast ratio between an element's text and the surface actually behind it, per WCAG. + * + * "The surface actually behind it" is the point. A transparent element -- which is what a + * tag becomes in night mode -- has no background of its own to compare against, so a naive + * check reads `rgba(0,0,0,0)` and concludes anything is legible. This walks up to the first + * ancestor that paints something, which is what the eye sees. + */ +const parseRgb = (value) => { + const parts = (value.match(/[\d.]+/g) || []).map(Number); + return {r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1}; +}; + +const relativeLuminance = ({r, g, b}) => { + const channel = (v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +const effectiveBackground = (el) => { + let node = el; + while (node && node !== document.documentElement) { + const bg = parseRgb(getComputedStyle(node).backgroundColor); + if (bg.a > 0) return bg; + node = node.parentElement; + } + return parseRgb(getComputedStyle(document.documentElement).backgroundColor); +}; + +Cypress.Commands.add('contrastRatio', {prevSubject: false}, (selector) => { + return cy.get(selector).then(($el) => { + const el = $el[0]; + const text = relativeLuminance(parseRgb(getComputedStyle(el).color)); + const behind = relativeLuminance(effectiveBackground(el)); + const lighter = Math.max(text, behind); + const darker = Math.min(text, behind); + return (lighter + 0.05) / (darker + 0.05); + }); +}); + Cypress.Commands.add('mountWithTags', (component, options) => { cy.fixture('tagsMock.json').then((mockTags) => { cy.intercept('GET', '**/api/tag', { diff --git a/app/src/Tags.js b/app/src/Tags.js index 585feac9..3c0e1925 100644 --- a/app/src/Tags.js +++ b/app/src/Tags.js @@ -111,18 +111,26 @@ export function useTags() { if (tag !== null && tag !== undefined) { const tagColor = tag['color'] || DEFAULT_TAG_COLOR; const textColor = contrastingColor(tagColor); - const className = ['wrolpi-label', props.onClick ? 'clickable' : ''].filter(Boolean).join(' '); + const className = ['wrolpi-label', 'wrolpi-tag', props.onClick ? 'clickable' : ''] + .filter(Boolean).join(' '); return {name} ; } // No tags have been fetched. - return ; + return ; } const TagsGroup = ({tagNames, onClick}) => { diff --git a/app/src/Tags.test.js b/app/src/Tags.test.js new file mode 100644 index 00000000..ba4c108b --- /dev/null +++ b/app/src/Tags.test.js @@ -0,0 +1,115 @@ +import React from 'react'; +import {screen, waitFor} from '@testing-library/react'; +import {render} from './test-utils'; +import {queryContextFixture} from './test-fixtures'; +import {QueryContext} from './contexts/contexts'; +import {useTags} from './Tags'; + +/* + * Tests for the real tag chip. + * + * Everything else in the suite takes `NameToTagLabel` from `tagsContextFixture`, which stubs + * it as a function returning the name -- so nothing exercised the component that actually + * paints a tag. That is how a tag came to be unreadable in night mode without a single test + * noticing: the colours are decided here, in the one place in the app that paints with a + * value no theme chose. + */ + +jest.mock('./api', () => ({ + getTags: jest.fn(), + getRecentTags: jest.fn(), + saveTag: jest.fn(), + deleteTag: jest.fn(), +})); + +import {getTags, getRecentTags} from './api'; + +const BRIGHT = '#ffe066'; // contrastingColor -> black +const DARK = '#1a1a2e'; // contrastingColor -> light + +// A harness that renders the real NameToTagLabel out of the real hook. +const TagProbe = ({name}) => { + const {NameToTagLabel} = useTags(); + return ; +}; + +const renderTag = (name, tags) => render( + + + +); + +const tagOf = (name) => screen.getByText(name); + +/* + * Wait for the fetched colours to arrive. Before they do, `NameToTagLabel` falls back to a + * plain `