mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 17:41:18 +00:00
ddf14d1954
## 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>
356 lines
14 KiB
JavaScript
356 lines
14 KiB
JavaScript
/* 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
|
|
},
|
|
// #1407 — per-role text colors paired with each bg for WCAG 1.4.3 AA
|
|
// (≥4.5:1). Wong defaults all pass with dark text; explicit so the
|
|
// CSS-var pipeline is uniform across presets.
|
|
roleText: {
|
|
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
|
|
sensor: '#1a1a1a', observer: '#1a1a1a'
|
|
},
|
|
mb: {
|
|
confirmed: '#56F0A0',
|
|
suspected: '#FFD966',
|
|
unknown: '#FF8888'
|
|
}
|
|
,
|
|
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
|
},
|
|
{
|
|
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
|
|
},
|
|
// #1407 — IBM 5-class: room (#785EF0) and observer (#DC267F) fail AA
|
|
// with #1a1a1a (3.86 / 3.83). Flip to white where needed.
|
|
roleText: {
|
|
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
|
|
sensor: '#1a1a1a', observer: '#ffffff'
|
|
},
|
|
mb: {
|
|
confirmed: '#648FFF',
|
|
suspected: '#FFB000',
|
|
unknown: '#DC267F'
|
|
}
|
|
,
|
|
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
|
},
|
|
{
|
|
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'
|
|
},
|
|
// Same as deut for room/observer.
|
|
roleText: {
|
|
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
|
|
sensor: '#1a1a1a', observer: '#ffffff'
|
|
},
|
|
mb: {
|
|
confirmed: '#648FFF',
|
|
suspected: '#FFB000',
|
|
unknown: '#DC267F'
|
|
}
|
|
,
|
|
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
|
},
|
|
{
|
|
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
|
|
},
|
|
// #1407 — Tol muted has 3 darker anchors that fail with dark text:
|
|
// companion #117733 vs #1a1a1a = 3.71:1 → use white text
|
|
// room #882255 vs #1a1a1a = 2.41:1 → use white text
|
|
// observer #AA4499 vs #1a1a1a = 4.00:1 → use white text
|
|
// The 2 lighter anchors (rose, sand) keep dark text.
|
|
roleText: {
|
|
repeater: '#1a1a1a', // #CC6677 vs #1a1a1a = 5.73:1 ✓
|
|
companion: '#ffffff', // #117733 vs #fff = 5.66:1 ✓
|
|
room: '#ffffff', // #882255 vs #fff = 8.71:1 ✓
|
|
sensor: '#1a1a1a', // #DDCC77 vs #1a1a1a = 12.98:1 ✓
|
|
observer: '#ffffff' // #AA4499 vs #fff = 5.25:1 ✓
|
|
},
|
|
mb: {
|
|
confirmed: '#117733',
|
|
suspected: '#DDCC77',
|
|
unknown: '#CC6677'
|
|
}
|
|
,
|
|
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
|
},
|
|
{
|
|
id: 'achromat',
|
|
label: 'Achromatopsia (monochrome)',
|
|
description: 'Pure luminance ramp — relies on shape/letter/glyph carriers from #1356/#1357.',
|
|
roleColors: {
|
|
repeater: '#333333', // L=20%
|
|
companion: '#595959', // L=35%
|
|
room: '#808080', // L=50%
|
|
sensor: '#b3b3b3', // L=70%
|
|
observer: '#e6e6e6' // L=90%
|
|
},
|
|
// #1407 — original bug: pill text locked to #1a1a1a → 3 of 5 fail AA.
|
|
// Fix: white text on the 2 darkest grays, dark text on the 2 lightest,
|
|
// pure black for L=50 mid-gray (neither #1a1a1a nor #fff clears 4.5
|
|
// there — black yields 5.32:1).
|
|
// repeater #333 vs #fff = 12.63:1 ✓
|
|
// companion #595959 vs #fff = 7.00:1 ✓
|
|
// room #808080 vs #000 = 5.32:1 ✓ (vs #1a1a1a = 4.41 ✗ / #fff = 3.95 ✗)
|
|
// sensor #b3b3b3 vs #1a1a1a = 8.30:1 ✓
|
|
// observer #e6e6e6 vs #1a1a1a = 13.94:1 ✓
|
|
roleText: {
|
|
repeater: '#ffffff',
|
|
companion: '#ffffff',
|
|
room: '#000000',
|
|
sensor: '#1a1a1a',
|
|
observer: '#1a1a1a'
|
|
},
|
|
mb: {
|
|
confirmed: '#b3b3b3',
|
|
suspected: '#808080',
|
|
unknown: '#595959'
|
|
}
|
|
,
|
|
routeRamp: ['#222222', '#555555', '#888888', '#bbbbbb', '#eeeeee']
|
|
}
|
|
];
|
|
|
|
// ── 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]);
|
|
});
|
|
// #1407 — per-role text-color CSS vars so .mc-pill / badges can pick
|
|
// a foreground that meets WCAG 1.4.3 AA against the role bg.
|
|
var rt = p.roleText || {};
|
|
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
|
style.setProperty('--mc-role-' + role + '-text', rt[role] || '#1a1a1a');
|
|
});
|
|
Object.keys(p.mb).forEach(function (k) {
|
|
style.setProperty('--mc-mb-' + k, p.mb[k]);
|
|
});
|
|
// #1418 — route-view sequence ramp (5 stops). route-view.js reads
|
|
// --mc-rt-ramp-0..4 instead of hardcoded viridis/magma so a CB preset
|
|
// changes the route edge colors live. Achromat uses a luminance ramp.
|
|
var rr = p.routeRamp || ['#440154','#3b528b','#21918c','#5ec962','#fde725'];
|
|
for (var ri = 0; ri < 5; ri++) {
|
|
style.setProperty('--mc-rt-ramp-' + ri, rr[ri] || rr[rr.length - 1]);
|
|
}
|
|
// #1407 — ROLE_COLORS / ROLE_STYLE are now live getters in roles.js
|
|
// that read --mc-role-* directly, so no explicit sync is needed. The
|
|
// pre-#1407 code path kept them in sync as a workaround for the static
|
|
// literal bug; with the getter it's a no-op and removed.
|
|
}
|
|
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) {}
|
|
// #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() {
|
|
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.
|
|
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,
|
|
clearPreset: clearPreset,
|
|
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;
|
|
})();
|