fix(#1496): Reset All clears every customizer-touched state (#1497)

## Summary

`🗑️ Reset All Customizations` only stripped `cs-theme-overrides`,
leaving CB-preset, encrypted-channel toggle, dark-tile pick,
marker-stroke vars and the per-role `--mc-role-*` body.style writes from
PRs #1361/#1430/#1448/#1454/#1488 stuck. Operators had to clear
localStorage by hand to actually reset.

Single source of truth lands as `_resetAll()` in
`public/customize-v2.js` (exposed on `_customizerV2.resetAll` for
tests). The Reset button delegates to it. Future customizer features
extend ONE function — not 12 scattered call-sites.

## What is cleared

| surface | keys / props |
|---|---|
| localStorage | `cs-theme-overrides`, `meshcore-cb-preset`,
`channels-show-encrypted`, `mc-dark-tile-provider` |
| body attr | `data-cb-preset` |
| body.style | `--mc-role-{role}`, `--mc-role-{role}-text` for
repeater/companion/room/sensor/observer |
| :root style | `--mc-role-*`, `--mc-role-*-text`, `--node-*`,
`--mc-marker-stroke-{color,width,opacity}`,
`--mc-mb-{confirmed,suspected,unknown}`, `--mc-rt-ramp-{0..4}`,
`--logo-accent`, `--logo-accent-hi`, every value in `THEME_CSS_MAP` |

CB-preset teardown delegates to `MeshCorePresets.clearPreset()` so
`cb-preset-changed` fires and downstream consumers re-sync to server
config without a reload. Tile-provider teardown re-applies the active id
(which now falls through to server default / `carto-dark`) so
`mc-tile-provider-changed` fires and the live map swaps tiles, then
re-clears the just-rewritten localStorage entry.

## What is explicitly preserved (per issue body)

- `meshcore-theme` — separate user preference, not a customization
- `meshcore-gesture-hints-*` — has its own dedicated Reset button
- `meshcore-favorites` — operator's favorites list, not a customizer
pick
- `mc-channels-*` — channel selection state, not a customization

## TDD

- Red commit (`7a986fce`): adds `test-issue-1496-reset-all-complete.js`
+ a stub `resetAll: function () {}` so the test fails on assertions (9
of 14), not on a missing symbol. The 5 "must NOT clear" assertions pass
trivially against the stub.
- Green commit (`45c88154`): wires `_resetAll()`; all 14 pass.

```
14 passed, 0 failed
```

Existing customizer tests (`test-customize-display-e2e.js` shape only;
`test-issue-1361-cb-presets.js` 82/82;
`test-issue-1412-customizer-no-override.js` 13/13) unaffected. Two
pre-existing failures in `test-customizer-v2.js` and
`test-issue-1438-customizer-mcrole.js` reproduce on `origin/master`
without this change.

Closes #1496

---------

Co-authored-by: mc-bot <bot@meshcore.local>
This commit is contained in:
Kpa-clawbot
2026-05-29 10:01:24 -07:00
committed by GitHub
co-authored by mc-bot
parent 2196102eae
commit 9bed0e85ed
2 changed files with 413 additions and 2 deletions
+124 -2
View File
@@ -2294,11 +2294,13 @@
});
}
// Reset All
// Reset All — #1496: clear every customizer-touched piece of state,
// not just STORAGE_KEY. Implementation lives in resetAll() on the
// public API so tests can drive it without a DOM button.
var resetBtn = document.getElementById('cv2ResetAll');
if (resetBtn) resetBtn.addEventListener('click', function () {
if (!confirm('Reset all customizations to server defaults?')) return;
localStorage.removeItem(STORAGE_KEY);
_resetAll();
_runPipeline();
_renderPanel(container);
});
@@ -2449,6 +2451,124 @@
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
});
// ── Reset All (#1496) ──
//
// The original "Reset All" only cleared STORAGE_KEY (cs-theme-overrides),
// leaving CB-preset, encrypted-channel toggle, dark-tile pick, marker
// stroke vars and per-role body.style writes stuck — each subsequent
// customizer feature (PRs #1430, #1454, #1488, #1448) added its own
// out-of-band side effect and forgot to teach Reset about it.
//
// Single source of truth: this function. Adding a new customizer
// feature requires adding its teardown here. Each removal is wrapped
// in try/catch because we run before/after page lifecycle in tests
// and operators' browsers where any one surface (localStorage,
// document, body) could be missing.
function _resetAll() {
// 1. localStorage — customizer-owned keys ONLY. Theme / favorites /
// gesture hints / channel selection state are explicitly preserved
// (see issue #1496 "Out of scope").
var CUSTOMIZER_LS_KEYS = [
STORAGE_KEY, // 'cs-theme-overrides'
'meshcore-cb-preset', // #1361 CB preset id
'channels-show-encrypted', // #1454 encrypted-channel toggle
'mc-dark-tile-provider' // #1430 dark-tile provider pick
];
for (var i = 0; i < CUSTOMIZER_LS_KEYS.length; i++) {
try { localStorage.removeItem(CUSTOMIZER_LS_KEYS[i]); } catch (_e) { /* ignore */ }
}
// 2. CB preset — body[data-cb-preset] + CSS var teardown lives in
// MeshCorePresets.clearPreset(). Prefer the canonical helper so
// the dispatch (cb-preset-changed event) fires and downstream
// consumers re-sync to server config without a reload.
try {
var MCP = (typeof window !== 'undefined') && window.MeshCorePresets;
if (MCP && typeof MCP.clearPreset === 'function') {
MCP.clearPreset();
} else if (typeof document !== 'undefined' && document.body) {
// Defensive fallback (e.g. cb-presets.js failed to load).
document.body.removeAttribute('data-cb-preset');
}
} catch (_e) { /* ignore */ }
// 3. body.style — #1446/#1448 wrote --mc-role-{role} / -text with
// !important so they trump :root. Strip per role + text role.
try {
if (typeof document !== 'undefined' && document.body && document.body.style) {
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
document.body.style.removeProperty('--mc-role-' + role);
document.body.style.removeProperty('--mc-role-' + role + '-text');
});
}
} catch (_e) { /* ignore */ }
// 4. :root style — everything the customizer pipeline + cb-presets
// + marker-stroke + tile providers ever wrote onto documentElement.
// A fresh _runPipeline() will re-set whatever server config /
// remaining (theme-only) preferences dictate.
try {
if (typeof document !== 'undefined' && document.documentElement && document.documentElement.style) {
var rs = document.documentElement.style;
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
rs.removeProperty('--mc-role-' + role);
rs.removeProperty('--mc-role-' + role + '-text');
rs.removeProperty('--node-' + role);
});
// #1488 marker stroke vars.
rs.removeProperty('--mc-marker-stroke-color');
rs.removeProperty('--mc-marker-stroke-width');
rs.removeProperty('--mc-marker-stroke-opacity');
// CB-preset MB / RT-ramp vars (also cleared by clearPreset above
// but stripped here defensively in case clearPreset is absent).
['confirmed', 'suspected', 'unknown'].forEach(function (k) {
rs.removeProperty('--mc-mb-' + k);
});
for (var ri = 0; ri < 5; ri++) rs.removeProperty('--mc-rt-ramp-' + ri);
// Theme vars the customizer might have stamped onto :root via
// applyCSS (logo accents + every entry in THEME_CSS_MAP).
rs.removeProperty('--logo-accent');
rs.removeProperty('--logo-accent-hi');
for (var tkey in THEME_CSS_MAP) {
if (Object.prototype.hasOwnProperty.call(THEME_CSS_MAP, tkey)) {
rs.removeProperty(THEME_CSS_MAP[tkey]);
}
}
}
} catch (_e) { /* ignore */ }
// 5. Tile provider — clearing the localStorage key (step 1) takes
// care of persistence, but a live consumer needs the
// mc-tile-provider-changed event to swap back to the server
// default. Re-applying the effective active id achieves that
// without forcing a particular value (it falls through to the
// server default / DEFAULT_ID inside getActiveId()).
try {
if (typeof window !== 'undefined' &&
typeof window.MC_setDarkTileProvider === 'function' &&
typeof window.MC_getDarkTileProvider === 'function') {
window.MC_setDarkTileProvider(window.MC_getDarkTileProvider());
// Re-clear so we don't re-persist the just-re-applied value.
try { localStorage.removeItem('mc-dark-tile-provider'); } catch (_e) {}
}
} catch (_e) { /* ignore */ }
// 6. Encrypted-channel toggle — removing the LS key (step 1) fixes
// persistence, but channels.js subscribes to
// mc-channels-show-encrypted-changed for live re-render. Fire it
// explicitly with on:false so the channels list reverts to "hide
// encrypted" without a page reload.
try {
if (typeof window !== 'undefined' &&
typeof window.dispatchEvent === 'function' &&
typeof window.CustomEvent === 'function') {
window.dispatchEvent(new window.CustomEvent('mc-channels-show-encrypted-changed', {
detail: { on: false }
}));
}
} catch (_e) { /* ignore */ }
}
// ── Public API for app.js integration ──
/**
@@ -2474,6 +2594,8 @@
applyCSS: applyCSS,
isValidColor: isValidColor,
isOverridden: _isOverridden,
// #1496 — full reset (not just STORAGE_KEY). See _resetAll() above.
resetAll: _resetAll,
THEME_CSS_MAP: THEME_CSS_MAP
};
})();
+289
View File
@@ -0,0 +1,289 @@
/**
* #1496 "Reset All Customizations" must clear every customizer-touched
* piece of state, not just `cs-theme-overrides`.
*
* Coverage:
* - localStorage keys: cs-theme-overrides, meshcore-cb-preset,
* channels-show-encrypted, mc-dark-tile-provider
* - body attribute: data-cb-preset
* - body.style: --mc-role-{role}, --mc-role-{role}-text
* - root.style: --mc-role-{role}, --mc-role-{role}-text, --node-{role},
* --mc-marker-stroke-{color,width,opacity}, --mc-mb-{confirmed,suspected,unknown},
* --mc-rt-ramp-{0..4}, --logo-accent, --logo-accent-hi, theme vars
*
* Must NOT touch:
* - localStorage: meshcore-theme (theme light/dark),
* meshcore-gesture-hints-* (own button), meshcore-favorites,
* mc-channels-* selection state
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
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() {
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]; },
_data: storage,
};
// Fake CSSStyleDeclaration with property tracking
function makeStyle() {
const props = {};
return {
_props: props,
setProperty(name, value /*, priority */) { props[name] = String(value); },
removeProperty(name) { delete props[name]; },
getPropertyValue(name) { return props[name] || ''; },
get cssText() {
return Object.keys(props).map(k => k + ': ' + props[k]).join('; ');
},
set cssText(v) {
for (const k in props) delete props[k];
// very loose parser — only used to reset to empty
if (!v) return;
},
};
}
const rootStyle = makeStyle();
const bodyAttrs = {};
const bodyStyle = makeStyle();
const docEl = {
style: rootStyle,
dataset: { theme: 'dark' },
getAttribute: (n) => n === 'data-theme' ? 'dark' : null,
setAttribute: () => {},
removeAttribute: () => {},
};
const body = {
style: bodyStyle,
_attrs: bodyAttrs,
setAttribute(n, v) { bodyAttrs[n] = v; },
getAttribute(n) { return bodyAttrs[n] || null; },
removeAttribute(n) { delete bodyAttrs[n]; },
hasAttribute(n) { return n in bodyAttrs; },
};
const ctx = {
window: {
addEventListener: () => {},
_events: [],
dispatchEvent(ev) { this._events.push({ type: ev && ev.type, detail: ev && ev.detail }); },
SITE_CONFIG: {},
_SITE_CONFIG_ORIGINAL_HOME: null,
},
document: {
readyState: 'loading',
createElement: () => ({
id: '', textContent: '', innerHTML: '', className: '',
setAttribute: () => {}, appendChild: () => {},
style: makeStyle(), addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
}),
head: { appendChild: () => {} },
body: body,
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
documentElement: docEl,
},
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 { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
getComputedStyle: () => ({ getPropertyValue: () => '' }),
};
ctx.window.localStorage = localStorage;
ctx.window.CustomEvent = ctx.CustomEvent;
ctx.self = ctx.window;
return { ctx, body, rootStyle, bodyStyle, localStorage, bodyAttrs };
}
function loadCustomizer() {
const env = makeSandbox();
// Provide a stub MeshCorePresets.clearPreset so resetAll can call it.
let clearPresetCalls = 0;
env.ctx.window.MeshCorePresets = {
clearPreset() {
clearPresetCalls++;
env.localStorage.removeItem('meshcore-cb-preset');
env.body.removeAttribute('data-cb-preset');
// Mirror real cb-presets.js: strip preset CSS vars from :root
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
env.rootStyle.removeProperty('--mc-role-' + role);
env.rootStyle.removeProperty('--mc-role-' + role + '-text');
});
['confirmed', 'suspected', 'unknown'].forEach(function (k) {
env.rootStyle.removeProperty('--mc-mb-' + k);
});
for (var i = 0; i < 5; i++) env.rootStyle.removeProperty('--mc-rt-ramp-' + i);
},
get _calls() { return clearPresetCalls; },
list: [],
currentPreset: () => null,
};
// Tile provider stubs.
let tpCalls = 0;
env.ctx.window.MC_setDarkTileProvider = (id) => {
tpCalls++;
if (id) env.localStorage.setItem('mc-dark-tile-provider', id);
return true;
};
env.ctx.window.MC_getDarkTileProvider = () => env.localStorage.getItem('mc-dark-tile-provider') || 'carto-dark';
const code = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
vm.createContext(env.ctx);
vm.runInContext(code, env.ctx, { filename: 'customize-v2.js' });
env.api = env.ctx.window._customizerV2;
return env;
}
console.log('\n📋 Issue #1496 — Reset All Customizations completeness\n');
test('resetAll function is exposed on _customizerV2 API', () => {
const env = loadCustomizer();
assert.strictEqual(typeof env.api.resetAll, 'function', 'expected api.resetAll to be a function');
});
test('clears cs-theme-overrides localStorage key', () => {
const env = loadCustomizer();
env.localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('cs-theme-overrides'), null);
});
test('clears meshcore-cb-preset localStorage key', () => {
const env = loadCustomizer();
env.localStorage.setItem('meshcore-cb-preset', 'deut');
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('meshcore-cb-preset'), null);
});
test('clears channels-show-encrypted localStorage key', () => {
const env = loadCustomizer();
env.localStorage.setItem('channels-show-encrypted', 'true');
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('channels-show-encrypted'), null);
});
test('clears mc-dark-tile-provider localStorage key', () => {
const env = loadCustomizer();
env.localStorage.setItem('mc-dark-tile-provider', 'voyager-inverted');
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('mc-dark-tile-provider'), null);
});
test('removes data-cb-preset body attribute', () => {
const env = loadCustomizer();
env.body.setAttribute('data-cb-preset', 'deut');
env.api.resetAll();
assert.strictEqual(env.body.hasAttribute('data-cb-preset'), false);
});
test('clears per-role --mc-role-* writes from body.style', () => {
const env = loadCustomizer();
env.bodyStyle.setProperty('--mc-role-repeater', '#abcdef');
env.bodyStyle.setProperty('--mc-role-companion', '#123456');
env.bodyStyle.setProperty('--mc-role-repeater-text', '#ffffff');
env.api.resetAll();
assert.strictEqual(env.bodyStyle.getPropertyValue('--mc-role-repeater'), '');
assert.strictEqual(env.bodyStyle.getPropertyValue('--mc-role-companion'), '');
assert.strictEqual(env.bodyStyle.getPropertyValue('--mc-role-repeater-text'), '');
});
test('clears per-role --mc-role-* and --node-* from root.style', () => {
const env = loadCustomizer();
['repeater','companion','room','sensor','observer'].forEach((r) => {
env.rootStyle.setProperty('--mc-role-' + r, '#abcdef');
env.rootStyle.setProperty('--node-' + r, '#abcdef');
});
env.api.resetAll();
['repeater','companion','room','sensor','observer'].forEach((r) => {
assert.strictEqual(env.rootStyle.getPropertyValue('--mc-role-' + r), '', '--mc-role-' + r);
assert.strictEqual(env.rootStyle.getPropertyValue('--node-' + r), '', '--node-' + r);
});
});
test('clears marker stroke vars from root.style', () => {
const env = loadCustomizer();
env.rootStyle.setProperty('--mc-marker-stroke-color', '#ff0000');
env.rootStyle.setProperty('--mc-marker-stroke-width', '3');
env.rootStyle.setProperty('--mc-marker-stroke-opacity', '0.5');
env.api.resetAll();
assert.strictEqual(env.rootStyle.getPropertyValue('--mc-marker-stroke-color'), '');
assert.strictEqual(env.rootStyle.getPropertyValue('--mc-marker-stroke-width'), '');
assert.strictEqual(env.rootStyle.getPropertyValue('--mc-marker-stroke-opacity'), '');
});
test('clears --mc-mb-* and --mc-rt-ramp-* preset vars from root.style', () => {
const env = loadCustomizer();
['confirmed','suspected','unknown'].forEach((k) => env.rootStyle.setProperty('--mc-mb-' + k, '#abcdef'));
for (let i = 0; i < 5; i++) env.rootStyle.setProperty('--mc-rt-ramp-' + i, '#abcdef');
env.api.resetAll();
['confirmed','suspected','unknown'].forEach((k) => {
assert.strictEqual(env.rootStyle.getPropertyValue('--mc-mb-' + k), '', '--mc-mb-' + k);
});
for (let i = 0; i < 5; i++) {
assert.strictEqual(env.rootStyle.getPropertyValue('--mc-rt-ramp-' + i), '', '--mc-rt-ramp-' + i);
}
});
test('does NOT clear theme (meshcore-theme) localStorage key', () => {
const env = loadCustomizer();
env.localStorage.setItem('meshcore-theme', 'dark');
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('meshcore-theme'), 'dark');
});
test('does NOT clear meshcore-gesture-hints-* keys', () => {
const env = loadCustomizer();
env.localStorage.setItem('meshcore-gesture-hints-row-swipe', '1');
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('meshcore-gesture-hints-row-swipe'), '1');
});
test('does NOT clear meshcore-favorites', () => {
const env = loadCustomizer();
env.localStorage.setItem('meshcore-favorites', '["abc"]');
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('meshcore-favorites'), '["abc"]');
});
test('does NOT clear mc-channels-* selection state', () => {
const env = loadCustomizer();
env.localStorage.setItem('mc-channels-selection', 'foo');
env.api.resetAll();
assert.strictEqual(env.localStorage.getItem('mc-channels-selection'), 'foo');
});
test('dispatches mc-channels-show-encrypted-changed with on:false (live re-render)', () => {
const env = loadCustomizer();
env.ctx.window._events.length = 0;
env.api.resetAll();
const ev = env.ctx.window._events.find(e => e.type === 'mc-channels-show-encrypted-changed');
assert.ok(ev, 'expected mc-channels-show-encrypted-changed event');
assert.strictEqual(ev.detail && ev.detail.on, false, 'expected detail.on === false');
});
console.log('\n' + passed + ' passed, ' + failed + ' failed\n');
if (failed > 0) process.exit(1);