diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6b35903f..66da6df1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/config.example.json b/config.example.json index 570f5566..915ddaef 100644 --- a/config.example.json +++ b/config.example.json @@ -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", diff --git a/public/customize-v2.js b/public/customize-v2.js index 7eb83107..1bf4655e 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -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 }; })(); diff --git a/test-issue-1509-detect-preset.js b/test-issue-1509-detect-preset.js new file mode 100644 index 00000000..ded46616 --- /dev/null +++ b/test-issue-1509-detect-preset.js @@ -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); diff --git a/test-issue-1509-nav-active-bg.js b/test-issue-1509-nav-active-bg.js new file mode 100644 index 00000000..35dbef23 --- /dev/null +++ b/test-issue-1509-nav-active-bg.js @@ -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);