ui: the navbar measures its foreground instead of assuming one

The bar's background is the one surface a user picks -- twelve colour names,
which each theme resolves to twelve of its own colours.  Forty-eight
backgrounds, and the links and status icons were drawn on all of them in a
single token, `--btn-text`, which is near-black in three of the four themes.

Night's `--brown` is #451212.  A near-black glyph on it is 1.33:1.  Nine of
night's twelve colours and seven of amber's sat below 4.5:1, and twelve of the
forty-eight below 3:1 -- icons the same colour as the bar behind them.

So the foreground is measured against the background the theme actually
resolved, choosing between that theme's own `--btn-text`, `--black` and
`--white`.  Its own, not literal ones: a white pixel in night mode undoes the
dark adaptation the theme exists to protect, so night's "white" is #ff8a8a.
Measuring at runtime rather than shipping an `--on-red` token per colour also
means the custom YAML themes still ahead get this for free.

Nothing is below 3:1 any more and the worst case is 3.27:1, up from 1.33:1.
No combination is worse than it was.

Ten of the forty-eight are still under 4.5:1 and no foreground will lift them:
a monochrome theme resolves all twelve names onto one ramp, and a mid-tone of a
hue cannot carry text of that same hue.  That shortfall is in the palette, so
/theme-sample now draws all twelve bars with the measured ratio printed beside
each one, calling out the ten -- it is a decision to make while looking at them.

The WCAG primitives move to themes/contrast so theme code can measure a colour
without importing Common.js, which pulls in half the component library.
Common.js forwards `contrastRatio` with a delegating function rather than a bare
re-export: six specs mock it with `{...jest.requireActual('./Common')}`, the
spread invokes every getter, and those spreads run re-entrantly while the module
is still evaluating -- a re-export getter fires before its import binding exists
and takes the whole suite down.

Tested: navColors.test.js measures all forty-eight against the palette parsed
out of tokens.css, and holds Nav.js to the call by source since neither bar is
reachable from a test that could measure it.  ui-layout.cy.js measures the
painted result in a real browser in all four themes and holds it against the
number the component reports.  Reverting to the fixed foreground reddens four
jest cases and three cypress ones.

1115 jest / 62 suites, 177 in ui-layout.cy.js, clean npm run build.
This commit is contained in:
Roland 2026-08-02 14:07:19 -06:00
parent bbe3cac9a8
commit bbb267aa83
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 690 additions and 66 deletions

View file

@ -1,6 +1,6 @@
/* Nav bar: a hand-rolled flexbox bar (was a Semantic Menu). The user-chosen
background color and the `--btn-text` foreground are set inline per theme;
this stylesheet only supplies structure and hover affordances. */
/* Nav bar: a hand-rolled flexbox bar (was a Semantic Menu). The user-chosen background
and the foreground measured against it are set inline (see themes/navColors); this
stylesheet only supplies structure and hover affordances. */
.wrolpi-navbar {
display: flex;
align-items: stretch;

View file

@ -29,6 +29,7 @@ import {
import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useVnc, useWROLMode} from "../hooks/customHooks";
import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts";
import {themeChoices} from "../themes/names";
import {contrastRatio as measureContrast} from "../themes/contrast";
import {FilePreviewContext} from "./FilePreview";
import _ from "lodash";
import {killDownloads, startDownloads, unlockCookies} from "../api";
@ -1575,52 +1576,28 @@ export const toLocaleString = (num, locale = 'en-US') => {
return num.toLocaleString(locale);
}
/**
* WCAG relative luminance: sRGB channels linearised first, then Rec. 709 weighted.
/*
* The WCAG primitives now live in themes/contrast, so the theme code can measure a colour
* without importing this module -- Common.js pulls in half the component library, and
* `themes/` importing it is how an import cycle starts. Forwarded because a good many call
* sites and tests already take `contrastRatio` from here.
*
* The linearisation is the part that matters. Weighting the gamma-encoded bytes directly --
* which is what this did before -- is a rough approximation that misjudges mid-tone blues and
* purples badly enough to pick the wrong text colour for them.
*/
function relativeLuminance(color) {
const rgb = (typeof color === 'string') ? hexToRGBArray(color) : color;
if (!rgb) {
return 0;
}
const linear = rgb.map(value => {
const channel = value / 255;
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
});
return (0.2126 * linear[0]) + (0.7152 * linear[1]) + (0.0722 * linear[2]);
}
/** WCAG contrast ratio between two colours, from 1:1 (identical) to 21:1 (black on white). */
export function contrastRatio(a, b) {
const luminances = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
}
/**
* A hex colour as [r, g, b], accepting both `#rrggbb` and the shorthand `#rgb`.
* It is forwarded by a function that delegates rather than by `export {contrastRatio}`, and
* that is load-bearing. A bare re-export compiles to a getter that dereferences the imported
* module, and six specs mock this module with `{...jest.requireActual('./Common')}` -- a
* spread invokes every getter, and those spreads run re-entrantly while Common.js is still
* evaluating (Common -> customHooks -> Common, both mocked that way). The getter fires
* before the import binding is assigned, throws `Cannot read properties of undefined`, and
* takes the whole suite with it. A function is read by the spread but not called, so it
* dereferences nothing until evaluation has finished.
*
* The shorthand matters because tag colours are not only chosen in the colour picker: they
* live in the database and in a config file, and a config is something a user edits by hand
* The hex parser accepting the `#rgb` shorthand matters and is not incidental: tag colours
* are not only chosen in the colour picker, they live in a config file a user edits by hand
* -- configs are the source of truth in WROLPi. Rejecting `#fff` left the luminance at zero,
* so a near-white tag was treated as black and handed light text.
*/
function hexToRGBArray(color) {
let hex = (typeof color === 'string' ? color : '').replace(/^#/, '');
if (hex.length === 3) {
// #abc means #aabbcc, not #0a0b0c.
hex = hex.split('').map(digit => digit + digit).join('');
}
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
console.error('Invalid hex color: ' + color);
return;
}
let rgb = [];
for (let i = 0; i <= 2; i++) rgb[i] = parseInt(hex.substr(i * 2, 2), 16);
return rgb;
export function contrastRatio(a, b) {
return measureContrast(a, b);
}
export const TAG_TEXT_DARK = '#000000';

View file

@ -19,6 +19,7 @@ import {useReorganizationStatus} from "../contexts/FileWorkerStatusContext";
import {SearchIconButton} from "./Search";
import {HELP_VIEWER_URI, NAME, semanticUIColorMap} from "./Vars";
import {useOverflowNav} from "../hooks/useOverflowNav";
import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors";
import _ from "lodash";
function updateFavicon(colorName) {
@ -141,7 +142,7 @@ function NavIconWrapper({children}) {
function useNavColorSetting() {
// Use localstorage to avoid flickering navbar color on startup.
const {settings} = React.useContext(SettingsContext);
const [navColor, setNavColor] = useLocalStorage('nav_color', 'violet');
const [navColor, setNavColor] = useLocalStorage('nav_color', defaultNavColor);
React.useEffect(() => {
if (!_.isEmpty(settings)) {
@ -163,7 +164,12 @@ export function NavBar() {
const wrolModeEnabled = useWROLMode();
const {status} = React.useContext(StatusContext);
const navColor = useNavColorSetting();
const wrolpiIcon = <img src='/icon.svg' height='32px' width='32px' alt='WROLPi Home Icon'/>;
/*
* The bar's foreground is measured against the background the theme resolved for the
* user's colour, not taken from a single token. See themes/navColors.
*/
const navColors = useNavColors(navColor);
const wrolpiIcon =<img src='/icon.svg' height='32px' width='32px' alt='WROLPi Home Icon'/>;
const name = <i>{NAME || wrolpiIcon}</i>;
const topNavText = wrolModeEnabled ? <>{name}&nbsp; <Icon name='lock'/></> : name;
@ -362,15 +368,7 @@ export function NavBar() {
return <>
<Media between={['mobile', 'tablet']}>
<nav className='wrolpi-navbar' id='global_navbar'
style={{
background: `var(--${navColor})`,
color: 'var(--btn-text)',
// The hotspot glyph stacks two icons, and the corner one paints a disc
// of its surface behind itself. The bar's colour is the user's choice,
// so it has to be handed down rather than looked up from a token.
'--icon-stack-bg': `var(--${navColor})`,
}}>
<nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
{homeLink}
<div className='wrolpi-navbar-right'>
{icons}
@ -380,12 +378,12 @@ export function NavBar() {
</nav>
</Media>
<Media greaterThanOrEqual='tablet'>
<DesktopNav navColor={navColor} homeLink={homeLink} icons={icons}/>
<DesktopNav navColors={navColors} homeLink={homeLink} icons={icons}/>
</Media>
</>
}
function DesktopNav({navColor, homeLink, icons}) {
function DesktopNav({navColors, homeLink, icons}) {
const containerRef = React.useRef(null);
const homeRef = React.useRef(null);
const rightMenuRef = React.useRef(null);
@ -400,15 +398,7 @@ function DesktopNav({navColor, homeLink, icons}) {
return (
<div ref={containerRef}>
<nav className='wrolpi-navbar' id='global_navbar'
style={{
background: `var(--${navColor})`,
color: 'var(--btn-text)',
// The hotspot glyph stacks two icons, and the corner one paints a disc
// of its surface behind itself. The bar's colour is the user's choice,
// so it has to be handed down rather than looked up from a token.
'--icon-stack-bg': `var(--${navColor})`,
}}>
<nav className='wrolpi-navbar' id='global_navbar' style={navBarStyle(navColors)}>
<span ref={homeRef} style={{display: 'contents'}}>{homeLink}</span>
{(isReady ? visibleLinks : allLinks).map((link, idx) => (
<span

View file

@ -43,6 +43,8 @@ import {
} from './ui';
import {semanticColorNames} from '../themes/mantine';
import {contrastingColor} from './Common';
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
import {IconMenu2, IconSearch} from '@tabler/icons-react';
/*
* A gallery of every component in the library, in the current theme.
@ -76,6 +78,54 @@ const Row = ({children}) => <div style={{display: 'flex', gap: 10, flexWrap: 'wr
{children}
</div>;
/*
* One navigation bar, in one of the twelve colours a user can pick for it.
*
* The bar is the only surface in the app whose background the USER chooses, and every theme
* resolves those twelve names to twelve of its own colours -- forty-eight backgrounds that
* the same links and status icons have to stay legible on. They did not: the bar drew every
* one of them in `--btn-text`, which is near-black in three of the four themes, and night's
* `--brown` is #451212. That glyph was 1.33:1 against the bar behind it.
*
* These are the real `.wrolpi-navbar` chrome and the real `navBarStyle`, not a mock-up, so a
* sample cannot show a bar the app does not draw. The measured ratio is printed beside each
* one because ten of the forty-eight still sit under 4.5:1 and no foreground will lift them
* -- a monochrome theme resolves all twelve names onto one ramp, and a mid-tone of a hue
* cannot carry text of that same hue. Fixing those means changing the palette, which is a
* decision to make while looking at them.
*/
const AA_CONTRAST = 4.5;
export const NavBarSample = ({color}) => {
const colors = useNavColors(color);
const short = colors.ratio !== null && colors.ratio < AA_CONTRAST;
return <div data-nav-sample={color} style={{marginBottom: 8}}>
<nav className='wrolpi-navbar' style={{...navBarStyle(colors), minHeight: '2.8em'}}>
<span className='wrolpi-navbar-link'><i>WROLPi</i>&nbsp;<Icon name='lock'/></span>
<span className='wrolpi-navbar-link'>Videos</span>
<span className='wrolpi-navbar-link'>Map</span>
<div className='wrolpi-navbar-right' style={{gap: '0.5em', paddingRight: '0.5em'}}>
<Icon name='warning sign' size='large' label='Drive health warning'/>
<Icon name='hdd' size='large' label='Drive temperature warning'/>
<Icon name='plug' size='large' label='API is not responding'/>
<IconButton icon={IconSearch} label='Search' variant='subtle'/>
<IconButton icon={IconMenu2} label='Menu' variant='subtle'/>
</div>
</nav>
<div style={{
fontSize: 11, color: short ? 'var(--danger)' : 'var(--muted)',
padding: '3px 2px 0', display: 'flex', gap: 8,
}}>
<strong style={{fontWeight: 600}}>{color}</strong>
<span data-nav-sample-ratio={color}>
{colors.ratio === null ? '—' : `${colors.ratio.toFixed(2)}:1`}
</span>
{short && <span>below 4.5:1 — the palette has no better option</span>}
</div>
</div>
};
// Numbers the stacked toasts so it is obvious which press produced which, and that a second
// press adds a toast rather than replacing the first. Module scope, so it survives re-renders.
let toastCounter = 0;
@ -109,6 +159,20 @@ export function ThemeSamplePage() {
</Panel>
</Section>
<Section label='Navigation bars'>
<Panel>
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 0}}>
Every colour the navigation bar can be set to (Settings &rarr; navbar
colour), in the <strong>{theme}</strong> theme. The text and icons are not
a fixed colour: each bar is measured, and drawn in whichever of this
theme's own colours reads best on it. The ratio under each bar is that
measurement — anything under 4.5:1 is called out, and means the palette
itself has no colour that reads on that background.
</p>
{navColorNames.map(color => <NavBarSample key={color} color={color}/>)}
</Panel>
</Section>
<Section label='Media filtering'>
<Panel>
<div style={{display: 'flex', gap: 18, flexWrap: 'wrap', alignItems: 'flex-start'}}>

View file

@ -10,6 +10,8 @@ import {CardPoster, contrastingColor, HelpHeader, LoadStatistic} from '../Common
import {Notifications} from '@mantine/notifications';
import {clearToasts, toast} from './toast';
import {monochromeThemes, themeNames} from '../../themes/names';
import {navColorNames} from '../../themes/navColors';
import {NavBarSample} from '../ThemeSamplePage';
/* `--panel` is authored as a hex; computed backgrounds come back as rgb(). */
const hexToRgb = (hex) => {
@ -2071,3 +2073,128 @@ describe('pagination sits in the middle of its container', () => {
});
});
});
describe('the navigation bar is legible on every colour a user can pick', () => {
/*
* The bar's background is the one surface the USER chooses, by hue name, and each theme
* resolves those twelve names to twelve of its own colours. Forty-eight backgrounds; the
* links and status icons have to read on all of them. They did not -- every one was
* drawn in `--btn-text`, near-black in three of the four themes, against backgrounds like
* night's `--brown` (#451212). That glyph was 1.33:1 against the bar behind it.
*
* navColors.test.js measures the mapping against the palette parsed out of tokens.css,
* which is the cheap and exhaustive half. What it cannot see is whether the value the
* mapping returns reaches the pixels: whether the tokens resolve through the cascade,
* whether the style lands on the bar, and what an icon inside it actually inherits. So
* this measures the painted result and holds it against the number the component claims.
*
* `NavBarSample` is the gallery's bar rather than <NavBar/> itself, which needs the
* settings, status and worker contexts and a dozen polling hooks. Both build their style
* with the same `navBarStyle(useNavColors(colour))` call, and navColors.test.js has a
* source guard holding NavBar to that.
*/
const barsFor = (theme) => {
cy.mountUI(<div>
{navColorNames.map(color => <NavBarSample key={color} color={color}/>)}
</div>, {theme});
};
themeNames.forEach((theme) => {
it(`paints a resolved background for all twelve colours in ${theme}`, () => {
barsFor(theme);
navColorNames.forEach((color) => {
cy.get(`[data-nav-sample="${color}"] .wrolpi-navbar`).should(($nav) => {
const background = getComputedStyle($nav[0]).backgroundColor;
// An unresolved token paints nothing at all, and a transparent bar over
// the page would read as "the colour just looks wrong" rather than as a
// broken style.
expect(background, `${theme}/${color} bar background`)
.to.not.equal('rgba(0, 0, 0, 0)');
});
});
});
it(`clears 3:1 on every colour in ${theme}`, () => {
/*
* 3:1 is the WCAG floor for graphical objects, which is what these icons are.
* Twelve of the forty-eight combinations were below it before measurement.
* Ten are still under 4.5:1 and cannot be lifted by any foreground -- a
* monochrome theme resolves all twelve names onto one ramp, and a mid-tone of a
* hue carries no text of that same hue. That residue is the palette's, and it
* is printed beside each sample in the gallery so it can be decided on.
*/
barsFor(theme);
navColorNames.forEach((color) => {
cy.contrastRatio(`[data-nav-sample="${color}"] .wrolpi-navbar-link`)
.then((ratio) => {
expect(ratio, `${theme}/${color} link contrast`).to.be.greaterThan(3);
});
});
});
it(`gives its icons the same foreground as its links in ${theme}`, () => {
// The actual complaint was about the icons, not the words. They take `color`
// by inheritance, which is a thing a stylesheet can quietly break.
barsFor(theme);
navColorNames.forEach((color) => {
cy.get(`[data-nav-sample="${color}"]`).should(($sample) => {
const link = $sample[0].querySelector('.wrolpi-navbar-link');
const icon = $sample[0].querySelector('.wrolpi-navbar-right svg');
expect(icon, `${theme}/${color} has an icon to measure`).to.not.be.null;
expect(getComputedStyle(icon).color, `${theme}/${color} icon colour`)
.to.equal(getComputedStyle(link).color);
});
});
});
it(`paints the ratio it reports in ${theme}`, () => {
/*
* Ties the number to the pixels. Without this the gallery could print a
* comfortable figure beside a bar drawn in something else entirely, and both
* halves would look right on their own.
*/
barsFor(theme);
navColorNames.forEach((color) => {
cy.get(`[data-nav-sample-ratio="${color}"]`).invoke('text').then((text) => {
const reported = parseFloat(text);
expect(reported, `${theme}/${color} reported a ratio`).to.be.greaterThan(1);
cy.contrastRatio(`[data-nav-sample="${color}"] .wrolpi-navbar-link`)
.then((painted) => {
expect(painted, `${theme}/${color} painted vs reported`)
.to.be.closeTo(reported, 0.05);
});
});
});
});
});
it('does not simply use --btn-text everywhere, which is what it replaced', () => {
/*
* The inverse. Every case above would pass on the old fixed foreground in the two
* themes where `--btn-text` happens to be the right end of the palette, so without
* this the suite could go green on a revert. Night is where the difference is
* largest: `--brown` takes `--white` (#ff8a8a) and `--red` keeps `--btn-text`, so
* the bar's foreground is demonstrably not one value.
*/
cy.mountUI(<div>
<NavBarSample color='brown'/>
<NavBarSample color='red'/>
</div>, {theme: 'night'});
cy.get('[data-nav-sample="brown"] .wrolpi-navbar-link').then(($brown) => {
cy.get('[data-nav-sample="red"] .wrolpi-navbar-link').then(($red) => {
const brown = getComputedStyle($brown[0]).color;
const red = getComputedStyle($red[0]).color;
expect(brown, 'brown and red bars take different foregrounds')
.to.not.equal(red);
// And the one that changed is the one that was unreadable.
expect(toRgb(brown), 'brown no longer takes --btn-text')
.to.not.equal(toRgb('#0a0000'));
});
});
});
});

View file

@ -0,0 +1,72 @@
/*
* WCAG contrast primitives.
*
* These live in `themes/` rather than in Common.js because theme code needs them and
* Common.js is a large module that imports half the component library -- `themes/`
* importing it is how an import cycle starts, and this app has already paid for one of
* those (see import-cycles.test.js). Common.js re-exports `contrastRatio` from here so
* its existing callers are unaffected.
*/
/** A hex colour as [r, g, b], accepting both `#rrggbb` and the shorthand `#rgb`. */
export function hexToRGBArray(color: string): number[] | undefined {
let hex = (typeof color === 'string' ? color : '').trim().replace(/^#/, '');
if (hex.length === 3) {
// #abc means #aabbcc, not #0a0b0c.
hex = hex.split('').map(digit => digit + digit).join('');
}
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
return undefined;
}
const rgb: number[] = [];
for (let i = 0; i <= 2; i++) rgb[i] = parseInt(hex.substr(i * 2, 2), 16);
return rgb;
}
/**
* WCAG relative luminance: sRGB channels linearised first, then Rec. 709 weighted.
*
* The linearisation is the part that matters. Weighting the gamma-encoded bytes directly
* is a rough approximation that misjudges mid-tone blues and purples badly enough to pick
* the wrong text colour for them.
*/
export function relativeLuminance(color: string | number[]): number {
const rgb = (typeof color === 'string') ? hexToRGBArray(color) : color;
if (!rgb) {
return 0;
}
const linear = rgb.map(value => {
const channel = value / 255;
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
});
return (0.2126 * linear[0]) + (0.7152 * linear[1]) + (0.0722 * linear[2]);
}
/** WCAG contrast ratio between two colours, from 1:1 (identical) to 21:1 (black on white). */
export function contrastRatio(a: string | number[], b: string | number[]): number {
const luminances = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
}
/**
* Whichever candidate actually reads best on `background`.
*
* Measures every option rather than comparing a brightness figure to a threshold. A
* threshold picks the worse of two options for every mid-tone -- which is the whole
* defect this exists to avoid -- and a mid-tone is exactly what a user picks when they
* choose a navbar colour.
*
* Ties go to the earlier candidate, so a caller can order them by preference.
*/
export function bestForeground(background: string, candidates: string[]): string | undefined {
let best: string | undefined;
let bestRatio = -1;
candidates.filter(Boolean).forEach((candidate) => {
const ratio = contrastRatio(candidate, background);
if (ratio > bestRatio) {
best = candidate;
bestRatio = ratio;
}
});
return best;
}

View file

@ -0,0 +1,244 @@
import fs from 'fs';
import path from 'path';
import {contrastRatio} from './contrast';
import {defaultNavColor, navColorNames, navColorsFrom} from './navColors';
import {themeNames} from './names';
import {semanticUIColorMap} from '../components/Vars';
/*
* The navbar foreground, measured against the palette that actually ships.
*
* The bar's background is the one colour a user picks by name, so there are twelve of them
* per theme and forty-eight in total -- too many to eyeball, and the reason the bar drew
* near-black text on night's #451212 for as long as it did. The tokens are parsed out of
* tokens.css rather than restated here: a list restated here is a list that agrees with
* itself while disagreeing with the stylesheet.
*/
const tokensCss = fs.readFileSync(path.join(__dirname, 'tokens.css'), 'utf8');
/** Every `--name: #hex` declared in one theme's block. */
const paletteOf = (theme) => {
const pattern = theme === 'light'
? /:root,\s*html\[data-theme="light"]\s*{([^}]*)}/
: new RegExp(`html\\[data-theme="${theme}"]\\s*{([^}]*)}`);
const match = tokensCss.match(pattern);
if (!match) throw new Error(`tokens.css has no block for ${theme}`);
return Object.fromEntries([...match[1].matchAll(/(--[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,6})\s*;/g)]
.map(m => [m[1], m[2]]));
};
/** A token reader for one theme, as `navColorsFrom` expects. */
const readerFor = (theme) => {
const palette = paletteOf(theme);
return (name) => palette[name] || '';
};
/** Every (theme, navColor) pair, with what the bar resolves to. */
const everyCombination = () => themeNames.flatMap(theme => navColorNames.map((navColor) => {
const read = readerFor(theme);
return {theme, navColor, palette: paletteOf(theme), ...navColorsFrom(read, navColor)};
}));
describe('the navbar palette', () => {
it('parses a palette for every theme', () => {
// The premise for every case below. Without it a broken parser reads as a
// navbar that never fails, because there would be nothing to measure.
themeNames.forEach((theme) => {
const palette = paletteOf(theme);
expect({theme, colors: Object.keys(palette).length > 15}).toEqual(
{theme, colors: true});
});
});
it('offers exactly the colours the favicon has an icon for', () => {
/*
* `semanticUIColorMap` supplies the fixed hexes for `/favicon-<colour>.svg` and the
* `theme-color` meta tag -- a file on disk and OS chrome, neither of which can take a
* token. A colour offered here with no icon there falls back to violet, so the bar
* and the browser tab would disagree.
*/
expect([...navColorNames].sort()).toEqual(Object.keys(semanticUIColorMap).sort());
});
it('defaults to a colour it offers', () => {
expect(navColorNames).toContain(defaultNavColor);
});
});
describe('the navbar foreground', () => {
/** The tokens the bar is allowed to draw its text in. */
const candidatesOf = (palette) =>
[palette['--btn-text'], palette['--black'], palette['--white']];
it('takes a colour the theme itself supplies, never a literal black or white', () => {
/*
* A literal white pixel in night mode undoes the user's dark adaptation, which is the
* one thing that theme exists to prevent. Night's "white" is #ff8a8a and its "black"
* is #2a0808; both are red, and both are among what this must choose between.
*/
everyCombination().forEach(({theme, navColor, color, palette}) => {
expect({theme, navColor, color}).toEqual({
theme, navColor,
color: candidatesOf(palette).includes(color)
? color : `expected a theme token, got ${color}`,
});
});
});
it('picks the best of them, every time', () => {
// The invariant. A brightness threshold picks the worse option for every mid-tone,
// and a mid-tone is exactly what a navbar colour is.
everyCombination().forEach(({theme, navColor, background, color, palette}) => {
const chosenRatio = contrastRatio(color, background);
const rejected = candidatesOf(palette)
.filter(candidate => candidate !== color)
.map(candidate => contrastRatio(candidate, background));
expect({theme, navColor, best: rejected.every(ratio => chosenRatio >= ratio)})
.toEqual({theme, navColor, best: true});
});
});
it('reports the ratio it measured', () => {
everyCombination().forEach(({theme, navColor, background, color, ratio}) => {
expect({theme, navColor, ratio: Number(ratio.toFixed(4))}).toEqual({
theme, navColor, ratio: Number(contrastRatio(color, background).toFixed(4)),
});
});
});
it('never does worse than the --btn-text it replaced', () => {
/*
* The regression guard, and the whole claim of the change. `--btn-text` is a single
* value per theme, so it was right for whichever end of the palette it happened to
* match and wrong for the other -- 1.33:1 on night's brown.
*/
everyCombination().forEach(({theme, navColor, background, ratio, palette}) => {
const before = contrastRatio(palette['--btn-text'], background);
expect({theme, navColor, atLeastAsGood: ratio >= before - 0.001})
.toEqual({theme, navColor, atLeastAsGood: true});
});
});
it('lifts every combination clear of 3:1', () => {
/*
* 3:1 is the WCAG floor for large text and for graphical objects, which is what the
* bar's icons are. Twelve combinations were below it -- night's brown at 1.33:1 was
* a glyph the same colour as the bar behind it.
*/
const failing = everyCombination()
.filter(({ratio}) => ratio < 3)
.map(({theme, navColor, ratio}) => `${theme}/${navColor} ${ratio.toFixed(2)}`);
expect(failing).toEqual([]);
});
it('is a real constraint: --btn-text does NOT clear 3:1', () => {
/*
* Without this the case above could pass on any mapping at all, including the one it
* replaced, and would prove nothing. Seventeen combinations sat below 4.5:1 with a
* fixed foreground and twelve below 3:1.
*/
const failing = everyCombination()
.filter(({background, palette}) => contrastRatio(palette['--btn-text'], background) < 3);
expect(failing.length).toBeGreaterThan(5);
});
});
describe('what measurement cannot fix', () => {
/*
* Eleven of the forty-eight combinations still sit under 4.5:1, and no choice of
* foreground will lift them, because the shortfall is in the BACKGROUND.
*
* A monochrome theme resolves all twelve colour names onto one red (or amber) ramp, and
* seven of night's land in the middle of it -- #b03030 is 2.91:1 from night's darkest red
* and 2.52:1 from its brightest, so the best available option is the one this picks and
* it is still short. There is no foreground in a one-hue palette that reads on a
* mid-tone of that same hue; the fix, if the residue matters, is to the palette.
*
* Recorded as a number rather than a comment so it cannot quietly grow. /theme-sample
* prints the measured ratio beside every sample, which is where this gets decided.
*/
const belowAA = () => everyCombination()
.filter(({ratio}) => ratio < 4.5)
.map(({theme, navColor}) => `${theme}/${navColor}`);
it('leaves ten combinations short of 4.5:1, all of them mid-tones', () => {
expect(belowAA().sort()).toEqual([
'amber/blue', 'amber/grey', 'amber/teal',
'light/yellow',
'night/blue', 'night/green', 'night/grey', 'night/pink', 'night/teal',
'night/violet',
]);
});
it('holds the worst case above 3:1 -- night/green, at 3.27', () => {
// Nothing is invisible any more, which is the bar this change had to clear. The
// number is here so that a palette edit which makes it worse has to say so.
const worst = everyCombination().sort((a, b) => a.ratio - b.ratio)[0];
expect({combination: `${worst.theme}/${worst.navColor}`, above3: worst.ratio > 3})
.toEqual({combination: 'night/green', above3: true});
});
it('leaves none of them in dark, which has a full palette to draw on', () => {
// The contrast with the monochrome themes, and the evidence that the shortfall is
// the palette's rather than the mapping's.
expect(belowAA().filter(name => name.startsWith('dark/'))).toEqual([]);
});
});
describe('the bar the app actually renders', () => {
/*
* Nav.js draws two bars -- one for mobile, one for desktop -- and neither is reachable
* from a test that could measure it. <NavBar/> needs the settings, status and file-worker
* contexts and a dozen polling hooks, and which of the two renders is decided by a media
* query. ui-layout.cy.js measures the gallery's bar instead, which is the same chrome
* built with the same call; these hold Nav.js to that call, so a bar that stops going
* through the measurement cannot pass by being untested.
*/
const source = fs.readFileSync(
path.join(__dirname, '../components/Nav.js'), 'utf8');
it('styles both bars through navBarStyle', () => {
const bars = [...source.matchAll(/className='wrolpi-navbar'[^>]*/g)].map(m => m[0]);
// Mobile and desktop. If this drops to one, the case below is covering half of
// what it claims to.
expect(bars.length).toBe(2);
bars.forEach(bar => expect(bar).toContain('style={navBarStyle(navColors)}'));
});
it('never hardcodes the foreground on the bar again', () => {
// What it did before: one token for forty-eight backgrounds.
expect(source).not.toMatch(/color:\s*'var\(--btn-text\)'/);
});
});
describe('when the tokens cannot be read', () => {
/*
* jsdom resolves no stylesheet, and neither does the first render before the document
* has one. The bar must still be painted by CSS then, and must still follow the theme --
* falling through to an empty `background` would leave a transparent bar over the page.
*/
it('falls back to the tokens themselves', () => {
expect(navColorsFrom(() => '', 'olive')).toEqual({
background: 'var(--olive)', color: 'var(--btn-text)', ratio: null,
});
});
it('falls back for an unknown colour too', () => {
// `nav_color` comes from a config file, which a user edits by hand.
expect(navColorsFrom(() => '', 'chartreuse').background)
.toEqual(`var(--${defaultNavColor})`);
});
it('resolves an unknown colour to the default when tokens ARE readable', () => {
const read = readerFor('light');
expect(navColorsFrom(read, 'chartreuse').background)
.toEqual(paletteOf('light')[`--${defaultNavColor}`]);
});
});

150
app/src/themes/navColors.ts Normal file
View file

@ -0,0 +1,150 @@
import React from 'react';
import {bestForeground, contrastRatio} from './contrast';
/*
* The navigation bar's colours.
*
* The bar's background is the one colour in the interface the USER picks (Settings ->
* navbar colour), and it is picked by hue name -- `violet`, `olive`, `brown` -- which each
* theme then resolves to its own hex. So the bar has a background the theme cannot know in
* advance, and its links and status icons have to stay legible on all twelve of them in all
* four themes: forty-eight combinations, not one.
*
* It used to draw them all with the single `--btn-text` token, which is white in light and
* near-black in the other three. Near-black on night's `--brown` (#451212) is 1.33:1 and on
* amber's (#452708) 1.48:1 -- nine of night's twelve colours and seven of amber's sat below
* 4.5:1, which is why the bar's icons were unreadable. `--btn-text` is the right token for
* a button, whose fill the theme chooses; it was never the right one here.
*
* So the foreground is MEASURED instead, against the background the theme actually resolved.
* The two candidates are the theme's own `--black` and `--white` rather than literal black and
* white, because a literal white pixel in night mode defeats the entire point of the mode --
* night's "white" is #ff8a8a, a bright red, and amber's is #ffc875.
*
* Measuring at runtime rather than shipping a paired `--on-red`/`--on-olive` token for every
* colour also means the custom YAML themes still ahead of us get this for free: a theme
* supplies a palette, and the correct foreground for each entry falls out of it.
*/
/**
* The colours offered for the bar, in picker order.
*
* The same keys as `semanticUIColorMap` in components/Vars.js, which maps them to the fixed
* hexes used for the favicon and the mobile status bar (those are files and OS chrome, so
* they cannot take a token). navColors.test.js holds the two lists together.
*/
export const navColorNames: string[] = [
'red', 'orange', 'yellow', 'olive', 'green', 'teal',
'blue', 'violet', 'purple', 'pink', 'brown', 'grey',
];
export const defaultNavColor = 'violet';
export interface NavColors {
/** CSS colour for the bar itself. */
background: string;
/** CSS colour its text and icons take. */
color: string;
/**
* Measured contrast of `color` on `background`, or null when the tokens could not be
* read and the pair below is the un-measured fallback. Surfaced so /theme-sample can
* show the number next to the sample.
*/
ratio: number | null;
}
/**
* The bar's colours, given a way to read theme tokens.
*
* Takes a reader rather than an element so it can be measured against the real palette in a
* unit test without a browser -- the shipped tokens are parsed out of tokens.css and handed
* in. Forty-eight combinations is far too many to eyeball.
*/
export function navColorsFrom(read: (name: string) => string, navColor: string): NavColors {
const name = navColorNames.includes(navColor) ? navColor : defaultNavColor;
const background = read(`--${name}`);
/*
* `--btn-text` leads because it is the token this used to use unconditionally, so where
* it was already the best choice nothing changes -- and in the monochrome themes it is
* genuinely the darkest thing the theme has (night's is #0a0000 against a `--black` of
* #2a0808), which makes it the right foreground for every bright bar. It was never
* wrong; it was just applied without looking at what it was being drawn on.
*/
const candidates = [read('--btn-text'), read('--black'), read('--white')].filter(Boolean);
if (!background || candidates.length === 0) {
/*
* No stylesheet to read: jsdom, or a very early render. Fall back to the tokens
* themselves so the bar is still painted by CSS and still changes with the theme --
* this is the pre-measurement behaviour, and it is only ever wrong about the
* foreground.
*/
return {background: `var(--${name})`, color: 'var(--btn-text)', ratio: null};
}
const color = bestForeground(background, candidates) as string;
return {background, color, ratio: contrastRatio(color, background)};
}
/** The bar's colours read from a live document. */
export function resolveNavColors(
navColor: string,
element: Element = document.documentElement,
): NavColors {
const styles = window.getComputedStyle(element);
return navColorsFrom(name => styles.getPropertyValue(name).trim(), navColor);
}
/**
* The inline style for a bar painted in these colours.
*
* Shared by the real navigation bar and the /theme-sample gallery, so a sample cannot end
* up demonstrating a bar the app does not actually draw.
*/
export function navBarStyle(colors: NavColors): React.CSSProperties {
return {
background: colors.background,
color: colors.color,
/*
* The hotspot glyph stacks two icons, and the corner one paints a disc of its
* surface behind itself. The bar's colour is the user's choice, so it has to be
* handed down rather than looked up from a token.
*/
'--icon-stack-bg': colors.background,
} as React.CSSProperties;
}
/**
* The bar's colours, kept current as the theme changes.
*
* Watches the `data-theme` attribute rather than subscribing to ThemeContext, because the
* attribute is what the tokens actually key off and it is stamped by ThemeProvider in an
* effect of its own. React runs child effects before parent effects, so a consumer that
* re-read the tokens when the context value changed would read them one theme late -- and
* the bar would sit on the previous theme's foreground until something else re-rendered it.
*
* The attribute is also stamped by the inline script in index.html before React loads, and
* that is a change no context can report at all.
*/
export function useNavColors(navColor: string): NavColors {
const [colors, setColors] = React.useState<NavColors>(
() => navColorsFrom(() => '', navColor));
React.useLayoutEffect(() => {
const update = () => setColors(resolveNavColors(navColor));
update();
if (typeof MutationObserver !== 'function') {
return;
}
const observer = new MutationObserver(update);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, [navColor]);
return colors;
}