diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 074a54fc..4c71d29b 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -284,6 +284,26 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) { "accentHover": "#6db3ff", "navBg": "#0f0f23", "navBg2": "#1a1a2e", + "navText": "#ffffff", + "navTextMuted": "#cbd5e1", + "background": "#f4f5f7", + "text": "#1a1a2e", + "textMuted": "#5b6370", + "border": "#e2e5ea", + "surface1": "#ffffff", + "surface2": "#ffffff", + "surface3": "#ffffff", + "sectionBg": "#eef2ff", + "cardBg": "#ffffff", + "contentBg": "#f4f5f7", + "detailBg": "#ffffff", + "inputBg": "#ffffff", + "rowStripe": "#f9fafb", + "rowHover": "#eef2ff", + "selectedBg": "#dbeafe", + "statusGreen": "#22c55e", + "statusYellow": "#eab308", + "statusRed": "#ef4444", }, s.cfg.Theme, theme.Theme) nodeColors := mergeMap(map[string]interface{}{ @@ -294,8 +314,44 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) { "observer": "#8b5cf6", }, s.cfg.NodeColors, theme.NodeColors) - themeDark := mergeMap(map[string]interface{}{}, s.cfg.ThemeDark, theme.ThemeDark) - typeColors := mergeMap(map[string]interface{}{}, s.cfg.TypeColors, theme.TypeColors) + themeDark := mergeMap(map[string]interface{}{ + "accent": "#4a9eff", + "accentHover": "#6db3ff", + "navBg": "#0f0f23", + "navBg2": "#1a1a2e", + "navText": "#ffffff", + "navTextMuted": "#cbd5e1", + "background": "#0f0f23", + "text": "#e2e8f0", + "textMuted": "#a8b8cc", + "border": "#334155", + "surface1": "#1a1a2e", + "surface2": "#232340", + "cardBg": "#1a1a2e", + "contentBg": "#0f0f23", + "detailBg": "#232340", + "inputBg": "#1e1e34", + "rowStripe": "#1e1e34", + "rowHover": "#2d2d50", + "selectedBg": "#1e3a5f", + "statusGreen": "#22c55e", + "statusYellow": "#eab308", + "statusRed": "#ef4444", + "surface3": "#2d2d50", + "sectionBg": "#1e1e34", + }, s.cfg.ThemeDark, theme.ThemeDark) + typeColors := mergeMap(map[string]interface{}{ + "ADVERT": "#22c55e", + "GRP_TXT": "#3b82f6", + "TXT_MSG": "#f59e0b", + "ACK": "#6b7280", + "REQUEST": "#a855f7", + "RESPONSE": "#06b6d4", + "TRACE": "#ec4899", + "PATH": "#14b8a6", + "ANON_REQ": "#f43f5e", + "UNKNOWN": "#6b7280", + }, s.cfg.TypeColors, theme.TypeColors) var home interface{} if theme.Home != nil { diff --git a/public/customize-v2.js b/public/customize-v2.js index 577f6beb..42fe4819 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -290,6 +290,7 @@ /** @type {object|null} server defaults, set during init */ var _serverDefaults = null; + var _initDone = false; var _saveStatus = 'saved'; // 'saved' | 'saving' | 'error' var _writeTimer = null; @@ -578,7 +579,29 @@ delta[sec] = _pendingOverrides[sec]; } } + var pendingKeys = _pendingOverrides; _pendingOverrides = {}; + // Spec Decision #7: don't silently prune existing overrides. + // Only prevent redundant NEW writes: if a value just written matches + // the server default, don't store it (clearOverride semantics). + var server = _serverDefaults || {}; + for (var ps in pendingKeys) { + if (typeof pendingKeys[ps] === 'object' && pendingKeys[ps] !== null && OBJECT_SECTIONS.indexOf(ps) >= 0) { + var serverSec = server[ps] || {}; + if (delta[ps]) { + for (var pk in pendingKeys[ps]) { + var ov = delta[ps][pk]; + var sv = serverSec[pk]; + var match = (typeof ov === 'object' || typeof sv === 'object') + ? JSON.stringify(ov) === JSON.stringify(sv) : ov === sv; + if (match) delete delta[ps][pk]; + } + if (Object.keys(delta[ps]).length === 0) delete delta[ps]; + } + } else if (SCALAR_SECTIONS.indexOf(ps) >= 0 && delta[ps] === server[ps]) { + delete delta[ps]; + } + } writeOverrides(delta); _runPipeline(); _refreshPanel(); @@ -742,17 +765,33 @@ if (section) { if (!overrides[section] || !overrides[section].hasOwnProperty(key)) return false; var serverSection = server[section] || {}; - return overrides[section][key] !== serverSection[key]; + var ov = overrides[section][key]; + var sv = serverSection[key]; + // Deep compare for arrays/objects + if (typeof ov === 'object' || typeof sv === 'object') { + return JSON.stringify(ov) !== JSON.stringify(sv); + } + return ov !== sv; } if (!overrides.hasOwnProperty(key)) return false; - return overrides[key] !== server[key]; + var ov2 = overrides[key]; + var sv2 = server[key]; + if (typeof ov2 === 'object' || typeof sv2 === 'object') { + return JSON.stringify(ov2) !== JSON.stringify(sv2); + } + return ov2 !== sv2; } - /** Count overridden fields in a section */ + /** Count overridden fields in a section (only those that differ from server defaults) */ function _countOverrides(section) { var overrides = _getOverrides(); if (!overrides[section] || typeof overrides[section] !== 'object') return 0; - return Object.keys(overrides[section]).length; + var count = 0; + var keys = Object.keys(overrides[section]); + for (var i = 0; i < keys.length; i++) { + if (_isOverridden(section, keys[i])) count++; + } + return count; } function _overrideDot(section, key) { @@ -962,11 +1001,12 @@ '' + val + ''; } + var fallbackTC = (typeof window !== 'undefined' && window.TYPE_COLORS) || {}; var tc = eff.typeColors || {}; var stc = server.typeColors || {}; var typeRows = ''; for (var tkey in TYPE_LABELS) { - var tval = tc[tkey] || '#000000'; + var tval = tc[tkey] || fallbackTC[tkey] || '#000000'; typeRows += '
' + '
' + '
' + (TYPE_HINTS[tkey] || '') + '
' + @@ -1113,6 +1153,59 @@ _bindEvents(container); } + /** Remove phantom overrides that match server defaults on startup */ + function _cleanPhantomOverrides() { + var delta = readOverrides(); + if (!delta || Object.keys(delta).length === 0) return; + var server = _serverDefaults || {}; + var changed = false; + + // Clean object sections + for (var i = 0; i < OBJECT_SECTIONS.length; i++) { + var sec = OBJECT_SECTIONS[i]; + if (!delta[sec] || typeof delta[sec] !== 'object') continue; + var serverSec = server[sec]; + // If server has no defaults for this section, only remove values that + // are clearly phantom (empty arrays/objects or undefined equivalents). + // Non-trivial values may be legitimate user choices. + if (!serverSec) { + var dKeys = Object.keys(delta[sec]); + for (var di = 0; di < dKeys.length; di++) { + var dv = delta[sec][dKeys[di]]; + var isPhantom = (Array.isArray(dv) && dv.length === 0) || + (typeof dv === 'object' && dv !== null && !Array.isArray(dv) && Object.keys(dv).length === 0); + if (isPhantom) { delete delta[sec][dKeys[di]]; changed = true; } + } + if (Object.keys(delta[sec]).length === 0) { delete delta[sec]; changed = true; } + continue; + } + var keys = Object.keys(delta[sec]); + for (var j = 0; j < keys.length; j++) { + var k = keys[j]; + var ov = delta[sec][k]; + var sv = serverSec[k]; + var match = false; + if (typeof ov === 'object' || typeof sv === 'object') { + match = JSON.stringify(ov) === JSON.stringify(sv); + } else { + match = ov === sv; + } + if (match) { delete delta[sec][k]; changed = true; } + } + if (Object.keys(delta[sec]).length === 0) { delete delta[sec]; changed = true; } + } + + // Clean scalar sections + for (var si = 0; si < SCALAR_SECTIONS.length; si++) { + var sk = SCALAR_SECTIONS[si]; + if (delta.hasOwnProperty(sk) && delta[sk] === server[sk]) { + delete delta[sk]; changed = true; + } + } + + if (changed) writeOverrides(delta); + } + function _refreshPanel() { if (!_panelEl) return; var inner = _panelEl.querySelector('.cust-inner'); @@ -1474,6 +1567,7 @@ // Watch dark/light mode toggle and re-apply new MutationObserver(function () { _runPipeline(); + if (_panelEl && !_panelEl.classList.contains('hidden')) _refreshPanel(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); }); @@ -1486,8 +1580,12 @@ window._customizerV2 = { init: function (serverConfig) { _serverDefaults = serverConfig || {}; + _cleanPhantomOverrides(); _runPipeline(); + _initDone = true; }, + /** True after init() has been called with server config and pipeline has run */ + get initDone() { return _initDone; }, readOverrides: readOverrides, writeOverrides: writeOverrides, computeEffective: computeEffective, diff --git a/test-customizer-v2.js b/test-customizer-v2.js index 2874e96a..645a8f2b 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -403,6 +403,115 @@ test('returns true when server has no default for overridden key', () => { assert.strictEqual(api.isOverridden('theme', 'accent'), true); }); +// ── Bug #518 Fixes ── + +test('phantom overrides cleaned on init — matching scalars removed', () => { + const { api, ls } = loadCustomizer(); + const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' }, typeColors: { ADVERT: '#22c55e' } }; + ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#4a9eff' }, typeColors: { ADVERT: '#22c55e' } })); + api.init(server); + const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}'); + assert.ok(!delta.theme, 'phantom theme override should be cleaned'); + assert.ok(!delta.typeColors, 'phantom typeColors override should be cleaned'); +}); + +test('phantom overrides cleaned on init — matching arrays removed', () => { + const { api, ls } = loadCustomizer(); + const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } }; + ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } })); + api.init(server); + const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}'); + assert.ok(!delta.home, 'phantom home array override should be cleaned'); +}); + +test('real overrides preserved after init cleanup', () => { + const { api, ls } = loadCustomizer(); + const server = { theme: { accent: '#4a9eff' } }; + ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } })); + api.init(server); + const delta = JSON.parse(ls.getItem('cs-theme-overrides')); + assert.strictEqual(delta.theme.accent, '#ff0000'); +}); + +test('isOverridden handles array comparison via JSON.stringify', () => { + const { api, ls } = loadCustomizer(); + const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } }; + ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } })); + api.init(server); + assert.strictEqual(api.isOverridden('home', 'steps'), false, 'matching array should not be overridden'); +}); + +test('isOverridden returns true for differing arrays', () => { + const { api, ls } = loadCustomizer(); + const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } }; + ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '🚀', title: 'New', description: 'Changed' }] } })); + api.init(server); + assert.strictEqual(api.isOverridden('home', 'steps'), true, 'differing array should be overridden'); +}); + +test('setOverride prunes value matching server default', () => { + const { api, ls } = loadCustomizer(); + const server = { theme: { accent: '#4a9eff' } }; + api.init(server); + api.setOverride('theme', 'accent', '#4a9eff'); + // debounce fires synchronously in sandbox + const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}'); + assert.ok(!delta.theme || !delta.theme.accent, 'matching value should be pruned after setOverride'); +}); + +// ── Fix #2: _cleanPhantomOverrides when server has no section ── + +test('phantom overrides cleaned when server has NO home section', () => { + const { api, ls } = loadCustomizer(); + // Server has theme but NO home — the common deployment case + const server = { theme: { accent: '#4a9eff' } }; + ls.setItem('cs-theme-overrides', JSON.stringify({ home: { checklist: [], steps: [] } })); + api.init(server); + const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}'); + assert.ok(!delta.home, 'phantom home override should be removed when server has no home section'); +}); + +test('phantom overrides cleaned when server section is undefined — empty arrays removed', () => { + const { api, ls } = loadCustomizer(); + const server = { theme: { accent: '#4a9eff' }, nodeColors: { repeater: '#dc2626' } }; + // timestamps has actual values (not phantom), home has empty arrays (phantom) + ls.setItem('cs-theme-overrides', JSON.stringify({ + timestamps: { defaultMode: 'ago', timezone: 'local' }, + home: { checklist: [], steps: [] } + })); + api.init(server); + const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}'); + assert.ok(!delta.home, 'phantom home with empty arrays should be removed'); + // timestamps has non-empty values — preserved even without server section + assert.ok(delta.timestamps, 'timestamps with actual values should be preserved'); + assert.strictEqual(delta.timestamps.defaultMode, 'ago'); +}); + +// ── Fix #4: setOverride with value matching server default is NOT stored ── + +test('setOverride with value matching server default is not stored', () => { + const { api, ls } = loadCustomizer(); + const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } }; + api.init(server); + // Set override to same value as server default + api.setOverride('theme', 'accent', '#4a9eff'); + const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}'); + assert.ok(!delta.theme || !delta.theme.accent, 'value matching server default should not be stored'); +}); + +test('existing user overrides are NOT pruned by setOverride on other keys', () => { + const { api, ls } = loadCustomizer(); + const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } }; + // User previously chose a custom accent (different from server default) + ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } })); + api.init(server); + // Now user changes border — accent should be preserved + api.setOverride('theme', 'border', '#00ff00'); + const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}'); + assert.strictEqual(delta.theme.accent, '#ff0000', 'pre-existing custom override should be preserved'); + assert.strictEqual(delta.theme.border, '#00ff00', 'new non-matching override should be stored'); +}); + // ── Summary ── console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 298b1c63..2f87333c 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -1067,20 +1067,29 @@ async function run() { await test('Customizer v2: setOverride persists and applies CSS', async () => { await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]'); + // Force light mode — CI headless browsers may default to dark mode, + // and in dark mode themeDark.accent overwrites theme.accent in applyCSS + await page.evaluate(() => { + localStorage.setItem('meshcore-theme', 'light'); + document.documentElement.setAttribute('data-theme', 'light'); + }); // Clear any existing overrides await page.evaluate(() => localStorage.removeItem('cs-theme-overrides')); + // Wait for init() to complete (server config fetch + full pipeline) before + // setting override, so _runPipeline from init doesn't overwrite our value. + await page.waitForFunction(() => { + return window._customizerV2 && window._customizerV2.initDone; + }, { timeout: 5000 }); // Set an override via the API const result = await page.evaluate(() => { - if (!window._customizerV2) return { error: 'customizerV2 not loaded' }; window._customizerV2.setOverride('theme', 'accent', '#ff0000'); - // Wait for debounce + // Wait for debounce (300ms) + buffer return new Promise(resolve => setTimeout(() => { const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}'); const cssVal = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); resolve({ stored, cssVal }); }, 500)); }); - assert(!result.error, result.error || ''); assert(result.stored.theme && result.stored.theme.accent === '#ff0000', 'Override not persisted to localStorage'); assert(result.cssVal === '#ff0000', @@ -1092,9 +1101,17 @@ async function run() { await test('Customizer v2: clearOverride resets to server default', async () => { await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]'); + // Force light mode for consistent CSS testing + await page.evaluate(() => { + localStorage.setItem('meshcore-theme', 'light'); + document.documentElement.setAttribute('data-theme', 'light'); + }); + // Wait for init() to complete so _serverDefaults is populated + await page.waitForFunction(() => { + return window._customizerV2 && window._customizerV2.initDone; + }, { timeout: 5000 }); const result = await page.evaluate(() => { - if (!window._customizerV2) return { error: 'customizerV2 not loaded' }; - // Get the server default accent + // Set the server default accent window._customizerV2.setOverride('theme', 'accent', '#ff0000'); return new Promise(resolve => setTimeout(() => { window._customizerV2.clearOverride('theme', 'accent'); @@ -1103,7 +1120,6 @@ async function run() { resolve({ hasAccent }); }, 500)); }); - assert(!result.error, result.error || ''); assert(!result.hasAccent, 'accent should be removed from overrides after clearOverride'); await page.evaluate(() => localStorage.removeItem('cs-theme-overrides')); });