diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js index bc537cbc..7ea77209 100644 --- a/app/src/components/Nav.js +++ b/app/src/components/Nav.js @@ -208,7 +208,7 @@ export function NavBar() { * user's colour, not taken from a single token. See themes/navColors. */ const navColors = useNavColors(navColor); - const wrolpiIcon =WROLPi Home Icon; + const wrolpiIcon = WROLPi Home Icon; const name = {NAME || wrolpiIcon}; const topNavText = wrolModeEnabled ? <>{name}  : name; diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index b42b711d..b64a7b87 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -2199,8 +2199,13 @@ describe('the navigation bar is legible on every colour a user can pick', () => * 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. + * largest: `--brown` takes `--white` and `--red` keeps `--btn-text`, so the bar's + * foreground is demonstrably not one value. + * + * `--btn-text` is READ from the theme rather than written out as a hex. The unit + * tests parse tokens.css precisely so a palette edit cannot leave a stale claim + * behind, and a literal here would be the one place that rule was broken -- it would + * either go stale or quietly stop testing anything if night's token moved. */ cy.mountUI(
@@ -2211,11 +2216,19 @@ describe('the navigation bar is legible on every colour a user can pick', () => cy.get('[data-nav-sample="red"] .wrolpi-navbar-link').then(($red) => { const brown = getComputedStyle($brown[0]).color; const red = getComputedStyle($red[0]).color; + const btnText = getComputedStyle(document.documentElement) + .getPropertyValue('--btn-text'); + + // The premise: the token resolved. `toRgb` throws on an unresolved value + // rather than letting the comparison below pass by comparing nothing. + expect(toRgb(btnText), 'night --btn-text resolved').to.match(/^rgb/); 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')); + .to.not.equal(toRgb(btnText)); + // While red, which was always legible, still does. + expect(toRgb(red), 'red still takes --btn-text').to.equal(toRgb(btnText)); }); }); }); diff --git a/app/src/themes/contrast.ts b/app/src/themes/contrast.ts index 344e6ea9..0516ad69 100644 --- a/app/src/themes/contrast.ts +++ b/app/src/themes/contrast.ts @@ -8,14 +8,14 @@ * its existing callers are unaffected. */ -/** A hex colour as [r, g, b], accepting both `#rrggbb` and the shorthand `#rgb`. */ +/** A hex colour as [r, g, b], accepting `#rgb`, `#rrggbb`, and `#rrggbbaa`. */ export function hexToRGBArray(color: string): number[] | undefined { let hex = (typeof color === 'string' ? color : '').trim().replace(/^#/, ''); - if (hex.length === 3) { - // #abc means #aabbcc, not #0a0b0c. + if (hex.length === 3 || hex.length === 4) { + // #abc means #aabbcc, not #0a0b0c. A fourth digit is alpha; see parseColor. hex = hex.split('').map(digit => digit + digit).join(''); } - if (!/^[0-9a-fA-F]{6}$/.test(hex)) { + if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(hex)) { return undefined; } const rgb: number[] = []; @@ -23,6 +23,46 @@ export function hexToRGBArray(color: string): number[] | undefined { return rgb; } +/** + * Any colour these measurements can actually read, as [r, g, b] — or undefined. + * + * Undefined matters as much as the value. `relativeLuminance` used to return 0 for + * everything it could not parse, which is indistinguishable from black: a token written as + * `rgb(90, 79, 168)` measured as pure black, so the "best" foreground was chosen against a + * colour the theme does not contain, and the wrong one was frozen into the bar's inline + * style. A caller that cannot tell "unreadable" from "black" cannot fall back, and the + * custom YAML themes ahead of us are exactly where a non-hex token will first appear. + * + * Alpha is deliberately dropped rather than rejected: WCAG contrast is defined on composited + * colour, and what a translucent value composites over is not knowable here. Every token in + * tokens.css is opaque, so this only arises for a hand-written theme, where measuring the + * colour itself is a better answer than refusing. + */ +export function parseColor(color: string): number[] | undefined { + const value = (typeof color === 'string' ? color : '').trim(); + if (!value) return undefined; + if (value.startsWith('#')) return hexToRGBArray(value); + + // `rgb(90 79 168 / 0.5)` and `rgba(90, 79, 168, 0.5)` are both current syntax. + const functional = value.match(/^rgba?\(([^)]*)\)$/i); + if (functional) { + const parts = functional[1].split(/[\s,/]+/).filter(Boolean).slice(0, 3); + if (parts.length !== 3) return undefined; + const channels = parts.map((part) => { + // A percentage is 0-100 of full scale; a bare number is already 0-255. + const numeric = parseFloat(part); + if (Number.isNaN(numeric)) return NaN; + return part.includes('%') ? (numeric / 100) * 255 : numeric; + }); + if (channels.some(Number.isNaN)) return undefined; + return channels.map(channel => Math.min(255, Math.max(0, channel))); + } + + // A named colour, `hsl()`, `color-mix()`, or an unresolved `var()`. Not measurable + // here, and saying so is the point. + return undefined; +} + /** * WCAG relative luminance: sRGB channels linearised first, then Rec. 709 weighted. * @@ -31,8 +71,11 @@ export function hexToRGBArray(color: string): number[] | undefined { * the wrong text colour for them. */ export function relativeLuminance(color: string | number[]): number { - const rgb = (typeof color === 'string') ? hexToRGBArray(color) : color; + const rgb = (typeof color === 'string') ? parseColor(color) : color; if (!rgb) { + // Zero is black's luminance, so this is a lie -- but the signature has no way to + // say otherwise, and every caller that needs to distinguish the two asks + // `isMeasurable` first. return 0; } const linear = rgb.map(value => { @@ -48,6 +91,9 @@ export function contrastRatio(a: string | number[], b: string | number[]): numbe return (luminances[0] + 0.05) / (luminances[1] + 0.05); } +/** Whether a colour can be measured at all, as distinct from measuring as black. */ +export const isMeasurable = (color: string): boolean => parseColor(color) !== undefined; + /** * Whichever candidate actually reads best on `background`. * diff --git a/app/src/themes/navColors.test.js b/app/src/themes/navColors.test.js index 4cad4dc5..acd69fa8 100644 --- a/app/src/themes/navColors.test.js +++ b/app/src/themes/navColors.test.js @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import {contrastRatio} from './contrast'; +import {contrastRatio, isMeasurable} from './contrast'; import {defaultNavColor, navColorNames, navColorsFrom} from './navColors'; import {themeNames} from './names'; import {semanticUIColorMap} from '../components/Vars'; @@ -150,11 +150,11 @@ describe('the navbar foreground', () => { describe('what measurement cannot fix', () => { /* - * Eleven of the forty-eight combinations still sit under 4.5:1, and no choice of + * Ten 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 + * six of night's land in the middle of it -- #b03030 is 3.27: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. @@ -242,3 +242,85 @@ describe('when the tokens cannot be read', () => { .toEqual(paletteOf('light')[`--${defaultNavColor}`]); }); }); + +describe('a token this cannot measure', () => { + /* + * Every token in tokens.css is a hex, so this is about the custom YAML themes ahead of + * us -- the first place a palette will be written by hand, and the first place something + * other than a hex will appear. + * + * The failure it guards was silent and worse than doing nothing. An unparseable colour + * measured as luminance zero, indistinguishable from black, so the bar chose its + * foreground against a colour the theme does not contain and then froze the wrong hex + * into an inline style, where CSS could no longer correct it. Falling back leaves the + * bar painted by CSS: wrong about the foreground at worst, rather than about both. + */ + const hexPalette = () => paletteOf('light'); + + /** The light palette with one token rewritten into some other notation. */ + const readerWith = (overrides) => { + const palette = {...hexPalette(), ...overrides}; + return (token) => palette[token] || ''; + }; + + it('measures rgb() as readily as hex, since both are colours', () => { + // `--violet` is #5c4fa8 in light. The same colour, written the other way, must + // produce the same answer rather than falling back. + const asHex = navColorsFrom(readerFor('light'), 'violet'); + const asRgb = navColorsFrom(readerWith({'--violet': 'rgb(92, 79, 168)'}), 'violet'); + + expect(asRgb.color).toEqual(asHex.color); + expect(asRgb.ratio).toBeCloseTo(asHex.ratio, 4); + }); + + it('handles the modern space-separated form too', () => { + const asHex = navColorsFrom(readerFor('light'), 'violet'); + const asRgb = navColorsFrom(readerWith({'--violet': 'rgb(92 79 168 / 1)'}), 'violet'); + + expect(asRgb.ratio).toBeCloseTo(asHex.ratio, 4); + }); + + it('falls back rather than measuring a background it cannot read', () => { + // `color-mix` is the shape a hand-written theme is most likely to reach for, and + // resolving it needs a browser. + const colors = navColorsFrom( + readerWith({'--olive': 'color-mix(in srgb, #6f7a24 60%, white)'}), 'olive'); + + expect(colors).toEqual({ + background: 'var(--olive)', color: 'var(--btn-text)', ratio: null, + }); + }); + + it('falls back when no candidate foreground can be read', () => { + const colors = navColorsFrom(readerWith({ + '--btn-text': 'hsl(0 0% 100%)', '--black': 'rebeccapurple', '--white': 'hsl(0 0% 0%)', + }), 'violet'); + + expect(colors.ratio).toBeNull(); + }); + + it('still measures when only SOME candidates are unreadable', () => { + /* + * The narrow case, and the reason the unreadable ones are filtered out rather than + * the whole measurement abandoned. A theme that writes one token oddly should not + * lose the measurement for the other two. + */ + const colors = navColorsFrom( + readerWith({'--white': 'oklch(0.9 0.1 20)'}), 'violet'); + + expect(colors.ratio).not.toBeNull(); + expect([hexPalette()['--btn-text'], hexPalette()['--black']]).toContain(colors.color); + }); + + it('is a real constraint: an unreadable colour is NOT treated as black', () => { + /* + * Without this the fallback could be removed and every case above would still pass + * on `relativeLuminance` returning zero -- which is precisely the bug, because zero + * is a legitimate luminance and nothing downstream could tell the two apart. + */ + expect(contrastRatio('color-mix(in srgb, red, blue)', '#ffffff')) + .toBeCloseTo(contrastRatio('#000000', '#ffffff'), 4); + expect(isMeasurable('color-mix(in srgb, red, blue)')).toBe(false); + expect(isMeasurable('#000000')).toBe(true); + }); +}); diff --git a/app/src/themes/navColors.ts b/app/src/themes/navColors.ts index 63713182..eb53a5c8 100644 --- a/app/src/themes/navColors.ts +++ b/app/src/themes/navColors.ts @@ -1,5 +1,5 @@ import React from 'react'; -import {bestForeground, contrastRatio} from './contrast'; +import {bestForeground, contrastRatio, isMeasurable} from './contrast'; /* @@ -23,8 +23,31 @@ import {bestForeground, contrastRatio} from './contrast'; * 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. + * colour also means a theme only has to supply a palette: the correct foreground for each + * entry falls out of it, with nothing extra to declare and nothing to keep in step. + * + * What is NOT measured, and should be: the warning indicators. NavBar still picks their + * colour from `conflictingColors`, a hardcoded list of navbar names that swaps `yellow`/`red` + * for `null`/`black` on the five bars those hues would blend into. It is the same defect + * this replaced -- a name list standing in for a measurement -- and it is worse than the one + * fixed here rather than better: `--danger` and `--warning` measure 1.0-1.8:1 against the bar + * in EVERY theme, light and dark included, because a red icon on a red bar is a red icon on a + * red bar. It is left alone deliberately, because the fix is not a better colour. Those + * icons have to stay distinguishable from each other AND from the bar's own text, and in a + * monochrome theme there is no third brightness to spend, so severity has to stop being a + * hue -- a filled badge, or a shape. That is a redesign of how severity reads, not a + * substitution, and it is not this change. + * + * /theme-sample draws the indicators as they are, taking the measured bar colour, which is + * what the bar does when nothing is wrong. It does not stage a fake overheating Pi to + * exhibit a fault nobody has agreed how to fix. + * + * Two limits on the measuring that IS done here, both of which the custom YAML themes will + * meet before anything else does. A token has to be a colour these measurements can read -- hex or `rgb()`; anything + * else falls back to CSS rather than being measured (see navColorsFrom). And re-measurement + * is triggered by `data-theme` changing, which is how a theme is applied today; a future + * editor that patches custom properties in place without touching the attribute would leave + * the bar on the values it last measured (see useNavColors). */ /** @@ -73,17 +96,28 @@ export function navColorsFrom(read: (name: string) => string, navColor: string): */ const candidates = [read('--btn-text'), read('--black'), read('--white')].filter(Boolean); - if (!background || candidates.length === 0) { + const measurable = candidates.filter(isMeasurable); + + if (!isMeasurable(background) || measurable.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. + * Nothing readable to measure, so measure nothing and let CSS do its job. + * + * Two cases reach here. The ordinary one is that there is no stylesheet yet -- jsdom, + * or a very early render -- and the reader returns empty strings. The other is a + * token this cannot parse: a hand-written theme writing `rgb(90 79 168)` or + * `color-mix(...)` rather than a hex. That one used to be silent and worse than + * useless: an unparseable colour measured as luminance zero, indistinguishable from + * black, so the bar picked its foreground against a colour the theme does not contain + * and then froze the wrong hex into an inline style, where CSS could not correct it. + * + * Falling back to the tokens themselves is the pre-measurement behaviour: the bar is + * painted by CSS, follows the theme, and is only ever wrong about the foreground -- + * which is a great deal better than being confidently wrong about both. */ return {background: `var(--${name})`, color: 'var(--btn-text)', ratio: null}; } - const color = bestForeground(background, candidates) as string; + const color = bestForeground(background, measurable) as string; return {background, color, ratio: contrastRatio(color, background)}; } @@ -126,6 +160,13 @@ export function navBarStyle(colors: NavColors): React.CSSProperties { * * 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. + * + * This assumes token VALUES only change when `data-theme` does, which holds for every way a + * theme is applied today. It will not hold for a custom-theme editor that rewrites custom + * properties in place: the measured pair is a snapshot of hexes, so the bar would keep the + * old one until the theme name or the chosen colour changed. When that editor exists it + * should either re-stamp the attribute or announce the change for this to observe -- there + * is no event for "a custom property was assigned" to watch instead. */ export function useNavColors(navColor: string): NavColors { const [colors, setColors] = React.useState(