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'));
});