ui: an inactive tab was a near-white block in dark, night and amber

Found while checking tags in the gallery, and unrelated to them.

A tab is a NavLink in the app's nav bars, but a <button> on the Inventory
page and in the theme gallery.  A button carries the UA's own `buttonface`
background -- rgb(239, 239, 239) in Chrome -- and only `.wrolpi-tab-active`
set a background, so every inactive tab painted that near-white block.
Invisible in light, and a bright patch in the other three; in night it is
the one thing the theme exists to prevent.

One line, and a test in all four themes.
This commit is contained in:
Roland 2026-07-30 19:46:20 -06:00
parent ecf611931b
commit fe92455ee6
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 320 additions and 7 deletions

View file

@ -1,5 +1,8 @@
import React from 'react';
import {ActionInput, Button, Header, PathInput, Statistic, StatisticGroup, TextInput} from './index';
import {
ActionInput, Button, Header, PathInput, Statistic, StatisticGroup, TabBar, tabClassName, TextInput,
} from './index';
import {contrastingColor} from '../Common';
import {themeNames} from '../../themes/names';
/*
@ -320,6 +323,183 @@ describe('StatisticGroup separates its statistics with nothing but space', () =>
});
});
describe('a tag is legible in every theme, whatever colour the user picked', () => {
/*
* Tag colours are the user's, stored per tag -- the only thing in the app painted with a
* value no theme chose. Tags.js calculates black or light text against that fill, and
* night discards the fill entirely (a filled tag would be a bright patch) while amber
* replaces it with its own. Whether the text still reads afterwards is a question about
* resolved colour against a real painted surface, so it can only be asked here.
*/
// The extremes of the black-or-light decision, plus a mid tone.
const TAG_COLOURS = [
['Reference', '#f2f2f2'], // near-white: text is calculated black
['Archived', '#1b1c1d'], // near-black: text is calculated light
['Water', '#2185d0'],
];
const tags = <div>
{TAG_COLOURS.map(([name, color]) => <span
key={name}
className='wrolpi-label wrolpi-tag'
data-tag={name}
style={{'--label-color': color, '--label-text': contrastingColor(color)}}
>{name}</span>)}
</div>;
themeNames.forEach((theme) => {
TAG_COLOURS.forEach(([name]) => {
it(`reads ${name} against its real surface in ${theme}`, () => {
cy.mountUI(tags, {theme});
/*
* 4.5:1 is WCAG AA for body text. Before this, night measured 1.1:1 on a
* near-white tag -- the black text calculated for that fill, painted onto a
* transparent tag over a near-black page. The tag was readable only as an
* empty outline.
*/
cy.contrastRatio(`[data-tag="${name}"]`).should('be.at.least', 4.5);
});
});
});
it('detects the unreadable tag this started as, so the check has teeth', () => {
/*
* The original defect, reproduced deliberately: the calculated text colour written to
* `color` inline, which no stylesheet rule can outrank. In night the tag becomes a
* transparent outline over a near-black page, so the black text calculated for a
* near-white fill is painted onto near-black.
*
* Asserting that this IS unreadable proves contrastRatio measures the surface actually
* behind the text rather than the tag's own missing background. A contrast check that
* has never gone red is indistinguishable from one that cannot.
*/
cy.mountUI(
<span
className='wrolpi-label wrolpi-tag'
data-tag='Legacy'
style={{'--label-color': '#f2f2f2', color: contrastingColor('#f2f2f2')}}
>Reference</span>,
{theme: 'night'},
);
cy.contrastRatio('[data-tag="Legacy"]').should('be.lessThan', 1.5);
});
it('is at least as large as the Semantic label it replaced', () => {
// The first pass came out at 11.5px against Semantic's 12px and read as shrunken.
// A tag is a hit target as well as a word, so it also has to stay tall enough to hit.
cy.mountUI(tags);
cy.get('.wrolpi-tag').first().should(($tag) => {
expect(parseFloat(getComputedStyle($tag[0]).fontSize), 'font size').to.be.at.least(12);
expect($tag[0].getBoundingClientRect().height, 'height').to.be.at.least(26);
});
});
});
describe('a tag looks like a physical tag', () => {
const tag = <span
className='wrolpi-label wrolpi-tag'
style={{'--label-color': '#2185d0', '--label-text': '#ffffff'}}
>Water</span>;
it('has a pointed left edge that spans the full height', () => {
/*
* The point is a square rotated 45 degrees behind the left edge. Its diagonal has to
* equal the body's height, or the two edges meet the body short of its corners and the
* result reads as a notch stuck on the side rather than one tag-shaped outline.
*/
cy.mountUI(tag);
cy.get('.wrolpi-tag').should(($tag) => {
const el = $tag[0];
const height = el.getBoundingClientRect().height;
const side = parseFloat(getComputedStyle(el, '::before').width);
expect(side, 'the point is drawn at all').to.be.greaterThan(0);
expect(side * Math.SQRT2, "the point's diagonal matches the body height")
.to.be.closeTo(height, 2);
});
});
it('protrudes to the left, and reserves the room it needs', () => {
// The point is drawn outside the body, so without a matching margin it would print
// over whatever sits to the left -- the previous tag in the row.
cy.mountUI(tag);
cy.get('.wrolpi-tag').should(($tag) => {
const el = $tag[0];
const side = parseFloat(getComputedStyle(el, '::before').width);
const protrusion = side / 2;
expect(parseFloat(getComputedStyle(el).marginLeft), 'room for the point')
.to.be.at.least(protrusion - 1);
});
});
it('does not print the point over the first letter', () => {
/*
* A positioned pseudo-element paints above the parent's text, not behind it, and the
* point's inner half lies over the body. Removing the left padding once hid the first
* character of every tag: "Water" read as "ater".
*/
cy.mountUI(tag);
cy.get('.wrolpi-tag').should(($tag) => {
const el = $tag[0];
const styles = getComputedStyle(el);
const overlapIntoBody = parseFloat(getComputedStyle(el, '::before').width) / 2;
const textStartsAt = parseFloat(styles.paddingLeft) + parseFloat(styles.borderLeftWidth);
expect(textStartsAt, 'text starts clear of the point').to.be.greaterThan(overlapIntoBody);
});
});
it('keeps wrapped rows of tags off each other', () => {
// Tags wrap wherever they are listed, and a wrapped row was sitting on the row above.
cy.mountUI(<div style={{width: 240}}>
{['Water', 'Food', 'Medical', 'Shelter', 'Power', 'Comms'].map((name) => <span
key={name}
className='wrolpi-label wrolpi-tag'
style={{'--label-color': '#2185d0', '--label-text': '#ffffff'}}
>{name}</span>)}
</div>);
cy.get('.wrolpi-tag').should(($tags) => {
const rects = [...$tags].map((el) => el.getBoundingClientRect());
const rows = [...new Set(rects.map((r) => Math.round(r.top)))].sort((a, b) => a - b);
expect(rows.length, 'the tags wrapped').to.be.greaterThan(1);
for (let row = 1; row < rows.length; row++) {
const previousBottom = Math.max(...rects
.filter((r) => Math.round(r.top) === rows[row - 1])
.map((r) => r.bottom));
expect(rows[row] - previousBottom, `gap above row ${row + 1}`).to.be.at.least(3);
}
});
});
});
describe('tabs', () => {
themeNames.forEach((theme) => {
it(`gives an inactive tab no surface of its own in ${theme}`, () => {
/*
* A tab is a NavLink in the app's nav bars but a <button> on Inventory and in the
* gallery, and a button carries the UA's `buttonface` background -- rgb(239,239,239).
* Only the active tab set a background, so every inactive one was a near-white block
* in dark, night and amber.
*/
cy.mountUI(<TabBar>
<button className={tabClassName(true)}>Videos</button>
<button className={tabClassName(false)}>Channels</button>
</TabBar>, {theme});
cy.contains('button', 'Channels').should(($tab) =>
expect(getComputedStyle($tab[0]).backgroundColor, 'inactive tab is transparent')
.to.equal('rgba(0, 0, 0, 0)'));
});
});
});
describe('theme tokens actually resolve', () => {
themeNames.forEach((theme) => {
it(`paints text and borders from ${theme}'s table`, () => {

View file

@ -191,6 +191,13 @@ html[data-theme="amber"] .wrolpi-pagination button[data-active] {
color: var(--muted);
text-decoration: none;
font-size: 13px;
/*
* A tab is a NavLink in the app's nav bars but a <button> on Inventory and in the
* gallery, and a button carries the UA's own `buttonface` background -- rgb(239,239,239).
* Only the active tab set a background, so every inactive one was a near-white block in
* dark, night and amber. The bar's own surface has to show through instead.
*/
background: transparent;
border: 1px solid transparent;
/* Sits on the bar's own rule, so the active tab can erase it and read as
connected to the panel below. */
@ -554,27 +561,101 @@ html[data-theme="amber"] .wrolpi-path-input-control:focus-within {
/* ----------------------------------------------------------------- Labels */
/*
* Sized to Semantic's label (12px text, ~7px of vertical padding), which these replaced.
* The first pass came out at 11.5px with 3px of padding and read as noticeably smaller than
* what users had before -- a tag is a hit target as well as a word.
*/
.wrolpi-label {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
font-size: 11.5px;
padding: 5px 10px;
font-size: 12.5px;
font-weight: 500;
line-height: 1.3;
background: var(--label-color);
color: var(--btn-text);
/*
* `--label-text` is set per-tag by Tags.js, which calculates black or light text against
* the user's chosen fill. The fallback covers every label whose fill is a theme token.
* It is a variable rather than an inline `color` so that a theme which discards the fill
* can also discard the text colour calculated for it.
*/
color: var(--label-text, var(--btn-text));
border: 1px solid transparent;
}
/* White and black labels would vanish into the surface they sit on. */
/* ------------------------------------------------------------------- Tags */
/*
* A tag is a label with a physical shape: a pointed left edge and an eyelet, like a tag
* tied to a garment. The point is a square rotated 45 degrees behind the left edge -- its
* right half sits under the body in the same colour and merges, leaving the left half
* showing as the point. At 20px a side its diagonal is 28px, which is the body's height,
* so the two edges of the point meet the body exactly at its corners.
*
* The eyelet is drawn in `currentColor`, so it reads as a hole punched through the tag on
* any surface, filled or outlined, without needing to know what is behind it.
*/
.wrolpi-tag {
position: relative;
/* Room for the point, which is drawn outside the body. */
margin-left: 15px;
/* Tags wrap, and a wrapped row was sitting on the row above it. */
margin-bottom: 5px;
/*
* The point is centred on the left edge, so its inner half covers the body's first 10px
* -- and a positioned pseudo-element paints above the parent's text, not behind it, so
* without this the first letter is hidden under the point. Semantic's tag label carried
* the same asymmetry (`padding-left: 1.5em`) for the same reason.
*/
padding-left: 13px;
}
.wrolpi-tag::before {
content: '';
position: absolute;
top: 50%;
right: 100%;
width: 20px;
height: 20px;
transform: translate(50%, -50%) rotate(45deg);
background: var(--label-color);
}
/*
* The eyelet sits in the point, where a real tag's hole is -- inside the body it reads as a
* bullet in front of the word. It is outside the element's own box, over the ::before, and
* paints on top of it by being the later pseudo-element.
*/
.wrolpi-tag::after {
content: '';
position: absolute;
top: 50%;
left: -7px;
width: 5px;
height: 5px;
margin-top: -2.5px;
border-radius: 50%;
background: currentColor;
/* A hole, not a dot: it should read as absence rather than as a bullet. */
opacity: 0.45;
}
/*
* White and black labels would vanish into the surface they sit on. They set the text
* colour through `--label-text` rather than `color` for the same reason the per-tag colour
* does: setting `color` here outranked the base rule, so amber -- which replaces every fill
* with its own and has no `color` rule of its own -- was left painting `--white` on `--text`,
* light orange on orange.
*/
.wrolpi-label-white {
color: var(--black);
--label-text: var(--black);
border-color: var(--border);
}
.wrolpi-label-black {
color: var(--white);
--label-text: var(--white);
border-color: var(--border);
}
@ -588,6 +669,21 @@ html[data-theme="night"] .wrolpi-label {
border-color: var(--label-color);
}
/*
* Night draws the tag as an outline, so the point has to be two strokes rather than a solid
* diamond, and the body's own left border has to go -- otherwise a line closes across the
* base of the point and it reads as a chip with a triangle stuck to it.
*/
html[data-theme="night"] .wrolpi-tag {
border-left-color: transparent;
}
html[data-theme="night"] .wrolpi-tag::before {
background: transparent;
border-left: 1px solid var(--label-color);
border-bottom: 1px solid var(--label-color);
}
/* ---------------------------------------------------------------- Headers */
.wrolpi-header {
@ -659,6 +755,17 @@ html[data-theme="night"] .wrolpi-label {
html[data-theme="night"] .wrolpi-label,
html[data-theme="amber"] .wrolpi-label {
--label-color: var(--text) !important;
/*
* The per-tag text colour goes with the fill it was calculated against. Tags.js works
* out black-or-light from the user's chosen hex, and once that hex is discarded the
* answer is not just useless but wrong: amber was painting `#dddddd` on `#ff9500`, about
* 1.9:1 and a neutral grey in a theme whose whole point is that nothing else is. Night,
* which is an outline over a near-black page, was painting that black text on nothing.
*
* There is no contrast to calculate here, which is why this is a token and not a second
* version of `contrastingColor`: the fill is the theme's own, so the answer is fixed.
*/
--label-text: var(--btn-text) !important;
}
/* --------------------------------------------------------------- Messages */

View file

@ -573,6 +573,32 @@ describe('Label', () => {
});
});
describe('Label as a tag', () => {
it('is a plain chip unless asked to be a tag', () => {
// The same component carries the count badges in the Tags table -- file, zim, channel
// and domain counts -- and a pointed left edge on a number reads as an arrow.
const {container} = renderUI(<Label color='blue'>1,432</Label>);
expect(container.querySelector('.wrolpi-label')).toBeInTheDocument();
expect(container.querySelector('.wrolpi-tag')).not.toBeInTheDocument();
});
it('takes the tag shape when asked', () => {
const {container} = renderUI(<Label tag color='blue'>Water</Label>);
expect(container.querySelector('.wrolpi-tag')).toBeInTheDocument();
});
it('leaves the text colour to the stylesheet', () => {
// Every label routes its text colour through `--label-text` so that night and amber,
// which replace the fill with their own, can replace the text colour with it. An
// inline `color` could not be overridden by either.
const {container} = renderUI(<Label tag color='blue'>Water</Label>);
expect(container.querySelector('.wrolpi-label').style.color).toBe('');
});
});
describe('Table', () => {
it('renders a Semantic-shaped compound table', () => {
renderUI(<Table>