mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-16 20:21:51 +00:00
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:
co-authored by
openclaw-bot
parent
0b35c7eef3
commit
101c11b4b3
@@ -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: |
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user