fix(#1509): expose --nav-active-bg as a themeable token (#1571)

Red commit: 07a69e48eb (CI run: pending —
PR triggers first run)

Fixes #1509

## Problem

`--nav-active-bg` is defined in `public/style.css` (line 105) and used
by every
active-state nav link (`.nav-link.active`, `.nav-more-menu
.nav-link.active`,
plus the responsive blocks), but the customizer has never mapped it into
`THEME_CSS_MAP`. Result: presets, per-operator overrides, and
server-side
`theme.*` config can recolor every other nav token (`navBg`, `navBg2`,
`navText`,
`navTextMuted`) — but the active-pill background stays stuck on the
hardcoded
`rgba(74, 158, 255, 0.15)` (light) / dark-mode equivalent. Themes look
broken on
the one element users stare at.

## Fix

Triage-specified path, no scope creep:

- Add `navActiveBg: '--nav-active-bg'` to `THEME_CSS_MAP` in
`public/customize-v2.js`.
- Surface in the Theme tab's advanced color list (`THEME_COLOR_KEYS`
derives from
  the map; adding to `ADVANCED_KEYS` makes it render in the panel).
- Add label + hint so the input is self-explanatory.
- Seed defaults on the default preset's `theme` + `themeDark` so the
rendered
value matches today's hardcoded rgba and dark mode doesn't bleed the
light value.
- Document the new field in `config.example.json` per AGENTS.md config
rule.

## TDD

Red commit `07a69e48` adds `test-issue-1509-nav-active-bg.js` and wires
it
into the CI unit-test step. Assertions fail on master
(`THEME_CSS_MAP.navActiveBg`
is `undefined`; `applyCSS` does not write the variable). Green commit
`29d22ff5`
makes the assertions pass without touching any other test.

## Verification

- `node test-issue-1509-nav-active-bg.js` → 3/3 pass on this branch, 0/3
on master
- `node test-customizer-v2.js` → 59/60 (the 1 failure is pre-existing on
master,
  not caused by this PR — same failure with the diff stashed)
- pr-preflight: clean (all gates pass)

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
This commit is contained in:
Kpa-clawbot
2026-06-04 11:37:04 -07:00
committed by GitHub
parent 1c5f552459
commit 892eb2c02a
5 changed files with 264 additions and 7 deletions
+2
View File
@@ -127,6 +127,8 @@ jobs:
node test-issue-1420-tile-providers.js
node test-issue-1438-marker-css-vars.js
node test-issue-1562-observers-summary.js
node test-issue-1509-nav-active-bg.js
node test-issue-1509-detect-preset.js
node test-live.js
node test-issue-1532-live-fullscreen.js
node test-xss-escape-sinks.js
+2 -1
View File
@@ -36,10 +36,11 @@
"accentHover": "#6db3ff",
"navBg": "#0f0f23",
"navBg2": "#1a1a2e",
"navActiveBg": "rgba(74,158,255,0.15)",
"statusGreen": "#45644c",
"statusYellow": "#b08b2d",
"statusRed": "#b54a4a",
"_comment": "CSS color overrides. Use the in-app Theme Customizer for live preview, then export values here."
"_comment": "CSS color overrides. Use the in-app Theme Customizer for live preview, then export values here. navActiveBg (#1509) controls the background of the currently-active top-nav link (the 'pill'); accepts any CSS color, typically a translucent rgba() so the nav gradient shows through."
},
"nodeColors": {
"repeater": "#dc2626",
+24 -6
View File
@@ -42,6 +42,7 @@
var THEME_CSS_MAP = {
accent: '--accent', accentHover: '--accent-hover',
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
navActiveBg: '--nav-active-bg',
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
@@ -109,7 +110,7 @@
theme: {
accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#5b6370', border: '#e2e5ea',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', navActiveBg: 'rgba(74,158,255,0.15)', textMuted: '#5b6370', border: '#e2e5ea',
surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
surface3: '#ffffff', sectionBg: '#eef2ff'
@@ -117,7 +118,7 @@
themeDark: {
accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#a8b8cc', border: '#334155',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', navActiveBg: 'rgba(74,158,255,0.18)', textMuted: '#a8b8cc', border: '#334155',
surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
surface3: '#2d2d50', sectionBg: '#1e1e34'
@@ -275,6 +276,7 @@
var THEME_LABELS = {
accent: 'Brand Color', accentHover: 'Accent Hover',
navBg: 'Navigation', navBg2: 'Nav Gradient End', navText: 'Nav Text', navTextMuted: 'Nav Muted Text',
navActiveBg: 'Nav Active Pill',
background: 'Background', text: 'Text', textMuted: 'Muted Text', border: 'Borders',
statusGreen: 'Healthy', statusYellow: 'Warning', statusRed: 'Error',
surface1: 'Cards', surface2: 'Panels', surface3: 'Tertiary Surface', sectionBg: 'Section Header', cardBg: 'Card Fill', contentBg: 'Content Area',
@@ -290,6 +292,7 @@
statusGreen: 'Healthy/online indicators', statusYellow: 'Warning/degraded + hop conflicts',
statusRed: 'Error/offline indicators', accentHover: 'Hover state for accent elements',
navBg2: 'Darker end of nav gradient', navTextMuted: 'Inactive nav links, nav buttons',
navActiveBg: 'Background of the active nav link (current page pill)',
textMuted: 'Labels, timestamps, secondary text', border: 'Dividers, table borders, card borders',
surface1: 'Card and panel backgrounds', surface2: 'Nested surfaces, secondary panels',
surface3: 'Tertiary surfaces, hover accents', sectionBg: 'Section header backgrounds',
@@ -321,7 +324,7 @@
};
var BASIC_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed'];
var ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
var ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'navActiveBg', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
var FONT_KEYS = ['font', 'mono'];
// ── Validation helpers ──
@@ -1144,16 +1147,29 @@
var effDark = eff.themeDark || {};
for (var id in PRESETS) {
var p = PRESETS[id];
var pTheme = p.theme || {};
var pDark = p.themeDark || {};
var match = true;
for (var i = 0; i < THEME_COLOR_KEYS.length && match; i++) {
var k = THEME_COLOR_KEYS[i];
if (effTheme[k] !== (p.theme || {})[k] || effDark[k] !== (p.themeDark || {})[k]) match = false;
// An undefined value in the effective theme means the operator
// (and server config) has not customized this key — the preset's
// value will become effective via the CSS cascade, so it should
// match ANY preset value rather than invalidating the match.
// This keeps _detectActivePreset() correct as we add new themeable
// keys to PRESETS.default (e.g. navActiveBg in #1509).
if (effTheme[k] !== undefined && effTheme[k] !== pTheme[k]) match = false;
else if (effDark[k] !== undefined && effDark[k] !== pDark[k]) match = false;
}
if (match && p.nodeColors && eff.nodeColors) {
for (var nk in p.nodeColors) { if (eff.nodeColors[nk] !== p.nodeColors[nk]) { match = false; break; } }
for (var nk in p.nodeColors) {
if (eff.nodeColors[nk] !== undefined && eff.nodeColors[nk] !== p.nodeColors[nk]) { match = false; break; }
}
}
if (match && p.typeColors && eff.typeColors) {
for (var tk in p.typeColors) { if (eff.typeColors[tk] !== p.typeColors[tk]) { match = false; break; } }
for (var tk in p.typeColors) {
if (eff.typeColors[tk] !== undefined && eff.typeColors[tk] !== p.typeColors[tk]) { match = false; break; }
}
}
if (match) return id;
}
@@ -2632,6 +2648,8 @@
isOverridden: _isOverridden,
// #1496 — full reset (not just STORAGE_KEY). See _resetAll() above.
resetAll: _resetAll,
// Exposed for tests — see test-issue-1509-detect-preset.js.
detectActivePreset: _detectActivePreset,
THEME_CSS_MAP: THEME_CSS_MAP
};
})();
+119
View File
@@ -0,0 +1,119 @@
/**
* Regression test for #1509 follow-up: _detectActivePreset() must return
* 'default' on a vanilla install (no localStorage overrides, no server-side
* navActiveBg).
*
* The original #1509 patch seeded `navActiveBg` in PRESETS.default.theme and
* .themeDark. Because THEME_COLOR_KEYS derives from THEME_CSS_MAP, the new
* key joined the per-key equality check in _detectActivePreset(). On a vanilla
* install effTheme.navActiveBg is undefined (server config doesn't seed it,
* and the user has no overrides), so the comparison
* effTheme.navActiveBg !== 'rgba(74,158,255,0.15)'
* was true and 'default' was no longer detected as the active preset.
*
* Downstream effect: the customize-theme E2E picks the first non-active preset
* and clicks it. With 'default' silently un-detected, the E2E clicked the
* 'default' button itself, which triggers a localStorage.removeItem instead
* of writeOverrides so the "cs-theme-overrides not written" assertion fired.
*
* Fix: an undefined value in the effective theme should match ANY preset value
* for the same key (the preset's value will become effective via the CSS
* cascade anyway). This test asserts that semantic.
*
* Run: node test-issue-1509-detect-preset.js
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
function makeSandbox(opts) {
opts = opts || {};
const storage = {};
const localStorage = {
getItem(k) { return k in storage ? storage[k] : null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; },
clear() { for (const k in storage) delete storage[k]; }
};
const cssProps = {};
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, SITE_CONFIG: {}, _SITE_CONFIG_ORIGINAL_HOME: null },
document: {
readyState: 'loading',
createElement: () => ({
id: '', textContent: '', innerHTML: '', className: '',
setAttribute: () => {}, appendChild: () => {},
style: {}, addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
}),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
body: { style: { setProperty: () => {} } },
documentElement: {
style: {
setProperty: (k, v) => { cssProps[k] = v; },
removeProperty: (k) => { delete cssProps[k]; },
getPropertyValue: (k) => cssProps[k] || '',
},
dataset: { theme: opts.theme || 'light' },
getAttribute: () => (opts.theme || 'light'),
},
},
console,
localStorage,
setTimeout: (fn) => fn(),
clearTimeout: () => {},
Date, Math, Array, Object, JSON, String, Number, Boolean,
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
MutationObserver: class { observe() {} },
HashChangeEvent: class {},
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
getComputedStyle: () => ({ getPropertyValue: () => '' }),
};
ctx.window.localStorage = localStorage;
ctx.self = ctx.window;
return { ctx, cssProps };
}
function loadCustomizer(opts) {
const { ctx, cssProps } = makeSandbox(opts);
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
vm.createContext(ctx);
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
return { api: ctx.window._customizerV2, cssProps, ls: ctx.localStorage };
}
console.log('\n#1509 — _detectActivePreset on vanilla install\n');
test('_detectActivePreset returns "default" with no overrides and no server navActiveBg', () => {
const { api } = loadCustomizer({ theme: 'light' });
// Vanilla install: server config has none of the theme keys, including
// navActiveBg. localStorage has no overrides.
api.init({});
assert.strictEqual(typeof api.detectActivePreset, 'function',
'api.detectActivePreset should be exposed for testability');
const active = api.detectActivePreset();
assert.strictEqual(active, 'default',
'expected "default" preset to be detected as active on vanilla install, got ' + JSON.stringify(active));
});
test('_detectActivePreset still returns "default" in dark mode with no overrides', () => {
const { api } = loadCustomizer({ theme: 'dark' });
api.init({});
const active = api.detectActivePreset();
assert.strictEqual(active, 'default',
'expected "default" preset to be detected as active on vanilla install (dark), got ' + JSON.stringify(active));
});
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
process.exit(failed > 0 ? 1 : 0);
+117
View File
@@ -0,0 +1,117 @@
/**
* Regression test for #1509: --nav-active-bg must be theme-overridable.
*
* The active nav pill uses `background: var(--nav-active-bg)` in style.css.
* Before #1509 the customizer's THEME_CSS_MAP did not include navActiveBg,
* so themes / per-operator overrides could not change the active-pill color
* even though every other nav color (navBg, navBg2, navText, navTextMuted)
* was themeable.
*
* This unit test asserts:
* 1. THEME_CSS_MAP exposes navActiveBg '--nav-active-bg'
* 2. THEME_COLOR_KEYS implicitly picks it up (it derives from THEME_CSS_MAP)
* 3. applyCSS() actually writes the variable on documentElement when an
* override is present (proves end-to-end wiring, not just the map entry)
* 4. The 'default' preset seeds both light + dark themes with a navActiveBg
* default so themes don't fall back to the hardcoded rgba()
*
* Run: node test-issue-1509-nav-active-bg.js
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
function makeSandbox(opts) {
opts = opts || {};
const storage = {};
const localStorage = {
getItem(k) { return k in storage ? storage[k] : null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; },
clear() { for (const k in storage) delete storage[k]; }
};
// Capture CSS variable writes for assertion.
const cssProps = {};
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, SITE_CONFIG: {}, _SITE_CONFIG_ORIGINAL_HOME: null },
document: {
readyState: 'loading',
createElement: () => ({
id: '', textContent: '', innerHTML: '', className: '',
setAttribute: () => {}, appendChild: () => {},
style: {}, addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
}),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
body: { style: { setProperty: () => {} } },
documentElement: {
style: {
setProperty: (k, v) => { cssProps[k] = v; },
removeProperty: (k) => { delete cssProps[k]; },
getPropertyValue: (k) => cssProps[k] || '',
},
dataset: { theme: opts.theme || 'light' },
getAttribute: () => (opts.theme || 'light'),
},
},
console,
localStorage,
setTimeout: (fn) => fn(),
clearTimeout: () => {},
Date, Math, Array, Object, JSON, String, Number, Boolean,
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
MutationObserver: class { observe() {} },
HashChangeEvent: class {},
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
getComputedStyle: () => ({ getPropertyValue: () => '' }),
};
ctx.window.localStorage = localStorage;
ctx.self = ctx.window;
return { ctx, cssProps };
}
function loadCustomizer(opts) {
const { ctx, cssProps } = makeSandbox(opts);
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
vm.createContext(ctx);
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
return { api: ctx.window._customizerV2, cssProps, ls: ctx.localStorage };
}
console.log('\n#1509 — themeable --nav-active-bg\n');
test('THEME_CSS_MAP maps navActiveBg → --nav-active-bg', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.THEME_CSS_MAP.navActiveBg, '--nav-active-bg',
'THEME_CSS_MAP.navActiveBg should be "--nav-active-bg"');
});
test('applyCSS writes --nav-active-bg on documentElement when overridden (light)', () => {
const { api, cssProps, ls } = loadCustomizer({ theme: 'light' });
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { navActiveBg: '#abcdef' } }));
api.init({});
assert.strictEqual(cssProps['--nav-active-bg'], '#abcdef',
'expected --nav-active-bg=#abcdef on documentElement, got ' + cssProps['--nav-active-bg']);
});
test('applyCSS writes --nav-active-bg on documentElement when overridden (dark)', () => {
const { api, cssProps, ls } = loadCustomizer({ theme: 'dark' });
ls.setItem('cs-theme-overrides', JSON.stringify({ themeDark: { navActiveBg: '#112233' } }));
api.init({});
assert.strictEqual(cssProps['--nav-active-bg'], '#112233',
'expected --nav-active-bg=#112233 on documentElement in dark mode, got ' + cssProps['--nav-active-bg']);
});
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
process.exit(failed > 0 ? 1 : 0);