diff --git a/public/cb-presets.js b/public/cb-presets.js index 9142d3d0..921ffff5 100644 --- a/public/cb-presets.js +++ b/public/cb-presets.js @@ -279,11 +279,44 @@ if (v && _byId(v)) return v; } } catch (e) {} - return 'default'; + // #1446 — return null when no preset is stored. Previously this returned + // 'default' unconditionally, which forced body[data-cb-preset="default"] + // on every cold boot and trapped --mc-role-* in the Wong palette via the + // matching style.css rule. The CB preset is now an end-user opt-in: + // absent attribute = "no preset", role colors flow from server config. + return null; + } + + function clearPreset() { + try { if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY); } catch (e) {} + if (typeof document !== 'undefined' && document.body && document.body.removeAttribute) { + document.body.removeAttribute(DATA_ATTR); + } + // Strip preset-written CSS vars from documentElement so the cascade + // re-falls through :root defaults (or server config, which the + // customizer pipeline re-applies via the cb-preset-changed listener). + if (typeof document !== 'undefined' && document.documentElement && document.documentElement.style) { + var style = document.documentElement.style; + ['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) { + style.removeProperty('--mc-role-' + role); + style.removeProperty('--mc-role-' + role + '-text'); + }); + ['confirmed', 'suspected', 'unknown'].forEach(function (k) { + style.removeProperty('--mc-mb-' + k); + }); + for (var ri = 0; ri < 5; ri++) style.removeProperty('--mc-rt-ramp-' + ri); + } + if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof window.CustomEvent === 'function') { + try { window.dispatchEvent(new window.CustomEvent('cb-preset-changed', { detail: { id: null } })); } catch (e) {} + } + return true; } function initFromStorage() { - applyPreset(currentPreset(), { skipPersist: true }); + var id = currentPreset(); + // #1446 — only apply when a preset is actually stored. No stored preset + // means "no preset active" (the new default), not "fall back to Wong". + if (id) applyPreset(id, { skipPersist: true }); } // Cross-tab sync via storage event. @@ -305,6 +338,7 @@ var api = { list: PRESETS, applyPreset: applyPreset, + clearPreset: clearPreset, currentPreset: currentPreset, initFromStorage: initFromStorage, validatePreset: validatePreset, diff --git a/public/customize-v2.js b/public/customize-v2.js index 2c2c7752..515819bf 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -554,12 +554,18 @@ // overrides still flow through setRoleColorOverride() in customize.js. var nc = effectiveConfig.nodeColors; if (nc) { - // #1438 final: scope --mc-role-{role} writes to USER overrides only. - // Server-config nodeColors must stay out of --mc-role-* because - // ROLE_COLORS live getter (roles.js) reads from that var; writing - // server defaults there would re-introduce the #1412 bug (server - // legacy palette trapping cb-preset propagation). + // #1438 final: scope --mc-role-{role} writes to USER overrides only, + // UNLESS no CB preset is active (#1446). When a preset is active the + // server-config palette must stay out of --mc-role-* so the preset + // wins (preserves #1412). When NO preset is active, the cascade is: + // user override > server config > built-in :root default. + // → server config gets to write --mc-role-{role} in that case. var userNc = (userOverrides && userOverrides.nodeColors) || {}; + var presetActive = false; + try { + var presetAttr = document.body && document.body.getAttribute && document.body.getAttribute('data-cb-preset'); + presetActive = !!(presetAttr && presetAttr !== 'none'); + } catch (e) {} for (var role in nc) { root.setProperty('--node-' + role, nc[role]); if (Object.prototype.hasOwnProperty.call(userNc, role)) { @@ -568,7 +574,13 @@ // pick it up on every page load. Without this the user pick // sits in localStorage but --mc-role-{role} falls back to the // active preset on reload, reverting marker fills. - root.setProperty('--mc-role-' + role, userNc[role]); + root.setProperty('--mc-role-' + role, nc[role]); + } else if (!presetActive) { + // #1446 — no preset is active; server config is the legitimate + // source of role colors. Write --mc-role-{role} so marker SVGs + // honor operator's config.json without forcing visitors to pick + // a CB preset to "unlock" their server palette. + root.setProperty('--mc-role-' + role, nc[role]); } } } @@ -1147,7 +1159,9 @@ function _renderColorblindPresetSelector() { var MCP = (typeof window !== 'undefined') && window.MeshCorePresets; if (!MCP || !Array.isArray(MCP.list)) return ''; - var current = MCP.currentPreset ? MCP.currentPreset() : 'default'; + // #1446 — currentPreset() now returns null when no preset is stored. + var current = MCP.currentPreset ? MCP.currentPreset() : null; + var clearOpt = _renderCbPresetClearOption(current); var options = MCP.list.map(function (p) { var checked = p.id === current ? ' checked' : ''; return ''; }).join(''); - return '
Colorblind Preset
' + - 'Switch the role/status palette for color-vision variants. Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.
' + - 'Optional: Colorblind-Safe Preset
' + + 'A CB preset is an end-user opt-in that swaps the role/status palette for color-vision variants. ' + + 'Leave unset to use the operator\'s configured colors (or pick from above). ' + + 'Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.
' + + 'Node Role Colors
' + rows + + 'Node Role Colors
' + + 'These are the canonical role colors used across the app. They inherit from your server config (or built-in defaults), and can be optionally remapped by a colorblind-safe preset below.
' + + rows + 'Packet Type Colors
' + typeRows + 'Heatmap Opacity
' + @@ -1833,14 +1862,21 @@ if (_activeTab === 'geofilter') _initGeoFilterTab(container); // #1361 Colorblind preset radio — switches preset via MeshCorePresets.applyPreset + // #1446 — empty-value radio = "no preset" → clearPreset(), then re-run + // the customizer pipeline so server-config colors take over. container.querySelectorAll('[data-cv2-cb-preset]').forEach(function (radio) { radio.addEventListener('change', function () { if (!radio.checked) return; var id = radio.value; - if (window.MeshCorePresets && typeof window.MeshCorePresets.applyPreset === 'function') { - window.MeshCorePresets.applyPreset(id); - _refreshPanel(); + var MCP = window.MeshCorePresets; + if (!MCP) return; + if (!id) { + if (typeof MCP.clearPreset === 'function') MCP.clearPreset(); + _runPipeline(); + } else if (typeof MCP.applyPreset === 'function') { + MCP.applyPreset(id); } + _refreshPanel(); }); }); @@ -2166,6 +2202,18 @@ // 1. Migration check migrateOldKeys(); + // #1446 — when a CB preset is cleared (or applied), re-run the customizer + // pipeline so server-config nodeColors take over the --mc-role-{role} + // CSS vars (the gating logic in applyCSS checks the body[data-cb-preset] + // attribute to decide whether to write them). + try { + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('cb-preset-changed', function () { + if (_initDone) _runPipeline(); + }); + } + } catch (e) {} + // 2. Read overrides and apply CSS immediately (before DOMContentLoaded) // Server defaults will be set later when /api/config/theme completes. // For now, apply whatever overrides exist on top of current SITE_CONFIG. diff --git a/public/roles.js b/public/roles.js index a607217d..68b5d2c6 100644 --- a/public/roles.js +++ b/public/roles.js @@ -138,7 +138,23 @@ // pick up the operator's hex without a page reload. Writing to // body inline style is necessary because body[data-cb-preset="..."] // selectors beat :root inheritance. - targets.forEach(function (s) { s.setProperty(varName, hex); }); + // + // #1446: write with !important so the inline body declaration also + // beats the body[data-cb-preset="X"] CSS rule on equal specificity. + // Without !important, the cascade order picks the later-defined + // stylesheet rule in some browser versions even though specificity + // (1,0,1) matches the inline body style — operator pick visibly + // loses to active preset (root cause of #1444). + targets.forEach(function (s) { + // documentElement gets the value without !important (used as the + // canonical readout for the JS getter); body gets !important so it + // wins the CSS cascade against body[data-cb-preset="X"]. + if (s === (document.body && document.body.style)) { + s.setProperty(varName, hex, 'important'); + } else { + s.setProperty(varName, hex); + } + }); }; // Back-compat: also export the writable override map so customize.js's // `window.ROLE_COLORS[key] = inp.value` style mutation works. diff --git a/public/style.css b/public/style.css index 8f6787e0..e711a4ec 100644 --- a/public/style.css +++ b/public/style.css @@ -3479,21 +3479,12 @@ th.sort-active { color: var(--accent, #60a5fa); } * truth so a regression that drops one is still caught by the other * (mirrors the #1356 "pill color: defense-in-depth via CSS + inline" pattern). * ─────────────────────────────────────────────────────────────────────── */ -body[data-cb-preset="default"] { - --mc-role-repeater: #D55E00; - --mc-role-companion: #56B4E9; - --mc-role-room: #009E73; - --mc-role-sensor: #F0E442; - --mc-role-observer: #CC79A7; - --mc-role-repeater-text: #1a1a1a; - --mc-role-companion-text: #1a1a1a; - --mc-role-room-text: #1a1a1a; - --mc-role-sensor-text: #1a1a1a; - --mc-role-observer-text: #1a1a1a; - --mc-mb-confirmed: #56F0A0; - --mc-mb-suspected: #FFD966; - --mc-mb-unknown: #FF8888; -} +/* #1446 — the body data-cb-preset rule for the default (Wong) palette is + * intentionally OMITTED. Wong is the :root baseline; selecting "default" + * from the customizer is identical to "no preset". Writing the Wong + * palette here would mask the server config's nodeColors (see #1446 + * cascade). Wong values remain available via :root above. + */ body[data-cb-preset="deut"] { /* IBM 5-class deut variant — anchors shifted out of red/green collision. */ --mc-role-repeater: #FE6100; diff --git a/test-issue-1446-cb-preset-cascade.js b/test-issue-1446-cb-preset-cascade.js index 0f9701bf..078ec7e1 100644 --- a/test-issue-1446-cb-preset-cascade.js +++ b/test-issue-1446-cb-preset-cascade.js @@ -57,6 +57,7 @@ function makeSandbox(localStorageMap) { }; const sandbox = { window: null, + _listeners: {}, localStorage: { _m: Object.assign({}, localStorageMap), getItem(k) { return Object.prototype.hasOwnProperty.call(this._m, k) ? this._m[k] : null; }, @@ -76,7 +77,15 @@ function makeSandbox(localStorageMap) { }, console: console, setTimeout: setTimeout, clearTimeout: clearTimeout, - addEventListener() {}, dispatchEvent() { return true; }, + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + }, + dispatchEvent(ev) { + var arr = this._listeners[ev && ev.type] || []; + arr.forEach(function (fn) { try { fn(ev); } catch (e) {} }); + return true; + }, fetch: function () { return Promise.resolve({ json: function () { return Promise.resolve({}); } }); }, matchMedia: function () { return { matches: false, addEventListener() {} }; }, CustomEvent: function (t, o) { this.type = t; this.detail = o && o.detail; },