feat(#1446): CB preset is an end-user opt-in (closes #1446, fixes #1444 cascade) (#1447)

## Summary

Reframes the CB-preset feature as an **end-user opt-in** layered above
operator
config — not the canonical color source for the app. Implements the
cascade
defined in #1446's acceptance test and fixes the #1444 cascade trap as a
side effect.

**Cascade (top wins):**

```
user per-role override  >  active CB preset  >  server config.nodeColors  >  built-in :root defaults
```

Red commit: f59c0c5e (8 scenarios, 9 assertions red on master)
Green commit: 21f9b80c (all 16 assertions pass; reverting any one of the
four
source files brings the test back red).

## Changes

| File | What |
|---|---|
| `cb-presets.js` | `currentPreset()` returns `null` on no-stored-preset
(was `'default'`). `initFromStorage()` no longer auto-applies Wong cold.
New `clearPreset()` API. |
| `style.css` | Drop the `body[data-cb-preset="default"]` block. Wong
remains `:root` baseline; that block was masking server config in the
"no preset" state. |
| `roles.js` | `setRoleColorOverride` writes to `body.style` with
`!important` so user picks win on equal-specificity cascade against
`body[data-cb-preset="X"]` (root cause of #1444). |
| `customize-v2.js` | `applyCSS`: when no preset active, server-config
nodeColors get `--mc-role-{role}` too. UI re-ordered (Node Role Colors
first, preset section labelled "Optional"). Wires `cb-preset-changed`
listener so `clearPreset()` re-applies server config live. |

## Backward compat

- Visitors with a stored CB preset in localStorage continue to see it on
load.
- Visitors without one: now see operator's `config.json` colors (or
built-in
Wong if config has no `nodeColors`). Visually identical for default
deploys.

## Acceptance scenarios (verified in
`test-issue-1446-cb-preset-cascade.js`)

1. Cold boot, no localStorage → no `data-cb-preset` attr, no
`--mc-role-*` clamp
2. Server `nodeColors.repeater = #aaaaaa`, no preset →
`--mc-role-repeater = #aaaaaa`
3. User picks `#ff00ff` while `deut` active → body inline `!important`
wins
4. Clear override while `deut` active → reverts to `#FE6100` (deut)
5. Clear preset (server config present) → reverts to server config
6. Stored preset auto-applies on boot (backward compat)
7. Customizer UI: Node Role Colors block precedes preset block
8. `style.css`: no body data-cb-preset rule re-defines Wong (would mask
server)

Post-merge CDP verification on staging will run the 5 issue-acceptance
scenarios.

Closes #1446
Fixes #1444 (cascade)

E2E assertion added: `test-issue-1446-cb-preset-cascade.js:124`
(scenario 3 — user override beats active preset on body inline with
!important).
Browser verified: pending hot-deploy + CDP run post-merge (per task
brief).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-05-27 20:24:58 -07:00
committed by GitHub
parent b01466237f
commit ddf14d1954
6 changed files with 381 additions and 33 deletions
+36 -2
View File
@@ -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,
+63 -15
View File
@@ -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 '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
@@ -1159,12 +1173,25 @@
'</div>' +
'</label>';
}).join('');
return '<p class="cust-section-title">Colorblind Preset</p>' +
'<p class="cust-hint" style="margin-bottom:8px">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.</p>' +
'<div class="cust-cb-presets" data-cv2-cb-preset-group>' + options + '</div>' +
return '<p class="cust-section-title">Optional: Colorblind-Safe Preset</p>' +
'<p class="cust-hint" style="margin-bottom:8px">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.</p>' +
'<div class="cust-cb-presets" data-cv2-cb-preset-group>' + clearOpt + options + '</div>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">';
}
function _renderCbPresetClearOption(current) {
var checked = !current ? ' checked' : '';
return '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
'<input type="radio" name="cv2-cb-preset" data-cv2-cb-preset value="" data-cv2-cb-preset-none' + checked + ' style="margin-top:3px">' +
'<div style="flex:1">' +
'<div style="font-weight:600">No preset (use operator / custom colors)</div>' +
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">Default — server-configured colors apply, then any per-role overrides above.</div>' +
'</div>' +
'</label>';
}
function _renderCbPresetWarning(id) {
var MCP = window.MeshCorePresets;
if (!MCP || typeof MCP.validatePreset !== 'function') return '';
@@ -1213,9 +1240,11 @@
var liveHeatPct = Math.round(liveHeatOpacity * 100);
return '<div class="cust-panel' + (_activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
_renderColorblindPresetSelector() +
'<p class="cust-section-title">Node Role Colors</p>' + rows +
'<p class="cust-section-title">Node Role Colors</p>' +
'<p class="cust-hint" style="margin-bottom:8px">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.</p>' +
rows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
_renderColorblindPresetSelector() +
'<p class="cust-section-title">Packet Type Colors</p>' + typeRows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Heatmap Opacity</p>' +
@@ -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.
+17 -1
View File
@@ -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.
+6 -15
View File
@@ -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;
+1
View File
@@ -39,6 +39,7 @@ node test-issue-1418-polish-review.js
node test-issue-1420-tile-providers.js
node test-issue-1438-marker-css-vars.js
node test-issue-1438-customizer-mcrole.js
node test-issue-1446-cb-preset-cascade.js
echo ""
echo "═══════════════════════════════════════"
+258
View File
@@ -0,0 +1,258 @@
/**
* #1446 CB preset is an end-user opt-in, NOT the canonical color source.
*
* Cascade (top wins):
* user per-role override > active CB preset > server config.nodeColors > built-in :root defaults
*
* 5 acceptance scenarios from the issue. Source-grep + vm-sandbox assertions
* because the cascade is enforced by a mix of JS (cb-presets.js setting
* body[data-cb-preset]), CSS (body[data-cb-preset="X"] selectors), and inline
* style writes (setRoleColorOverride + applyCSS).
*
* If these fail, reverting the fix on customize-v2.js / cb-presets.js / roles.js
* brings them back red.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const ROOT = __dirname;
const cv2Src = fs.readFileSync(path.join(ROOT, 'public', 'customize-v2.js'), 'utf8');
const rolesSrc = fs.readFileSync(path.join(ROOT, 'public', 'roles.js'), 'utf8');
const presetsSrc = fs.readFileSync(path.join(ROOT, 'public', 'cb-presets.js'), 'utf8');
const styleSrc = fs.readFileSync(path.join(ROOT, 'public', 'style.css'), 'utf8');
function makeSandbox(localStorageMap) {
localStorageMap = localStorageMap || {};
function makeStyle() {
return {
_vars: {}, // map var → value
_imp: {}, // map var → priority ('important' or '')
setProperty(k, v, prio) {
this._vars[k] = String(v);
this._imp[k] = prio || '';
},
getPropertyValue(k) { return this._vars[k] || ''; },
getPropertyPriority(k) { return this._imp[k] || ''; },
removeProperty(k) { delete this._vars[k]; delete this._imp[k]; }
};
}
const root = { style: makeStyle(), getAttribute() { return null; }, setAttribute() {} };
const body = {
_attrs: {},
setAttribute(k, v) { this._attrs[k] = v; },
getAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; },
removeAttribute(k) { delete this._attrs[k]; },
hasAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k); },
dataset: {},
style: makeStyle()
};
const sandbox = {
window: null,
_listeners: {},
localStorage: {
_m: Object.assign({}, localStorageMap),
getItem(k) { return Object.prototype.hasOwnProperty.call(this._m, k) ? this._m[k] : null; },
setItem(k, v) { this._m[k] = String(v); },
removeItem(k) { delete this._m[k]; }
},
document: {
documentElement: root,
body: body,
readyState: 'complete',
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; },
head: { appendChild() {} },
addEventListener() {}
},
console: console,
setTimeout: setTimeout, clearTimeout: clearTimeout,
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; },
Event: function (t) { this.type = t; },
Proxy: Proxy,
getComputedStyle: function () {
// Pretend body[data-cb-preset]=X CSS rule applies if attr is set: but
// the JS apply also writes the var to root, so just return root's view.
return {
getPropertyValue: function (k) { return root.style._vars[k] || ''; }
};
}
};
sandbox.window = sandbox;
return { sandbox, root, body };
}
// ─── SCENARIO 1: Default load with NO localStorage preset → data-cb-preset must NOT be set ───
console.log('\n=== #1446 Scenario 1: cold boot with empty localStorage → no preset active ===');
{
const env = makeSandbox({});
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
// After cb-presets.js auto-init: with no stored preset, body must NOT carry
// data-cb-preset (or it must be "none"), so the body[data-cb-preset="X"]
// CSS rules in style.css do not apply.
const attr = env.body.getAttribute('data-cb-preset');
assert(attr === null || attr === 'none' || attr === '',
'cold boot: body[data-cb-preset] not forced to "default" (got: ' + JSON.stringify(attr) + ')');
// And cb-presets must NOT have stomped --mc-role-repeater on root with the
// Wong default, because that would lock out server config.
const repAtBoot = env.root.style.getPropertyValue('--mc-role-repeater');
assert(!repAtBoot,
'cold boot: cb-presets did NOT auto-write --mc-role-repeater (got: ' + JSON.stringify(repAtBoot) + ')');
}
// ─── SCENARIO 2: Server config nodeColors land on --mc-role-X when no preset is active ───
console.log('\n=== #1446 Scenario 2: server config.nodeColors → --mc-role-X (no preset active) ===');
{
const env = makeSandbox({});
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
vm.runInContext(cv2Src, env.sandbox);
env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa', companion: '#56B4E9' } });
const rep = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
assert(rep === '#aaaaaa',
'server-only nodeColors with NO active preset writes --mc-role-repeater = #aaaaaa (got: ' + JSON.stringify(rep) + ')');
}
// ─── SCENARIO 3: User per-role override beats active preset (writes to body inline w/ !important) ───
console.log('\n=== #1446 Scenario 3: user override > active CB preset (body inline !important) ===');
{
const env = makeSandbox({});
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
env.sandbox.window.MeshCorePresets.applyPreset('deut');
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
const bodyVal = env.body.style.getPropertyValue('--mc-role-repeater').toLowerCase();
const bodyPrio = env.body.style.getPropertyPriority('--mc-role-repeater');
assert(bodyVal === '#ff00ff',
'setRoleColorOverride writes --mc-role-repeater on body.style (got: ' + JSON.stringify(bodyVal) + ')');
assert(bodyPrio === 'important',
'setRoleColorOverride writes with !important so it wins against body[data-cb-preset] cascade (got: ' + JSON.stringify(bodyPrio) + ')');
}
// ─── SCENARIO 4: Clear per-role override → reverts to active preset ───
console.log('\n=== #1446 Scenario 4: clear override → reverts to active CB preset ===');
{
const env = makeSandbox({});
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
env.sandbox.window.MeshCorePresets.applyPreset('deut');
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
env.sandbox.window.setRoleColorOverride('repeater', null);
const bodyVal = env.body.style.getPropertyValue('--mc-role-repeater').toLowerCase();
// After clear: body inline should be restored to the snapshot (deut #FE6100) OR removed.
// The snapshot logic in roles.js restores the prior body inline value (which
// was '' before override since applyPreset writes only to root).
// Either way, after clear, the body inline override must NOT shadow the preset.
// Effective truth: root still carries #fe6100 from applyPreset.
const rootVal = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
assert(rootVal === '#fe6100',
'after clearing override, --mc-role-repeater on root still shows preset deut #FE6100 (got: ' + JSON.stringify(rootVal) + ')');
assert(bodyVal !== '#ff00ff',
'after clearing override, body inline no longer carries the user pick (got: ' + JSON.stringify(bodyVal) + ')');
}
// ─── SCENARIO 5: Clear preset → reverts to server config / built-in default ───
console.log('\n=== #1446 Scenario 5: clear preset → reverts to server config / default ===');
{
const env = makeSandbox({ 'meshcore-cb-preset': 'deut' });
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
vm.runInContext(cv2Src, env.sandbox);
env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa' } });
// Confirm deut is active first.
const repWithPreset = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
assert(repWithPreset === '#fe6100',
'precondition: deut active → --mc-role-repeater = #FE6100 (got: ' + JSON.stringify(repWithPreset) + ')');
// Now clear the preset.
const hasClear = typeof env.sandbox.window.MeshCorePresets.clearPreset === 'function';
assert(hasClear, 'MeshCorePresets.clearPreset() exists');
if (hasClear) env.sandbox.window.MeshCorePresets.clearPreset();
else { env.body.removeAttribute('data-cb-preset'); /* fallback to keep test going */ }
const attr = env.body.getAttribute('data-cb-preset');
assert(attr === null || attr === 'none' || attr === '',
'after clearPreset, body[data-cb-preset] is unset/none (got: ' + JSON.stringify(attr) + ')');
const repAfter = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
assert(repAfter === '#aaaaaa',
'after clearPreset, --mc-role-repeater reverts to server config #aaaaaa (got: ' + JSON.stringify(repAfter) + ')');
}
// ─── SCENARIO 6: existing localStorage preset still applies (backward compat) ───
console.log('\n=== #1446 Scenario 6: backward compat — existing localStorage preset still applies ===');
{
const env = makeSandbox({ 'meshcore-cb-preset': 'deut' });
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
// cb-presets auto-init must have applied deut from storage.
const attr = env.body.getAttribute('data-cb-preset');
assert(attr === 'deut', 'stored "deut" preset auto-applies on boot (got: ' + JSON.stringify(attr) + ')');
const rep = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
assert(rep === '#fe6100', 'stored preset writes --mc-role-repeater = #FE6100 (got: ' + JSON.stringify(rep) + ')');
}
// ─── Source-grep: customizer UI puts per-role pickers FIRST, then preset selector ───
console.log('\n=== #1446 Scenario 7: customizer UI re-org — node-color pickers come BEFORE preset selector ===');
{
// Find _renderNodes() return string: it concatenates _renderColorblindPresetSelector() + node rows + ...
// After fix: node rows come first, preset selector is in a labelled-secondary block.
const renderNodesIdx = cv2Src.indexOf('function _renderNodes()');
const after = cv2Src.slice(renderNodesIdx, renderNodesIdx + 4000);
const presetIdx = after.indexOf('_renderColorblindPresetSelector');
const rolesIdx = after.indexOf("Node Role Colors");
assert(presetIdx > rolesIdx && rolesIdx > 0,
'Node Role Colors section appears BEFORE Colorblind Preset block in _renderNodes (rolesIdx=' + rolesIdx + ', presetIdx=' + presetIdx + ')');
// Preset section labelled as optional.
assert(/Optional[^<]*colorblind|colorblind[^<]*\(optional\)/i.test(cv2Src),
'preset section labelled "Optional" / "(optional)" in customizer UI');
}
// ─── Source-grep: style.css must NOT define a body[data-cb-preset="default"] rule
// that locks --mc-role-X to Wong — Wong is the :root default already, and the
// "default" preset selection is the same as "no preset". ───
console.log('\n=== #1446 Scenario 8: style.css does not redundantly clamp Wong via body[data-cb-preset="default"] ===');
{
// Either the body[data-cb-preset="default"] block is removed, OR the rule body
// does not set --mc-role-* (only resets text). Either way, when default is
// active the rule must not write --mc-role-repeater = #D55E00 (which would
// mask server config). The safest implementation: drop the "default" block.
const re = /body\[data-cb-preset="default"\][^{]*\{([^}]*)\}/;
const m = styleSrc.match(re);
if (!m) {
assert(true, 'no body[data-cb-preset="default"] block in style.css (preferred)');
} else {
const blockBody = m[1];
assert(!/--mc-role-(repeater|companion|room|sensor|observer)\s*:/.test(blockBody),
'body[data-cb-preset="default"] block does not redefine --mc-role-{role} (would mask server config)');
}
}
console.log('\n──────────────────────────');
console.log('passed: ' + passed + ', failed: ' + failed);
process.exit(failed === 0 ? 0 : 1);