From ecf611931bf8539ca974bc73ffcf952d9a139abc Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 30 Jul 2026 19:46:10 -0600 Subject: [PATCH] ui: tags look like tags, and stay readable in every theme Four things, three cosmetic and one a real legibility bug. * Tags were square chips. They now have a pointed left edge and an eyelet, like a tag tied to a garment -- the shape Semantic gave them, and the reason a tag reads as a tag rather than as a small button. The point is a square rotated behind the left edge, sized so its diagonal equals the body height and its two edges meet the body at the corners. Only where something is actually a tag. `Label` carries the count badges in the Tags table too -- file, zim, channel and domain counts -- and a point on a number reads as an arrow, so the shape is behind a `tag` prop and `NameToTagLabel` opts in. * They were smaller than what they replaced: 11.5px text with 3px of padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which also keeps them a reasonable thing to tap. * Wrapped rows of tags sat on the row above, so they carry a bottom margin, plus a left margin for the point to occupy. * The text colour was unreadable in night and wrong in amber. Tags.js calculates black-or-light against the colour the user chose for each tag, and wrote the answer to `color` inline -- a declaration no stylesheet rule can outrank. Night turns a tag into an outline over a near-black page, so a near-white tag kept the black text calculated for its fill and was left legible only by its border: 1.1:1, measured. Amber replaces every fill with its own bright orange and was painting `#dddddd` on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole point is that no other hue reaches the eye. The fix is to stop hard-coding `color` so the theme can decide: the calculated colour goes into `--label-text`, and night and amber override it alongside the fill they already override. There is no second contrast function to write, which is what this looked like it needed -- once a theme has discarded the user's colour there is nothing left to calculate against, and the answer is just the theme's own token. `.wrolpi-label-white` and `-black` moved to the same variable. Setting `color` there outranked the base rule, so amber was painting `--white` on `--text`: light orange on orange. `contrastingColor` was also picking the worse of its two options. It compared a brightness figure against a threshold, and the figure weighted the gamma-encoded bytes without linearising them, so Semantic's blue `#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every mid-tone blue, teal and purple a user might pick sat in that band. It now measures the WCAG contrast ratio of both candidates and returns the better one, which needs no threshold at all. Tests ----- `Tags.test.js` is new, and covers the chip nothing covered: every other spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as a function returning the name. That is how a tag could be unreadable without a single test noticing. Two of these were verified red against the inline `color`. `cy.contrastRatio()` measures text against the surface *actually* behind it, walking up to the first ancestor that paints something -- a transparent tag has no background of its own, so a naive check reads rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three tag colours across four themes, at WCAG AA. Plus a test asserting the old inline-`color` version still measures below 1.5:1, so the check is provably able to fail. The gallery had label swatches in theme colours and no user-coloured tag, which is why none of this was visible in it. It has eleven now, spanning the range where the black-or-light decision flips. --- app/cypress/support/component.js | 42 ++++++++++ app/src/Tags.js | 14 +++- app/src/Tags.test.js | 115 ++++++++++++++++++++++++++ app/src/components/Common.js | 41 ++++++++- app/src/components/Common.test.js | 54 +++++++++++- app/src/components/ThemeSamplePage.js | 33 ++++++++ app/src/components/ui/Feedback.tsx | 13 ++- 7 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 app/src/Tags.test.js 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 `