diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js
index 13db0e9a..a917fdf1 100644
--- a/app/src/components/ui/ui-layout.cy.js
+++ b/app/src/components/ui/ui-layout.cy.js
@@ -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
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(
+ {navColorNames.map(color => )}
+
, {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(
+
+
+
, {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'));
+ });
+ });
+ });
+});
diff --git a/app/src/themes/contrast.ts b/app/src/themes/contrast.ts
new file mode 100644
index 00000000..344e6ea9
--- /dev/null
+++ b/app/src/themes/contrast.ts
@@ -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;
+}
diff --git a/app/src/themes/navColors.test.js b/app/src/themes/navColors.test.js
new file mode 100644
index 00000000..4cad4dc5
--- /dev/null
+++ b/app/src/themes/navColors.test.js
@@ -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-
.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. 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}`]);
+ });
+});
diff --git a/app/src/themes/navColors.ts b/app/src/themes/navColors.ts
new file mode 100644
index 00000000..63713182
--- /dev/null
+++ b/app/src/themes/navColors.ts
@@ -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(
+ () => 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;
+}