mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
bbe3cac9a8
commit
bbb267aa83
8 changed files with 690 additions and 66 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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} <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
|
||||
|
|
|
|||
|
|
@ -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> <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 → 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'}}>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
72
app/src/themes/contrast.ts
Normal file
72
app/src/themes/contrast.ts
Normal 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;
|
||||
}
|
||||
244
app/src/themes/navColors.test.js
Normal file
244
app/src/themes/navColors.test.js
Normal 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
150
app/src/themes/navColors.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue