From 101c11b4b3d1b00aecfec363cc477390788f615c Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Mon, 25 May 2026 22:35:42 -0700 Subject: [PATCH] =?UTF-8?q?fix(#1361):=20theme=20customizer=20=E2=80=94=20?= =?UTF-8?q?colorblind=20presets=20[WIP]=20(#1378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/deploy.yml | 1 + public/app.js | 19 +++ public/cb-presets.js | 263 ++++++++++++++++++++++++++++++++++ public/customize-v2.js | 49 +++++++ public/index.html | 1 + public/style.css | 69 +++++++++ test-issue-1361-cb-presets.js | 215 +++++++++++++++++++++++++++ 7 files changed, 617 insertions(+) create mode 100644 public/cb-presets.js create mode 100644 test-issue-1361-cb-presets.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f1ea69cd..57847a3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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: | diff --git a/public/app.js b/public/app.js index 233c20d9..1232f212 100644 --- a/public/app.js +++ b/public/app.js @@ -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 ); 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'); diff --git a/public/cb-presets.js b/public/cb-presets.js new file mode 100644 index 00000000..bf4bf705 --- /dev/null +++ b/public/cb-presets.js @@ -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; +})(); diff --git a/public/customize-v2.js b/public/customize-v2.js index 703048fc..d23c7ca7 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -1123,6 +1123,42 @@ ''; } + // โ”€โ”€ #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 ''; + }).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.

' + + '
' + options + '
' + + '
'; + } + + 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 '
โš  WCAG 1.4.11: ' + esc(names) + ' below 3:1 vs ' + (dark ? 'dark' : 'light') + ' tiles
'; + } + function _renderNodes() { var eff = _getEffective(); var server = _getServer(); @@ -1160,6 +1196,7 @@ var liveHeatPct = Math.round(liveHeatOpacity * 100); return '
' + + _renderColorblindPresetSelector() + '

Node Role Colors

' + rows + '
' + '

Packet Type Colors

' + 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 () { diff --git a/public/index.html b/public/index.html index f0832aea..79024f3a 100644 --- a/public/index.html +++ b/public/index.html @@ -102,6 +102,7 @@ + diff --git a/public/style.css b/public/style.css index eb9c5875..67382e20 100644 --- a/public/style.css +++ b/public/style.css @@ -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%; diff --git a/test-issue-1361-cb-presets.js b/test-issue-1361-cb-presets.js new file mode 100644 index 00000000..f9e5cb17 --- /dev/null +++ b/test-issue-1361-cb-presets.js @@ -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