fix(#1361): theme customizer — colorblind presets [WIP] (#1378)

WIP — draft PR for CI to exercise the RED test commit. Will be promoted
out of draft once the GREEN commit lands.

Red commit: 8b37c918 (test-only, expected CI failure on assertions)

Tracks #1361.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-05-25 22:35:42 -07:00
committed by GitHub
co-authored by openclaw-bot
parent 0b35c7eef3
commit 101c11b4b3
7 changed files with 617 additions and 0 deletions
+1
View File
@@ -110,6 +110,7 @@ jobs:
node test-issue-1360-pill-letter-count.js
node test-issue-1364-pill-no-clamp.js
node test-issue-1375-scope-stats-fetch.js
node test-issue-1361-cb-presets.js
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
run: |
+19
View File
@@ -1056,6 +1056,25 @@ window.addEventListener('DOMContentLoaded', () => {
applyTheme(isDark ? 'light' : 'dark');
});
// --- #1361 Colorblind preset bootstrap & cross-tab sync ---
// cb-presets.js auto-inits on module load, but body may not have existed
// yet (script loads in <head>); re-apply now that DOMContentLoaded fired
// so body[data-cb-preset] is set before first paint of map/cluster bubbles.
try {
if (window.MeshCorePresets && typeof window.MeshCorePresets.initFromStorage === 'function') {
window.MeshCorePresets.initFromStorage();
}
} catch (e) { console.error('[cb-preset] init failed:', e); }
// Cross-tab sync: storage event listener is also registered inside
// cb-presets.js, but we wire a redundant one here so any future refactor
// of the module still leaves the cross-tab guarantee intact.
window.addEventListener('storage', function (ev) {
if (!ev || ev.key !== 'meshcore-cb-preset') return;
if (window.MeshCorePresets && ev.newValue) {
window.MeshCorePresets.applyPreset(ev.newValue, { skipPersist: true });
}
});
// --- Hamburger Menu ---
const hamburger = document.getElementById('hamburger');
const navLinks = document.querySelector('.nav-links');
+263
View File
@@ -0,0 +1,263 @@
/* cb-presets.js — Colorblind preset registry & runtime switcher (#1361).
*
* MVP scope:
* - 5 presets: default (Wong 2011), deut (IBM 5-class), prot (IBM 5-class
* with high-luminance amber anchor), trit (Tol muted, blue/yellow-safe),
* achromat (pure luminance ramp).
* - applyPreset(id) sets body[data-cb-preset], writes --mc-role-* and
* --mc-mb-* CSS vars on documentElement, persists to localStorage.
* - initFromStorage() re-applies on reload.
* - storage event listener syncs across tabs.
* - WCAG 2.2 SC 1.4.3 / 1.4.11 contrast helper for validation.
*
* Stretch (Brettel/Vienot SVG simulation overlay, "Reset to default Wong"
* button) is intentionally NOT implemented here — separate follow-up.
*
* Palette sources cited in PR body.
*/
(function () {
'use strict';
var STORAGE_KEY = 'meshcore-cb-preset';
var DATA_ATTR = 'data-cb-preset';
// ── Palettes ────────────────────────────────────────────────────────────
// Each preset declares colors for the 5 roles + the 3 multi-byte status
// colors. role keys mirror --mc-role-{repeater|companion|room|sensor|observer}.
// mb keys mirror --mc-mb-{confirmed|suspected|unknown}.
var PRESETS = [
{
id: 'default',
label: 'Default (Wong 2011)',
description: 'Wong\'s 8-class colorblind-safe palette — the project default.',
roleColors: {
repeater: '#D55E00', // vermillion
companion: '#56B4E9', // sky blue
room: '#009E73', // bluish-green
sensor: '#F0E442', // yellow
observer: '#CC79A7' // reddish-purple
},
mb: {
confirmed: '#56F0A0',
suspected: '#FFD966',
unknown: '#FF8888'
}
},
{
id: 'deut',
label: 'Deuteranopia-tuned',
description: 'IBM 5-class palette — anchors shifted away from red/green collision.',
// IBM Design Language colorblind-safe: blue / purple / magenta / orange / amber.
roleColors: {
repeater: '#FE6100', // orange (high-luminance anchor for repeater)
companion: '#648FFF', // blue
room: '#785EF0', // purple
sensor: '#FFB000', // amber
observer: '#DC267F' // magenta
},
mb: {
confirmed: '#648FFF',
suspected: '#FFB000',
unknown: '#DC267F'
}
},
{
id: 'prot',
label: 'Protanopia-tuned',
description: 'IBM 5-class with amber-shifted repeater anchor (protan-safe luminance).',
roleColors: {
repeater: '#FFB000', // amber — higher luminance than orange for protans
companion: '#648FFF',
room: '#785EF0',
sensor: '#FE6100',
observer: '#DC267F'
},
mb: {
confirmed: '#648FFF',
suspected: '#FFB000',
unknown: '#DC267F'
}
},
{
id: 'trit',
label: 'Tritanopia-tuned',
description: 'Tol muted palette — avoids blue/yellow confusion zone.',
// Paul Tol muted (B/Y-safe): red / teal / green / purple / sand.
roleColors: {
repeater: '#CC6677', // rose
companion: '#117733', // green
room: '#882255', // wine
sensor: '#DDCC77', // sand (replaces pure yellow)
observer: '#AA4499' // purple
},
mb: {
confirmed: '#117733',
suspected: '#DDCC77',
unknown: '#CC6677'
}
},
{
id: 'achromat',
label: 'Achromatopsia (monochrome)',
description: 'Pure luminance ramp — relies on shape/letter/glyph carriers from #1356/#1357.',
// Luminance ramp at 90/70/50/35/20% per spec. Achromat users distinguish
// by lightness; the shape/letter/glyph carriers from #1356/#1357 carry
// role identity. Map markers also have the dark halo from #1356 so even
// light-grey fills remain visible against Carto-positron.
roleColors: {
repeater: '#333333', // L=20%
companion: '#595959', // L=35%
room: '#808080', // L=50%
sensor: '#b3b3b3', // L=70%
observer: '#e6e6e6' // L=90%
},
mb: {
confirmed: '#b3b3b3',
suspected: '#808080',
unknown: '#595959'
}
}
];
// ── WCAG helpers ────────────────────────────────────────────────────────
function _hexToRgb(hex) {
if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
return {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16)
};
}
function _channelLin(c) {
var s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function relativeLuminance(hex) {
var rgb = _hexToRgb(hex);
if (!rgb) return 0;
return 0.2126 * _channelLin(rgb.r) + 0.7152 * _channelLin(rgb.g) + 0.0722 * _channelLin(rgb.b);
}
function contrast(fg, bg) {
var L1 = relativeLuminance(fg);
var L2 = relativeLuminance(bg);
var hi = Math.max(L1, L2);
var lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
// Canonical map tile backgrounds for validation (Carto Positron / Dark Matter)
var TILE_LIGHT = '#f2efe9';
var TILE_DARK = '#1a1a1a';
/**
* Validate a preset against WCAG 2.2 SC 1.4.11 (3:1 for non-text UI).
* Returns an array of { role, color, vsLight, vsDark, passLight, passDark }.
*/
function validatePreset(presetId) {
var p = PRESETS.filter(function (x) { return x.id === presetId; })[0];
if (!p) return [];
var out = [];
Object.keys(p.roleColors).forEach(function (role) {
var c = p.roleColors[role];
var vL = contrast(c, TILE_LIGHT);
var vD = contrast(c, TILE_DARK);
out.push({
role: role,
color: c,
vsLight: vL,
vsDark: vD,
passLight: vL >= 3.0,
passDark: vD >= 3.0
});
});
return out;
}
// ── Runtime application ────────────────────────────────────────────────
function _byId(id) {
for (var i = 0; i < PRESETS.length; i++) if (PRESETS[i].id === id) return PRESETS[i];
return null;
}
function applyPreset(id, opts) {
opts = opts || {};
var p = _byId(id);
if (!p) return false;
if (typeof document !== 'undefined' && document.body) {
document.body.setAttribute(DATA_ATTR, p.id);
}
if (typeof document !== 'undefined' && document.documentElement) {
var style = document.documentElement.style;
Object.keys(p.roleColors).forEach(function (role) {
style.setProperty('--mc-role-' + role, p.roleColors[role]);
});
Object.keys(p.mb).forEach(function (k) {
style.setProperty('--mc-mb-' + k, p.mb[k]);
});
// Keep window.ROLE_COLORS in sync so legend/cluster JS picks up new hues.
if (typeof window !== 'undefined' && window.ROLE_COLORS) {
Object.keys(p.roleColors).forEach(function (role) {
window.ROLE_COLORS[role] = p.roleColors[role];
});
if (window.ROLE_STYLE) {
Object.keys(p.roleColors).forEach(function (role) {
if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = p.roleColors[role];
});
}
}
}
if (!opts.skipPersist) {
try { if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, p.id); } catch (e) {}
}
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof window.CustomEvent === 'function') {
try { window.dispatchEvent(new window.CustomEvent('cb-preset-changed', { detail: { id: p.id } })); } catch (e) {}
}
return true;
}
function currentPreset() {
try {
if (typeof localStorage !== 'undefined') {
var v = localStorage.getItem(STORAGE_KEY);
if (v && _byId(v)) return v;
}
} catch (e) {}
return 'default';
}
function initFromStorage() {
applyPreset(currentPreset(), { skipPersist: true });
}
// Cross-tab sync via storage event.
function _onStorage(ev) {
if (!ev || ev.key !== STORAGE_KEY) return;
var id = ev.newValue;
if (!id || !_byId(id)) return;
applyPreset(id, { skipPersist: true });
}
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', _onStorage);
}
// Auto-init on module load (so reload re-applies the saved preset before
// first paint, modulo script ordering — cb-presets.js loads before app.js).
try { initFromStorage(); } catch (e) {}
// Export
var api = {
list: PRESETS,
applyPreset: applyPreset,
currentPreset: currentPreset,
initFromStorage: initFromStorage,
validatePreset: validatePreset,
wcag: {
relativeLuminance: relativeLuminance,
contrast: contrast,
TILE_LIGHT: TILE_LIGHT,
TILE_DARK: TILE_DARK
},
STORAGE_KEY: STORAGE_KEY
};
if (typeof window !== 'undefined') window.MeshCorePresets = api;
if (typeof module !== 'undefined') module.exports = api;
})();
+49
View File
@@ -1123,6 +1123,42 @@
'</div>';
}
// ── #1361 Colorblind preset selector ──
// MVP scope: radio selector + 1-line description + WCAG warning badge.
// Stretch (live Brettel/Vienot simulation overlay, "Reset to default Wong"
// button) intentionally deferred to a follow-up issue.
function _renderColorblindPresetSelector() {
var MCP = (typeof window !== 'undefined') && window.MeshCorePresets;
if (!MCP || !Array.isArray(MCP.list)) return '';
var current = MCP.currentPreset ? MCP.currentPreset() : 'default';
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">' +
'<input type="radio" name="cv2-cb-preset" data-cv2-cb-preset value="' + escAttr(p.id) + '"' + checked + ' style="margin-top:3px">' +
'<div style="flex:1">' +
'<div style="font-weight:600">' + esc(p.label) + '</div>' +
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">' + esc(p.description) + '</div>' +
_renderCbPresetWarning(p.id) +
'</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>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">';
}
function _renderCbPresetWarning(id) {
var MCP = window.MeshCorePresets;
if (!MCP || typeof MCP.validatePreset !== 'function') return '';
var rep = MCP.validatePreset(id);
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
var failing = rep.filter(function (r) { return dark ? !r.passDark : !r.passLight; });
if (!failing.length) return '';
var names = failing.map(function (r) { return r.role; }).join(', ');
return '<div class="cust-cb-warn" style="margin-top:4px;font-size:11px;color:var(--status-yellow);background:rgba(255,200,0,0.08);padding:4px 6px;border-radius:4px">⚠ WCAG 1.4.11: ' + esc(names) + ' below 3:1 vs ' + (dark ? 'dark' : 'light') + ' tiles</div>';
}
function _renderNodes() {
var eff = _getEffective();
var server = _getServer();
@@ -1160,6 +1196,7 @@
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 +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Packet Type Colors</p>' + typeRows +
@@ -1756,6 +1793,18 @@
// GeoFilter tab init
if (_activeTab === 'geofilter') _initGeoFilterTab(container);
// #1361 Colorblind preset radio — switches preset via MeshCorePresets.applyPreset
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();
}
});
});
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
+1
View File
@@ -102,6 +102,7 @@
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=__BUST__"></script>
<script src="cb-presets.js?v=__BUST__"></script>
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=__BUST__"></script>
<script src="area-filter.js?v=__BUST__"></script>
+69
View File
@@ -3361,6 +3361,75 @@ th.sort-active { color: var(--accent, #60a5fa); }
--mc-mb-unknown: #FF8888;
}
/*
* #1361 Colorblind preset overrides.
*
* Each block overrides --mc-role-* and --mc-mb-* CSS vars when the body
* carries the matching data-cb-preset attribute. cb-presets.js also writes
* these vars inline on documentElement (defense-in-depth so the preset
* takes effect even on pages that ship custom theme overrides), and keeps
* window.ROLE_COLORS in sync for JS consumers (legend, cluster builder).
*
* Palette sources cited in PR body. Authoritative CSS rules here mirror
* the JS PRESETS table in public/cb-presets.js both are the source of
* 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-mb-confirmed: #56F0A0;
--mc-mb-suspected: #FFD966;
--mc-mb-unknown: #FF8888;
}
body[data-cb-preset="deut"] {
/* IBM 5-class deut variant — anchors shifted out of red/green collision. */
--mc-role-repeater: #FE6100;
--mc-role-companion: #648FFF;
--mc-role-room: #785EF0;
--mc-role-sensor: #FFB000;
--mc-role-observer: #DC267F;
--mc-mb-confirmed: #648FFF;
--mc-mb-suspected: #FFB000;
--mc-mb-unknown: #DC267F;
}
body[data-cb-preset="prot"] {
/* Protan: swap repeater anchor for higher-luminance amber. */
--mc-role-repeater: #FFB000;
--mc-role-companion: #648FFF;
--mc-role-room: #785EF0;
--mc-role-sensor: #FE6100;
--mc-role-observer: #DC267F;
--mc-mb-confirmed: #648FFF;
--mc-mb-suspected: #FFB000;
--mc-mb-unknown: #DC267F;
}
body[data-cb-preset="trit"] {
/* Paul Tol muted (B/Y-safe). */
--mc-role-repeater: #CC6677;
--mc-role-companion: #117733;
--mc-role-room: #882255;
--mc-role-sensor: #DDCC77;
--mc-role-observer: #AA4499;
--mc-mb-confirmed: #117733;
--mc-mb-suspected: #DDCC77;
--mc-mb-unknown: #CC6677;
}
body[data-cb-preset="achromat"] {
/* Pure luminance ramp at 20/35/50/70/90% — relies on #1356/#1357 carriers. */
--mc-role-repeater: #333333;
--mc-role-companion: #595959;
--mc-role-room: #808080;
--mc-role-sensor: #b3b3b3;
--mc-role-observer: #e6e6e6;
--mc-mb-confirmed: #b3b3b3;
--mc-mb-suspected: #808080;
--mc-mb-unknown: #595959;
}
.mc-cluster-wrap { background: transparent !important; border: 0 !important; }
.mc-cluster {
width: 48px; height: 48px; border-radius: 50%;
+215
View File
@@ -0,0 +1,215 @@
/**
* #1361 Theme customizer: first-class colorblind-mode presets.
*
* MVP scope (locked):
* - 5 presets: default, deut, prot, trit, achromat
* - Each preset overrides --mc-role-* CSS vars + --mc-mb-* status vars
* - Achromatopsia uses pure luminance ramp (no hue)
* - Persisted to localStorage("meshcore-cb-preset"), survives reload,
* syncs across tabs via the `storage` event.
* - Customizer UI exposes a radio/dropdown to switch preset.
* - WCAG 1.4.3 / 1.4.11 validation helper exists and is correct on
* known reference pairs.
*
* Pure-string + vm.createContext assertions (mirrors test-issue-1356 / 1360
* pattern) so this runs in the JS-unit-tests CI step without a browser.
*
* Stretch goals (live simulation overlay, "Reset to default Wong" button)
* are explicitly DEFERRED and intentionally NOT asserted here.
*/
'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 presetsPath = path.join(__dirname, 'public', 'cb-presets.js');
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
const customSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
const appSrc = fs.readFileSync(path.join(__dirname, 'public', 'app.js'), 'utf8');
const indexSrc = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
console.log('\n=== #1361 A: cb-presets.js module exists and is loadable ===');
assert(fs.existsSync(presetsPath), 'public/cb-presets.js exists');
const presetsSrc = fs.existsSync(presetsPath) ? fs.readFileSync(presetsPath, 'utf8') : '';
// Build a minimal browser-ish sandbox so we can run the IIFE module.
function makeSandbox() {
const root = { style: { _vars: {}, setProperty(k, v) { this._vars[k] = v; }, getPropertyValue(k) { return this._vars[k]; }, removeProperty(k) { delete this._vars[k]; } } };
const body = { _attrs: {}, setAttribute(k, v) { this._attrs[k] = v; }, getAttribute(k) { return this._attrs[k] || null; }, removeAttribute(k) { delete this._attrs[k]; }, dataset: {} };
const listeners = {};
const storage = {
_data: {},
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
setItem(k, v) { this._data[k] = String(v); },
removeItem(k) { delete this._data[k]; },
};
const sandbox = {
window: null,
document: {
documentElement: root,
body: body,
getElementById(id) { return null; },
createElement() { return { setAttribute() {}, appendChild() {}, style: {} }; },
},
localStorage: storage,
console: console,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
Event: function (type) { this.type = type; },
};
sandbox.window = sandbox;
sandbox.document.body = body;
return { sandbox, root, body, storage, listeners };
}
let envOK = false, env;
try {
env = makeSandbox();
vm.createContext(env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
envOK = true;
} catch (e) {
console.error(' ! cb-presets.js failed to load in vm sandbox: ' + e.message);
}
console.log('\n=== #1361 B: MeshCorePresets.list — 5 documented presets ===');
const MCP = envOK && env.sandbox.window && env.sandbox.window.MeshCorePresets;
assert(!!MCP, 'window.MeshCorePresets exists after script load');
assert(MCP && Array.isArray(MCP.list), 'MeshCorePresets.list is an array');
const expectedIds = ['default', 'deut', 'prot', 'trit', 'achromat'];
if (MCP && Array.isArray(MCP.list)) {
assert(MCP.list.length === 5, 'list contains exactly 5 presets (got ' + MCP.list.length + ')');
const ids = MCP.list.map(function (p) { return p.id; });
expectedIds.forEach(function (id) {
assert(ids.indexOf(id) >= 0, 'list contains preset id="' + id + '"');
});
MCP.list.forEach(function (p) {
assert(typeof p.label === 'string' && p.label.length > 0, 'preset "' + p.id + '" has non-empty label');
assert(typeof p.description === 'string' && p.description.length > 0, 'preset "' + p.id + '" has 1-line description');
assert(p.roleColors && typeof p.roleColors === 'object', 'preset "' + p.id + '" has roleColors map');
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
assert(typeof p.roleColors[role] === 'string' && /^#[0-9a-f]{6}$/i.test(p.roleColors[role]),
'preset "' + p.id + '" has hex roleColors.' + role);
});
});
}
console.log('\n=== #1361 C: applyPreset sets body[data-cb-preset] + CSS vars ===');
assert(MCP && typeof MCP.applyPreset === 'function', 'applyPreset is a function');
if (MCP && typeof MCP.applyPreset === 'function') {
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
MCP.applyPreset(id);
assert(env.body.getAttribute('data-cb-preset') === id,
'applyPreset("' + id + '") sets body[data-cb-preset="' + id + '"]');
// Verify the css var for repeater matches the preset's declared color
const declared = MCP.list.find(function (p) { return p.id === id; }).roleColors.repeater;
const got = env.root.style.getPropertyValue('--mc-role-repeater');
assert(got && got.toLowerCase() === declared.toLowerCase(),
'applyPreset("' + id + '") sets --mc-role-repeater=' + declared + ' (got ' + got + ')');
});
}
console.log('\n=== #1361 D: persistence — localStorage("meshcore-cb-preset") ===');
if (MCP) {
MCP.applyPreset('trit');
assert(env.storage.getItem('meshcore-cb-preset') === 'trit',
'applyPreset persists choice to localStorage key "meshcore-cb-preset"');
}
console.log('\n=== #1361 E: re-init from localStorage re-applies preset ===');
// Fresh sandbox with localStorage pre-populated
{
const env2 = makeSandbox();
env2.storage.setItem('meshcore-cb-preset', 'achromat');
vm.createContext(env2.sandbox);
try {
vm.runInContext(presetsSrc, env2.sandbox);
const MCP2 = env2.sandbox.window.MeshCorePresets;
// Module init OR explicit initFromStorage should re-apply
if (MCP2 && typeof MCP2.initFromStorage === 'function') MCP2.initFromStorage();
assert(env2.body.getAttribute('data-cb-preset') === 'achromat',
're-init from localStorage re-applies "achromat" preset to body data-attr');
} catch (e) {
assert(false, 're-init sandbox load failed: ' + e.message);
}
}
console.log('\n=== #1361 F: cross-tab sync via storage event ===');
if (MCP) {
// Dispatch a synthetic storage event for our key
const ev = new env.sandbox.Event('storage');
ev.key = 'meshcore-cb-preset';
ev.newValue = 'prot';
env.sandbox.dispatchEvent(ev);
assert(env.body.getAttribute('data-cb-preset') === 'prot',
'storage event with newValue="prot" updates body[data-cb-preset="prot"]');
}
console.log('\n=== #1361 G: style.css has preset blocks for non-default presets ===');
['deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
const re = new RegExp('body\\[data-cb-preset=["\']' + id + '["\']\\][^{]*\\{[^}]*--mc-role-repeater', 'i');
assert(re.test(styleSrc),
'style.css has body[data-cb-preset="' + id + '"] block overriding --mc-role-repeater');
});
console.log('\n=== #1361 H: customize-v2.js has Colorblind preset selector UI ===');
assert(/data-cv2-cb-preset|cust-cb-preset|colorblind|Colorblind/i.test(customSrc),
'customize-v2.js contains a Colorblind preset selector hook');
assert(/MeshCorePresets|applyPreset|cb-preset/i.test(customSrc),
'customize-v2.js wires the UI to MeshCorePresets.applyPreset');
console.log('\n=== #1361 I: index.html loads cb-presets.js BEFORE app.js ===');
const cbIdx = indexSrc.indexOf('cb-presets.js');
const appIdx = indexSrc.indexOf('app.js?');
assert(cbIdx > 0, 'index.html includes <script src="cb-presets.js?...">');
assert(cbIdx >= 0 && appIdx >= 0 && cbIdx < appIdx,
'cb-presets.js script tag precedes app.js (so app.js can init the preset)');
console.log('\n=== #1361 J: app.js initializes preset on DOMContentLoaded ===');
assert(/MeshCorePresets\s*[\.\&]/.test(appSrc) || /window\.MeshCorePresets/.test(appSrc),
'app.js references window.MeshCorePresets (init wiring)');
assert(/['"]storage['"]/.test(appSrc) && /meshcore-cb-preset/.test(appSrc),
'app.js handles cross-tab storage event for meshcore-cb-preset');
console.log('\n=== #1361 K: WCAG luminance helper — correctness on reference pairs ===');
assert(MCP && MCP.wcag && typeof MCP.wcag.contrast === 'function',
'MeshCorePresets.wcag.contrast(fg, bg) is exposed');
if (MCP && MCP.wcag && typeof MCP.wcag.contrast === 'function') {
const c1 = MCP.wcag.contrast('#000000', '#ffffff');
assert(Math.abs(c1 - 21) < 0.05, 'contrast(black, white) ≈ 21:1 (got ' + c1.toFixed(2) + ')');
const c2 = MCP.wcag.contrast('#ffffff', '#ffffff');
assert(Math.abs(c2 - 1) < 0.001, 'contrast(white, white) === 1:1 (got ' + c2.toFixed(3) + ')');
// Mid-grey #777 vs white ~ 4.48
const c3 = MCP.wcag.contrast('#777777', '#ffffff');
assert(c3 > 4.4 && c3 < 4.7, 'contrast(#777, white) ≈ 4.48 (got ' + c3.toFixed(2) + ')');
}
console.log('\n=== #1361 L: achromat preset is pure luminance (no chroma) ===');
if (MCP) {
const ach = MCP.list.find(function (p) { return p.id === 'achromat'; });
if (ach) {
Object.keys(ach.roleColors).forEach(function (role) {
const hex = ach.roleColors[role];
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
assert(r === g && g === b,
'achromat preset roleColors.' + role + ' is grey (r==g==b, got ' + hex + ')');
});
}
}
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);