mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
ecf611931b
commit
fe92455ee6
3 changed files with 320 additions and 7 deletions
|
|
@ -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`, () => {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue