Files
meshcore-analyzer/public/customize-v2.js
T
89eade6e7b M6: emoji → Phosphor — final sweep, lint gate, carry-forwards (#1648) (#1654)
Red commit: fe7468d473 (CI run: will
appear in this PR's Checks tab — emoji lint test fails on the red
commit, passes on green)

**Fixes #1648.**

Closes the 6-milestone emoji→Phosphor migration started in #1649.

## Sweep results (real UI icons swapped this PR)

| File:line | Before | After |
|---|---|---|
| `public/index.html:140` |  | `ph-star-fill` |
| `public/mobile-page-actions.js:154` |  | `ph-star-fill` |
| `public/geofilter-builder.html:76` | ⬇ | `ph-download-simple` |
| `public/analytics.js:2103` | ⏱️ | `ph-clock` |
| `public/analytics.js:2288` |  | `ph-clock` |
| `public/analytics.js:4080` |  | `ph-clock` |
| `public/nodes.js:1066` |  | `ph-clock` |
| `public/observer-detail.js:284` |  | `ph-clock` |
| `public/channel-qr.js:133` | 📋 | `ph-clipboard-text` |
| `public/channel-qr.js:139` | ✓ | `ph-check` |
| `public/packets.js:1472` | ⏸ | `ph-pause` |

## Carry-forwards addressed

- **M5 CDP — live `.cust-emoji-preview` re-render** —
`public/customize-v2.js:2434` wires `renderConfigGlyph` to the `input`
event so previews update without Save+reload. (commit `9e698a04`)
- **M5 CDP — `.modal-close` 44×44 mobile** — `public/style.css` adds a
≤640px breakpoint bumping both `.modal-close` and `.ch-modal-close` to
WCAG-minimum hit targets. (commit `9e698a04`)
- **M4 CDP — route-hop fallback color** — `public/route-render.js` now
reads `var(--status-info)` (new token added to `:root` and dark-mode
blocks in `style.css`) instead of baked `#3b82f6`. (commit `9e698a04`)
- M2 carry-forward set ( favStar / ▾ More / ⚠️ clock / 🌱 welcome
cards) verified already addressed by re-running M3 emoji scan — all
green.

## Lint gate (M6 headline)

- Test: `test-issue-1648-m6-final-sweep.js` — full repo scan across
`public/**.{js,html,css}` and `cmd/(server|ingestor|decrypt)/*.go`.
- Self-test: `test-issue-1648-m6-lint-self.js` — exercises the lint
engine + anti-tautology probe.
- Allowlist: `tests/emoji-allowlist.txt`. Format: `path` (glob),
`path:line`, `path:line:U+XXXX`, or `/regex/`. Add intentional emojis
here with a `# why` comment.
- Wired into `test-all.sh` alongside M1/M2/M3 scans.

## routes.go smoke check

Server-side defaults in `cmd/server/routes.go:567-574` confirmed
`ph:bluetooth`/`ph:radio`/`ph:broadcast`/`ph:repeat` (M5 landed).
Operator-customized configs on staging/prod still carry their legacy
emoji overrides — per M5 design call those are preserved and NOT touched
by this PR.

## PR closing list

Fixes #1648. M1 #1649 , M2 #1650 , M3 #1651 , M4 #1652 , M5 #1653 ,
this PR .

---------

Co-authored-by: Bot <bot@corescope>
2026-06-11 05:44:37 -07:00

2861 lines
151 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === CoreScope — customize-v2.js === */
/* Event-driven customizer with single-key localStorage, delta-based overrides, and migration from v1.
Spec: docs/specs/customizer-rework.md | Issue: #502 */
'use strict';
(function () {
// ── Constants ──
var DEFAULT_HOME = {
heroTitle: 'CoreScope',
heroSubtitle: 'Real-time MeshCore LoRa mesh network analyzer',
// #1648 M5: defaults use 'ph:<name>' sprite tokens. Back-compat: the
// render path (renderConfigGlyph) ALSO accepts legacy emoji strings
// for operators with stored config values.
steps: [
{ emoji: 'ph:bluetooth', title: 'Connect via Bluetooth', description: 'Flash **BLE companion** firmware from [MeshCore Flasher](https://flasher.meshcore.io/).\n- Screenless devices: default PIN `123456`\n- Screen devices: random PIN shown on display\n- If pairing fails: forget device, reboot, re-pair' },
{ emoji: 'ph:radio', title: 'Set the right frequency preset', description: '**US Recommended:**\n`910.525 MHz · BW 62.5 kHz · SF 7 · CR 5`\nSelect **"US Recommended"** in the app or flasher.' },
{ emoji: 'ph:broadcast', title: 'Advertise yourself', description: 'Tap the signal icon → **Flood** to broadcast your node to the mesh. Companions only advert when you trigger it manually.' },
{ emoji: 'ph:repeat', title: 'Check "Heard N repeats"', description: '- **"Sent"** = transmitted, no confirmation\n- **"Heard 0 repeats"** = no repeater picked it up\n- **"Heard 1+ repeats"** = you\'re on the mesh!' }
],
footerLinks: [
{ label: 'ph:package Packets', url: '#/packets' },
{ label: 'ph:map-trifold Network Map', url: '#/map' }
]
};
var STORAGE_KEY = 'cs-theme-overrides';
var DARK_MODE_KEY = 'meshcore-theme';
var LEGACY_KEYS = [
'meshcore-user-theme',
'meshcore-timestamp-mode',
'meshcore-timestamp-timezone',
'meshcore-timestamp-format',
'meshcore-timestamp-custom-format',
'meshcore-heatmap-opacity',
'meshcore-live-heatmap-opacity'
];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit', 'favorites', 'myNodes', 'markerStroke'];
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'markerStroke'];
var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity'];
var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto'];
// CSS variable mapping (theme key → CSS custom property)
var THEME_CSS_MAP = {
accent: '--accent', accentHover: '--accent-hover',
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
navActiveBg: '--nav-active-bg',
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
sectionBg: '--section-bg',
cardBg: '--card-bg', contentBg: '--content-bg', detailBg: '--detail-bg',
inputBg: '--input-bg', rowStripe: '--row-stripe', rowHover: '--row-hover', selectedBg: '--selected-bg',
font: '--font', mono: '--mono'
};
var THEME_COLOR_KEYS = Object.keys(THEME_CSS_MAP).filter(function (k) { return k !== 'font' && k !== 'mono'; });
// ── Brand logo swap helper (PR #1137) ──
// The default navbar brand logo is an inline <svg class="brand-logo"> so it
// inherits page CSS vars (--logo-text / --logo-accent / etc.). When an
// operator overrides branding.logoUrl in the customizer they expect a
// remote image — swap the inline <svg> for an <img>. Going back to the
// default URL or clearing the override swaps the <img> back to the inline
// <svg>. Layout dimensions (width=111 height=36) are preserved either way.
function _setBrandLogoUrl(url, alt) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (url) {
if (node.tagName.toLowerCase() === 'img') {
node.setAttribute('src', url);
if (alt != null) node.setAttribute('alt', alt);
return;
}
// swap inline <svg> → <img>
var img = document.createElement('img');
img.className = 'brand-logo';
img.setAttribute('src', url);
img.setAttribute('alt', alt || node.getAttribute('aria-label') || 'Brand');
// #1450 — DO NOT set width/height attrs. CSS img.brand-logo handles
// sizing (height:36px, width:auto, max-width cap) so the operator's
// natural image aspect ratio is preserved instead of being squished
// into the default SVG's 125x36 pill box.
node.parentNode.replaceChild(img, node);
} else {
if (node.tagName.toLowerCase() !== 'img') {
if (alt != null) node.setAttribute('aria-label', alt);
return;
}
// swap <img> → inline <svg> by clearing the src; here we just keep the
// <img> in place because we don't have the SVG markup at runtime
// (it lives in index.html). The next page reload restores the inline
// SVG. Setting src to the default URL is a graceful intermediate.
node.setAttribute('src', 'img/corescope-logo.svg');
if (alt != null) node.setAttribute('alt', alt);
}
}
function _setBrandAlt(alt) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (node.tagName.toLowerCase() === 'img') node.setAttribute('alt', alt);
else node.setAttribute('aria-label', alt);
var brandLink = document.querySelector('.nav-brand');
if (brandLink) brandLink.setAttribute('aria-label', alt + ' home');
}
// ── Brand home-link href swap (#1518) ──
// Apply a validated branding.homeUrl to <a class="nav-brand">[href]. When
// url is empty / invalid, restore the in-app default '#/' so operators can
// clear the override and immediately see the SPA route restored.
// Bottom-nav house glyph stays in-app — do NOT touch public/bottom-nav.js here, that
// preserves the SPA back-stack on mobile (per #1518 triage).
function _setBrandHomeUrl(url) {
var brandLink = document.querySelector('a.nav-brand');
if (!brandLink) return;
if (url && isValidHomeUrl(url)) {
brandLink.setAttribute('href', url);
} else {
brandLink.setAttribute('href', '#/');
}
}
// ── Presets (copied from v1 customize.js) ──
var PRESETS = {
default: {
name: 'Default', desc: 'MeshCore blue',
preview: ['#4a9eff', '#0f0f23', '#f4f5f7', '#1a1a2e', '#22c55e'],
theme: {
accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', navActiveBg: 'rgba(74,158,255,0.15)', textMuted: '#5b6370', border: '#e2e5ea',
surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
surface3: '#ffffff', sectionBg: '#eef2ff'
},
themeDark: {
accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', navActiveBg: 'rgba(74,158,255,0.18)', textMuted: '#a8b8cc', border: '#334155',
surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
surface3: '#2d2d50', sectionBg: '#1e1e34'
}
},
ocean: {
name: 'Ocean', desc: 'Deep blues & teals',
preview: ['#0077b6', '#03045e', '#f0f7fa', '#48cae4', '#15803d'],
theme: {
accent: '#0077b6', navBg: '#03045e', navText: '#ffffff', background: '#f0f7fa', text: '#0a1628',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#0096d6', navBg2: '#023e8a', navTextMuted: '#90caf9', textMuted: '#4a6580', border: '#c8dce8',
surface1: '#ffffff', surface2: '#e8f4f8', cardBg: '#ffffff', contentBg: '#f0f7fa',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f5fafd', rowHover: '#e0f0f8', selectedBg: '#bde0fe',
surface3: '#f5fafd', sectionBg: '#e0f0f8'
},
themeDark: {
accent: '#48cae4', navBg: '#03045e', navText: '#ffffff', background: '#0a1929', text: '#e0e7ef',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#76d7ea', navBg2: '#012a4a', navTextMuted: '#90caf9', textMuted: '#8eafc4', border: '#1e3a5f',
surface1: '#0d2137', surface2: '#122d4a', cardBg: '#0d2137', contentBg: '#0a1929',
detailBg: '#122d4a', inputBg: '#0d2137', rowStripe: '#0d2137', rowHover: '#153450', selectedBg: '#1a4570',
surface3: '#153450', sectionBg: '#0d2137'
}
},
forest: {
name: 'Forest', desc: 'Greens & earth tones',
preview: ['#2d6a4f', '#1b3a2d', '#f2f7f4', '#52b788', '#15803d'],
theme: {
accent: '#2d6a4f', navBg: '#1b3a2d', navText: '#ffffff', background: '#f2f7f4', text: '#1a2e24',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#40916c', navBg2: '#2d6a4f', navTextMuted: '#a3c4b5', textMuted: '#557063', border: '#c8dcd2',
surface1: '#ffffff', surface2: '#e8f0eb', cardBg: '#ffffff', contentBg: '#f2f7f4',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f5faf7', rowHover: '#e4f0e8', selectedBg: '#c2e0cc',
surface3: '#f5faf7', sectionBg: '#e4f0e8'
},
themeDark: {
accent: '#52b788', navBg: '#1b3a2d', navText: '#ffffff', background: '#0d1f17', text: '#d8e8df',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#74c69d', navBg2: '#14532d', navTextMuted: '#86b89a', textMuted: '#8aac9a', border: '#2d4a3a',
surface1: '#162e23', surface2: '#1d3a2d', cardBg: '#162e23', contentBg: '#0d1f17',
detailBg: '#1d3a2d', inputBg: '#162e23', rowStripe: '#162e23', rowHover: '#1f4030', selectedBg: '#265940',
surface3: '#1f4030', sectionBg: '#162e23'
}
},
sunset: {
name: 'Sunset', desc: 'Warm oranges & ambers',
preview: ['#c2410c', '#431407', '#fef7f2', '#fb923c', '#dc2626'],
theme: {
accent: '#c2410c', navBg: '#431407', navText: '#ffffff', background: '#fef7f2', text: '#1c0f06',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#ea580c', navBg2: '#7c2d12', navTextMuted: '#fdba74', textMuted: '#6b5344', border: '#e8d5c8',
surface1: '#ffffff', surface2: '#fef0e6', cardBg: '#ffffff', contentBg: '#fef7f2',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#fefaf7', rowHover: '#fef0e0', selectedBg: '#fed7aa',
surface3: '#fefaf7', sectionBg: '#fef0e0'
},
themeDark: {
accent: '#fb923c', navBg: '#431407', navText: '#ffffff', background: '#1a0f08', text: '#f0ddd0',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#fdba74', navBg2: '#7c2d12', navTextMuted: '#c2855a', textMuted: '#b09080', border: '#4a2a18',
surface1: '#261a10', surface2: '#332214', cardBg: '#261a10', contentBg: '#1a0f08',
detailBg: '#332214', inputBg: '#261a10', rowStripe: '#261a10', rowHover: '#3a2818', selectedBg: '#5c3518',
surface3: '#3a2818', sectionBg: '#261a10'
}
},
mono: {
name: 'Monochrome', desc: 'Pure grays, no color',
preview: ['#525252', '#171717', '#f5f5f5', '#a3a3a3', '#737373'],
theme: {
accent: '#525252', navBg: '#171717', navText: '#ffffff', background: '#f5f5f5', text: '#171717',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#737373', navBg2: '#262626', navTextMuted: '#a3a3a3', textMuted: '#525252', border: '#d4d4d4',
surface1: '#ffffff', surface2: '#fafafa', cardBg: '#ffffff', contentBg: '#f5f5f5',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#fafafa', rowHover: '#efefef', selectedBg: '#e5e5e5',
surface3: '#fafafa', sectionBg: '#efefef'
},
themeDark: {
accent: '#a3a3a3', navBg: '#171717', navText: '#ffffff', background: '#0a0a0a', text: '#e5e5e5',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#d4d4d4', navBg2: '#1a1a1a', navTextMuted: '#737373', textMuted: '#a3a3a3', border: '#333333',
surface1: '#171717', surface2: '#1f1f1f', cardBg: '#171717', contentBg: '#0a0a0a',
detailBg: '#1f1f1f', inputBg: '#171717', rowStripe: '#141414', rowHover: '#222222', selectedBg: '#2a2a2a',
surface3: '#222222', sectionBg: '#171717'
}
},
highContrast: {
name: 'High Contrast', desc: 'WCAG AAA, max readability',
preview: ['#0050a0', '#000000', '#ffffff', '#66b3ff', '#006400'],
theme: {
accent: '#0050a0', navBg: '#000000', navText: '#ffffff', background: '#ffffff', text: '#000000',
statusGreen: '#006400', statusYellow: '#7a5900', statusRed: '#b30000',
accentHover: '#0068cc', navBg2: '#1a1a1a', navTextMuted: '#e0e0e0', textMuted: '#333333', border: '#000000',
surface1: '#ffffff', surface2: '#f0f0f0', cardBg: '#ffffff', contentBg: '#ffffff',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f0f0f0', rowHover: '#e0e8f5', selectedBg: '#cce0ff',
surface3: '#f0f0f0', sectionBg: '#e0e8f5'
},
themeDark: {
accent: '#66b3ff', navBg: '#000000', navText: '#ffffff', background: '#000000', text: '#ffffff',
statusGreen: '#66ff66', statusYellow: '#ffff00', statusRed: '#ff6666',
accentHover: '#99ccff', navBg2: '#0a0a0a', navTextMuted: '#cccccc', textMuted: '#cccccc', border: '#ffffff',
surface1: '#111111', surface2: '#1a1a1a', cardBg: '#111111', contentBg: '#000000',
detailBg: '#1a1a1a', inputBg: '#111111', rowStripe: '#0d0d0d', rowHover: '#1a2a3a', selectedBg: '#003366',
surface3: '#1a2a3a', sectionBg: '#111111'
},
nodeColors: { repeater: '#ff0000', companion: '#0066ff', room: '#009900', sensor: '#cc8800', observer: '#9933ff' },
typeColors: {
ADVERT: '#009900', GRP_TXT: '#0066ff', TXT_MSG: '#cc8800', ACK: '#666666',
REQUEST: '#9933ff', RESPONSE: '#0099cc', TRACE: '#cc0066', PATH: '#009999', ANON_REQ: '#cc3355'
}
},
midnight: {
name: 'Midnight', desc: 'Deep purples & indigos',
preview: ['#7c3aed', '#1e1045', '#f5f3ff', '#a78bfa', '#15803d'],
theme: {
accent: '#7c3aed', navBg: '#1e1045', navText: '#ffffff', background: '#f5f3ff', text: '#1a1040',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#8b5cf6', navBg2: '#2e1065', navTextMuted: '#c4b5fd', textMuted: '#5b5075', border: '#d8d0e8',
surface1: '#ffffff', surface2: '#ede9fe', cardBg: '#ffffff', contentBg: '#f5f3ff',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#faf8ff', rowHover: '#ede9fe', selectedBg: '#ddd6fe',
surface3: '#faf8ff', sectionBg: '#ede9fe'
},
themeDark: {
accent: '#a78bfa', navBg: '#1e1045', navText: '#ffffff', background: '#0f0a24', text: '#e2ddf0',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#c4b5fd', navBg2: '#2e1065', navTextMuted: '#9d8abf', textMuted: '#9a90b0', border: '#352a55',
surface1: '#1a1338', surface2: '#221a48', cardBg: '#1a1338', contentBg: '#0f0a24',
detailBg: '#221a48', inputBg: '#1a1338', rowStripe: '#1a1338', rowHover: '#2a2050', selectedBg: '#352a6a',
surface3: '#2a2050', sectionBg: '#1a1338'
}
},
ember: {
name: 'Ember', desc: 'Warm red/orange, cyberpunk',
preview: ['#dc2626', '#1a0a0a', '#faf5f5', '#ef4444', '#15803d'],
theme: {
accent: '#dc2626', navBg: '#1a0a0a', navText: '#ffffff', background: '#faf5f5', text: '#1a0a0a',
statusGreen: '#15803d', statusYellow: '#a16207', statusRed: '#dc2626',
accentHover: '#ef4444', navBg2: '#2a1010', navTextMuted: '#f0a0a0', textMuted: '#6b4444', border: '#e0c8c8',
surface1: '#ffffff', surface2: '#faf0f0', cardBg: '#ffffff', contentBg: '#faf5f5',
detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#fdf8f8', rowHover: '#fce8e8', selectedBg: '#fecaca',
surface3: '#fdf8f8', sectionBg: '#fce8e8'
},
themeDark: {
accent: '#ef4444', navBg: '#1a0505', navText: '#ffffff', background: '#0d0505', text: '#f0dada',
statusGreen: '#4ade80', statusYellow: '#facc15', statusRed: '#f87171',
accentHover: '#f87171', navBg2: '#2a0a0a', navTextMuted: '#c07070', textMuted: '#b09090', border: '#4a2020',
surface1: '#1a0d0d', surface2: '#261414', cardBg: '#1a0d0d', contentBg: '#0d0505',
detailBg: '#261414', inputBg: '#1a0d0d', rowStripe: '#1a0d0d', rowHover: '#301818', selectedBg: '#4a1a1a',
surface3: '#301818', sectionBg: '#1a0d0d'
}
}
};
// ── Labels, hints, emojis (carried from v1) ──
var THEME_LABELS = {
accent: 'Brand Color', accentHover: 'Accent Hover',
navBg: 'Navigation', navBg2: 'Nav Gradient End', navText: 'Nav Text', navTextMuted: 'Nav Muted Text',
navActiveBg: 'Nav Active Pill',
background: 'Background', text: 'Text', textMuted: 'Muted Text', border: 'Borders',
statusGreen: 'Healthy', statusYellow: 'Warning', statusRed: 'Error',
surface1: 'Cards', surface2: 'Panels', surface3: 'Tertiary Surface', sectionBg: 'Section Header', cardBg: 'Card Fill', contentBg: 'Content Area',
detailBg: 'Detail Panels', inputBg: 'Inputs', rowStripe: 'Table Stripe',
rowHover: 'Row Hover', selectedBg: 'Selected',
font: 'Body Font', mono: 'Mono Font'
};
var THEME_HINTS = {
accent: 'Buttons, links, active tabs, badges, charts — your primary brand color',
navBg: 'Top navigation bar', navText: 'Nav bar text, links, brand name, buttons',
background: 'Main page background', text: 'Primary text — muted text auto-derives',
statusGreen: 'Healthy/online indicators', statusYellow: 'Warning/degraded + hop conflicts',
statusRed: 'Error/offline indicators', accentHover: 'Hover state for accent elements',
navBg2: 'Darker end of nav gradient', navTextMuted: 'Inactive nav links, nav buttons',
navActiveBg: 'Background of the active nav link (current page pill)',
textMuted: 'Labels, timestamps, secondary text', border: 'Dividers, table borders, card borders',
surface1: 'Card and panel backgrounds', surface2: 'Nested surfaces, secondary panels',
surface3: 'Tertiary surfaces, hover accents', sectionBg: 'Section header backgrounds',
cardBg: 'Detail panels, modals', contentBg: 'Content area behind cards',
detailBg: 'Modal, packet detail, side panels', inputBg: 'Text inputs, dropdowns',
rowStripe: 'Alternating table rows', rowHover: 'Table row hover', selectedBg: 'Selected/active rows',
font: 'System font stack for body text', mono: 'Monospace font for hex, code, hashes'
};
var NODE_LABELS = { repeater: 'Repeater', companion: 'Companion', room: 'Room Server', sensor: 'Sensor', observer: 'Observer' };
var NODE_HINTS = {
repeater: 'Infrastructure nodes that relay packets', companion: 'End-user devices',
room: 'Room/chat server nodes', sensor: 'Sensor/telemetry nodes', observer: 'MQTT observer stations'
};
var NODE_EMOJI = { repeater: 'ph:diamond', companion: 'ph:circle-fill', room: 'ph:square-fill', sensor: 'ph:triangle', observer: 'ph:star-fill' };
var TYPE_LABELS = {
ADVERT: 'Advertisement', GRP_TXT: 'Channel Message', TXT_MSG: 'Direct Message', ACK: 'Acknowledgment',
REQUEST: 'Request', RESPONSE: 'Response', TRACE: 'Traceroute', PATH: 'Path', ANON_REQ: 'Anonymous Request'
};
var TYPE_HINTS = {
ADVERT: 'Node advertisements', GRP_TXT: 'Group/channel messages', TXT_MSG: 'Direct messages',
ACK: 'Acknowledgments', REQUEST: 'Requests', RESPONSE: 'Responses',
TRACE: 'Traceroute', PATH: 'Path packets', ANON_REQ: 'Encrypted anonymous requests'
};
// #1648 M5: defaults use 'ph:<name>' tokens. renderConfigGlyph() below
// accepts both new ph tokens AND legacy emoji strings (back-compat).
var TYPE_EMOJI = {
ADVERT: 'ph:broadcast', GRP_TXT: 'ph:chat-circle', TXT_MSG: 'ph:envelope', ACK: 'ph:check', REQUEST: 'ph:question',
RESPONSE: 'ph:envelope-simple', TRACE: 'ph:magnifying-glass', PATH: 'ph:path', ANON_REQ: 'ph:lock'
};
// renderConfigGlyph(value): renders a Phosphor sprite for 'ph:<name>' OR
// returns the operator's stored string (escaped) for legacy emoji/text.
// This is #1648 M5 design call #1: never override operator-stored values.
function renderConfigGlyph(value) {
if (value == null) return '';
var s = String(value);
var m = s.match(/^ph:([a-z][a-z0-9-]+)$/);
if (m) return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-' + m[1] + '"/></svg>';
return esc(s); // EMOJI-OK-LEGACY-RENDER
}
function renderConfigLabel(label) {
if (label == null) return '';
var s = String(label);
var m = s.match(/^(ph:[a-z][a-z0-9-]+)\s+(.*)$/);
if (m) return renderConfigGlyph(m[1]) + ' ' + esc(m[2]);
return esc(s); // EMOJI-OK-LEGACY-RENDER
}
var BASIC_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed'];
var ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'navActiveBg', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
var FONT_KEYS = ['font', 'mono'];
// ── Validation helpers ──
var COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
var CSS_FUNC_RE = /^(?:rgb|rgba|hsl|hsla)\s*\(.*\)$/i;
// Basic list of CSS named colors (subset — covers common ones)
var NAMED_COLORS = 'aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen,transparent,currentcolor,inherit'.split(',');
var NAMED_SET = null;
function isValidColor(val) {
if (typeof val !== 'string') return false;
if (COLOR_RE.test(val)) return true;
if (CSS_FUNC_RE.test(val)) return true;
if (!NAMED_SET) { NAMED_SET = new Set(NAMED_COLORS); }
return NAMED_SET.has(val.toLowerCase().trim());
}
function isValidOpacity(val) {
return typeof val === 'number' && isFinite(val) && val >= 0 && val <= 1;
}
// ── isValidHomeUrl (#1518) ──
// Branding.homeUrl is rendered into <a class="nav-brand">[href], so it MUST
// reject any scheme that can execute script (javascript:, data:, vbscript:,
// etc.). Whitelist only:
// - http://... (absolute, plain http — operators may need it for intranet)
// - https://... (absolute, https)
// - #... (app-relative hash route, e.g. "#/", "#/home")
// Empty / whitespace / non-string → invalid (caller falls through to default).
//
// Defence-in-depth: also strip ALL whitespace before scheme-sniffing so a
// payload like "java\tscript:alert(1)" — which some lenient URL parsers
// collapse to javascript: — is rejected here too.
function isValidHomeUrl(val) {
if (typeof val !== 'string') return false;
var trimmed = val.replace(/^\s+/, '');
if (trimmed.length === 0) return false;
// Hash routes (app-internal) are always safe.
if (trimmed.charAt(0) === '#') return true;
// For scheme detection, collapse interior whitespace inside the scheme
// portion (chars before first ':'). HTML attribute parsing has been known
// to drop these, turning "java\tscript:" into "javascript:" at click time.
var colonIdx = trimmed.indexOf(':');
if (colonIdx === -1) return false;
var schemeRaw = trimmed.slice(0, colonIdx);
var schemeClean = schemeRaw.replace(/\s+/g, '').toLowerCase();
if (schemeClean !== 'http' && schemeClean !== 'https') return false;
// Must look like a real absolute URL: scheme://...
return /^https?:\/\//i.test(trimmed);
}
var TS_ENUMS = {
defaultMode: ['ago', 'absolute'],
timezone: ['local', 'utc'],
formatPreset: ['iso', 'iso-seconds', 'locale']
};
// ── Core data functions (exported for testing via window._customizerV2) ──
/** @type {object|null} server defaults, set during init */
var _serverDefaults = null;
var _initDone = false;
var _saveStatus = 'saved'; // 'saved' | 'saving' | 'error'
var _writeTimer = null;
function readOverrides() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
var parsed = (raw != null) ? JSON.parse(raw) : {};
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
// Include favorites and claimed nodes from their own localStorage keys
try {
var favs = JSON.parse(localStorage.getItem('meshcore-favorites') || '[]');
if (Array.isArray(favs) && favs.length) parsed.favorites = favs;
} catch (e) { /* ignore */ }
try {
var myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
if (Array.isArray(myNodes) && myNodes.length) parsed.myNodes = myNodes;
} catch (e) { /* ignore */ }
return parsed;
} catch (e) {
return {};
}
}
function _validateDelta(delta) {
// Validate color values in theme/themeDark/nodeColors/typeColors, numeric values, timestamp enums.
// Returns a cleaned copy (invalid values removed with console.warn).
var clean = {};
var colorSections = ['theme', 'themeDark', 'nodeColors', 'typeColors'];
for (var key in delta) {
if (!delta.hasOwnProperty(key)) continue;
if (colorSections.indexOf(key) !== -1 && typeof delta[key] === 'object' && delta[key] !== null) {
var section = {};
var src = delta[key];
for (var sk in src) {
if (!src.hasOwnProperty(sk)) continue;
// font/mono are not colors
if ((key === 'theme' || key === 'themeDark') && (sk === 'font' || sk === 'mono')) {
if (typeof src[sk] === 'string') section[sk] = src[sk];
continue;
}
if (typeof src[sk] === 'string' && isValidColor(src[sk])) {
section[sk] = src[sk];
} else {
console.warn('[customizer-v2] Invalid color value rejected:', key + '.' + sk, src[sk]);
}
}
if (Object.keys(section).length) clean[key] = section;
} else if (key === 'heatmapOpacity' || key === 'liveHeatmapOpacity') {
var numVal = typeof delta[key] === 'string' ? parseFloat(delta[key]) : delta[key];
if (isValidOpacity(numVal)) {
clean[key] = numVal;
} else {
console.warn('[customizer-v2] Invalid opacity value rejected:', key, delta[key]);
}
} else if (key === 'markerStroke' && typeof delta[key] === 'object' && delta[key] !== null) {
// #1488 — markerStroke { color, width, opacity }
var msIn = delta[key];
var msOut = {};
if (typeof msIn.color === 'string' && isValidColor(msIn.color)) {
msOut.color = msIn.color;
} else if (msIn.color != null) {
console.warn('[customizer-v2] Invalid markerStroke.color rejected:', msIn.color);
}
var msW = typeof msIn.width === 'string' ? parseFloat(msIn.width) : msIn.width;
if (msIn.width != null) {
if (isFinite(msW) && msW >= 0 && msW <= 10) {
msOut.width = msW;
} else {
console.warn('[customizer-v2] Invalid markerStroke.width rejected:', msIn.width);
}
}
var msO = typeof msIn.opacity === 'string' ? parseFloat(msIn.opacity) : msIn.opacity;
if (msIn.opacity != null) {
if (isValidOpacity(msO)) {
msOut.opacity = msO;
} else {
console.warn('[customizer-v2] Invalid markerStroke.opacity rejected:', msIn.opacity);
}
}
if (Object.keys(msOut).length) clean[key] = msOut;
} else if (key === 'timestamps' && typeof delta[key] === 'object' && delta[key] !== null) {
var ts = {};
var tsrc = delta[key];
for (var tk in tsrc) {
if (!tsrc.hasOwnProperty(tk)) continue;
if (TS_ENUMS[tk]) {
if (TS_ENUMS[tk].indexOf(tsrc[tk]) !== -1) {
ts[tk] = tsrc[tk];
} else {
console.warn('[customizer-v2] Invalid timestamp enum rejected:', tk, tsrc[tk]);
}
} else if (tk === 'customFormat') {
if (typeof tsrc[tk] === 'string') ts[tk] = tsrc[tk];
} else {
ts[tk] = tsrc[tk]; // unknown timestamp keys pass through
}
}
if (Object.keys(ts).length) clean[key] = ts;
} else if (key === 'branding' || key === 'home') {
// Pass through as-is (object shape)
if (typeof delta[key] === 'object' && delta[key] !== null) {
clean[key] = JSON.parse(JSON.stringify(delta[key]));
}
} else {
// Unknown key — pass through for forward compatibility
clean[key] = delta[key];
}
}
return clean;
}
function writeOverrides(delta) {
if (delta == null || typeof delta !== 'object') return;
// Extract favorites/myNodes and store in their own localStorage keys
if (Array.isArray(delta.favorites)) {
try { localStorage.setItem('meshcore-favorites', JSON.stringify(delta.favorites)); } catch (e) { /* ignore */ }
}
if (Array.isArray(delta.myNodes)) {
try { localStorage.setItem('meshcore-my-nodes', JSON.stringify(delta.myNodes)); } catch (e) { /* ignore */ }
}
// Build theme-only delta (without favorites/myNodes)
var themeDelta = {};
for (var k in delta) {
if (delta.hasOwnProperty(k) && k !== 'favorites' && k !== 'myNodes') {
themeDelta[k] = delta[k];
}
}
// If empty, remove key entirely
var keys = Object.keys(themeDelta);
if (keys.length === 0) {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
_updateSaveStatus('saved');
return;
}
var validated = _validateDelta(themeDelta);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(validated));
_updateSaveStatus('saved');
} catch (e) {
_updateSaveStatus('error');
console.error('[customizer-v2] localStorage quota exceeded:', e);
// Show visible warning
_showQuotaWarning();
}
}
function computeEffective(serverConfig, userOverrides) {
var effective = JSON.parse(JSON.stringify(serverConfig || {}));
// Defense-in-depth: if server returned home:null, use built-in defaults
if (!effective.home || typeof effective.home !== 'object') {
effective.home = JSON.parse(JSON.stringify(DEFAULT_HOME));
}
if (!userOverrides || typeof userOverrides !== 'object') return effective;
for (var key in userOverrides) {
if (!userOverrides.hasOwnProperty(key)) continue;
var uv = userOverrides[key];
if (uv != null && typeof uv === 'object' && !Array.isArray(uv)) {
// Object section — shallow merge
if (!effective[key] || typeof effective[key] !== 'object') effective[key] = {};
var val = userOverrides[key];
for (var sk in val) {
if (val.hasOwnProperty(sk)) {
if (Array.isArray(val[sk])) {
effective[key][sk] = JSON.parse(JSON.stringify(val[sk])); // array: full replace
} else {
effective[key][sk] = val[sk];
}
}
}
} else if (Array.isArray(uv)) {
// Array — full replacement
effective[key] = JSON.parse(JSON.stringify(uv));
} else {
// Scalar — direct replacement
effective[key] = uv;
}
}
return effective;
}
function isDarkMode() {
var attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark') return true;
if (attr === 'light') return false;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyCSS(effectiveConfig, userOverrides) {
var dark = isDarkMode();
var themeSection = dark
? Object.assign({}, effectiveConfig.theme || {}, effectiveConfig.themeDark || {})
: (effectiveConfig.theme || {});
var root = document.documentElement.style;
// Apply theme color/font CSS variables
for (var key in THEME_CSS_MAP) {
if (themeSection[key]) {
root.setProperty(THEME_CSS_MAP[key], themeSection[key]);
}
}
// Logo brand colors mirror --accent / --accent-hover ONLY when an
// operator has actually overridden them via the customizer. We check
// userOverrides (not the merged effective config), so the server-default
// accent (#4a9eff) does NOT clobber the sage/teal :root brand defaults
// out-of-the-box. When an operator picks a theme, customizer writes the
// override to localStorage, the override flows through here, and the
// wordmark recolors to follow the chosen accent.
var ovTheme = (userOverrides && (dark
? Object.assign({}, userOverrides.theme || {}, userOverrides.themeDark || {})
: (userOverrides.theme || {}))) || {};
if (ovTheme.accent) root.setProperty('--logo-accent', ovTheme.accent);
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
// Derived vars
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface2 || themeSection.surface1);
// Node colors → --node-X CSS var only (legacy compat).
// #1412: do NOT push server-config nodeColors into window.ROLE_COLORS —
// that defeats cb-presets propagation by trapping the legacy palette in
// the _roleOverrides map (where the live getter prefers it over the
// --mc-role-X CSS vars that presets actually write). User-chosen
// overrides still flow through setRoleColorOverride() in customize.js.
var nc = effectiveConfig.nodeColors;
if (nc) {
// #1438 final: scope --mc-role-{role} writes to USER overrides only,
// UNLESS no CB preset is active (#1446). When a preset is active the
// server-config palette must stay out of --mc-role-* so the preset
// wins (preserves #1412). When NO preset is active, the cascade is:
// user override > server config > built-in :root default.
// → server config gets to write --mc-role-{role} in that case.
var userNc = (userOverrides && userOverrides.nodeColors) || {};
var presetActive = false;
try {
var presetAttr = document.body && document.body.getAttribute && document.body.getAttribute('data-cb-preset');
presetActive = !!(presetAttr && presetAttr !== 'none');
} catch (e) {}
for (var role in nc) {
root.setProperty('--node-' + role, nc[role]);
if (Object.prototype.hasOwnProperty.call(userNc, role)) {
// Operator picked this color → drive --mc-role-{role} so marker
// SVGs (fill="var(--mc-role-X)") and other CSS-var consumers
// pick it up on every page load. Without this the user pick
// sits in localStorage but --mc-role-{role} falls back to the
// active preset on reload, reverting marker fills.
root.setProperty('--mc-role-' + role, nc[role]);
// #1446 — also write to body.style with !important so the user
// pick beats the body[data-cb-preset="X"] selector cascade when
// a CB preset is active. Without this, the root-level write is
// shadowed by the preset's body-scoped CSS rule (root cause of
// #1444). When no preset is active, the body write is harmless
// and still wins inheritance.
if (presetActive && document.body && document.body.style) {
document.body.style.setProperty('--mc-role-' + role, nc[role], 'important');
}
} else if (!presetActive) {
// #1446 — no preset is active; server config is the legitimate
// source of role colors. Write --mc-role-{role} so marker SVGs
// honor operator's config.json without forcing visitors to pick
// a CB preset to "unlock" their server palette.
root.setProperty('--mc-role-' + role, nc[role]);
} else if (presetActive && document.body && document.body.style) {
// Preset active AND this role has no user override:
// ensure any prior body inline !important is removed so the
// preset value (from body[data-cb-preset=X] CSS rule) takes over.
// Also remove the root-level --mc-role-{role} that a PREVIOUS
// setOverride call left behind (#1446 followup): without this,
// :root.style.--mc-role-{role} stays stuck at the old user-pick
// value even though body's cascaded preset rule now wins for
// descendant elements. The visible UI is correct but introspection
// (getComputedStyle on documentElement) reports stale color.
document.body.style.removeProperty('--mc-role-' + role);
root.removeProperty('--mc-role-' + role);
}
}
}
// Type colors → CSS vars + global objects
var tc = effectiveConfig.typeColors;
if (tc) {
for (var type in tc) {
root.setProperty('--type-' + type.toLowerCase(), tc[type]);
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = tc[type];
}
if (window.syncBadgeColors) window.syncBadgeColors();
}
// Timestamps → sync to SITE_CONFIG
if (effectiveConfig.timestamps) {
if (!window.SITE_CONFIG) window.SITE_CONFIG = {};
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
var ts = effectiveConfig.timestamps;
if (ts.defaultMode) window.SITE_CONFIG.timestamps.defaultMode = ts.defaultMode;
if (ts.timezone) window.SITE_CONFIG.timestamps.timezone = ts.timezone;
if (ts.formatPreset) window.SITE_CONFIG.timestamps.formatPreset = ts.formatPreset;
if (ts.customFormat != null) window.SITE_CONFIG.timestamps.customFormat = ts.customFormat;
}
// Heatmap opacity → sync to localStorage for map pages
if (typeof effectiveConfig.heatmapOpacity === 'number') {
localStorage.setItem('meshcore-heatmap-opacity', effectiveConfig.heatmapOpacity);
}
if (typeof effectiveConfig.liveHeatmapOpacity === 'number') {
localStorage.setItem('meshcore-live-heatmap-opacity', effectiveConfig.liveHeatmapOpacity);
}
// #1488 — marker stroke: drive CSS vars from effective config. SVG
// markers across map.js / live.js / roles.js all read these vars, so
// a single write here repaints every mounted marker without a reload.
var ms = effectiveConfig.markerStroke;
if (ms && typeof ms === 'object') {
if (typeof ms.color === 'string' && ms.color) {
root.setProperty('--mc-marker-stroke-color', ms.color);
}
if (ms.width != null && isFinite(ms.width)) {
root.setProperty('--mc-marker-stroke-width', String(ms.width));
}
if (ms.opacity != null && isFinite(ms.opacity)) {
root.setProperty('--mc-marker-stroke-opacity', String(ms.opacity));
}
}
// Distance unit → sync to localStorage for all pages
if (typeof effectiveConfig.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(effectiveConfig.distanceUnit) >= 0) {
localStorage.setItem('meshcore-distance-unit', effectiveConfig.distanceUnit);
}
// Nav gradient
if (themeSection.navBg) {
var nav = document.querySelector('.top-nav');
if (nav) {
nav.style.background = 'linear-gradient(135deg, ' + themeSection.navBg + ' 0%, ' + (themeSection.navBg2 || themeSection.navBg) + ' 50%, ' + themeSection.navBg + ' 100%)';
}
}
// Branding
var br = effectiveConfig.branding;
if (br) {
if (br.siteName) {
document.title = br.siteName;
_setBrandAlt(br.siteName);
var brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = br.siteName;
}
if (br.logoUrl) {
_setBrandLogoUrl(br.logoUrl, br.siteName || null);
var iconEl = document.querySelector('.brand-icon');
if (iconEl) iconEl.innerHTML = '<img src="' + br.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
}
if (br.faviconUrl) {
var fav = document.querySelector('link[rel="icon"]');
if (fav) fav.href = br.faviconUrl;
}
// #1518 — apply validated homeUrl override to .nav-brand[href].
// Always call: invalid/empty restores '#/' so a cleared override visibly
// reverts. Validator rejects javascript:/data:/etc to prevent XSS.
_setBrandHomeUrl(br.homeUrl);
}
// Dispatch theme-changed event (bare, no payload — matches existing behavior)
window.dispatchEvent(new CustomEvent('theme-changed'));
}
/** Run the full pipeline: read → merge → atomic assign → applyCSS */
function _runPipeline() {
var overrides = readOverrides();
var effective = computeEffective(_serverDefaults || {}, overrides);
window.SITE_CONFIG = effective;
applyCSS(effective, overrides);
}
// ── setOverride / clearOverride ──
// Uses _pendingOverrides accumulator to prevent data loss when rapid calls
// occur within the debounce window (each call would otherwise read stale
// overrides from localStorage before the previous write landed).
var _debounceTimer = null;
var _pendingOverrides = {};
function setOverride(section, key, value) {
if (section) {
if (!_pendingOverrides[section]) _pendingOverrides[section] = {};
_pendingOverrides[section][key] = value;
} else {
_pendingOverrides[key] = value;
}
_debouncedWrite();
}
function clearOverride(section, key) {
var delta = readOverrides();
if (section) {
if (delta[section]) {
delete delta[section][key];
if (Object.keys(delta[section]).length === 0) delete delta[section];
}
} else {
delete delta[key];
}
// Also clear from pending
if (section && _pendingOverrides[section]) {
delete _pendingOverrides[section][key];
if (Object.keys(_pendingOverrides[section]).length === 0) delete _pendingOverrides[section];
} else if (!section) {
delete _pendingOverrides[key];
}
// No debounce on reset — instant feedback
writeOverrides(delta);
_runPipeline();
_refreshPanel();
}
function _debouncedWrite() {
_updateSaveStatus('saving');
if (_debounceTimer) clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(function () {
_debounceTimer = null;
var delta = readOverrides();
for (var sec in _pendingOverrides) {
if (typeof _pendingOverrides[sec] === 'object' && _pendingOverrides[sec] !== null) {
if (!delta[sec]) delta[sec] = {};
for (var k in _pendingOverrides[sec]) {
delta[sec][k] = _pendingOverrides[sec][k];
}
} else {
delta[sec] = _pendingOverrides[sec];
}
}
var pendingKeys = _pendingOverrides;
_pendingOverrides = {};
// Spec Decision #7: don't silently prune existing overrides.
// Only prevent redundant NEW writes: if a value just written matches
// the server default, don't store it (clearOverride semantics).
var server = _serverDefaults || {};
for (var ps in pendingKeys) {
if (typeof pendingKeys[ps] === 'object' && pendingKeys[ps] !== null && OBJECT_SECTIONS.indexOf(ps) >= 0) {
var serverSec = server[ps] || {};
if (delta[ps]) {
for (var pk in pendingKeys[ps]) {
var ov = delta[ps][pk];
var sv = serverSec[pk];
var match = (typeof ov === 'object' || typeof sv === 'object')
? JSON.stringify(ov) === JSON.stringify(sv) : ov === sv;
if (match) delete delta[ps][pk];
}
if (Object.keys(delta[ps]).length === 0) delete delta[ps];
}
} else if (SCALAR_SECTIONS.indexOf(ps) >= 0 && delta[ps] === server[ps]) {
delete delta[ps];
}
}
writeOverrides(delta);
_runPipeline();
// Skip re-render while the user is typing inside the panel — setting
// innerHTML would destroy the focused input and collapse the mobile keyboard.
if (!(_panelEl && _panelEl.contains(document.activeElement))) {
_refreshPanel();
}
}, 300);
}
// ── Migration ──
function migrateOldKeys() {
// Skip if new key already exists
if (localStorage.getItem(STORAGE_KEY) != null) return null;
var hasLegacy = false;
for (var i = 0; i < LEGACY_KEYS.length; i++) {
if (localStorage.getItem(LEGACY_KEYS[i]) != null) { hasLegacy = true; break; }
}
if (!hasLegacy) return null;
var delta = {};
// meshcore-user-theme (JSON object with branding, theme, themeDark, nodeColors, typeColors, home)
try {
var raw = localStorage.getItem('meshcore-user-theme');
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
var allowedKeys = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home'];
for (var k = 0; k < allowedKeys.length; k++) {
if (parsed[allowedKeys[k]] && typeof parsed[allowedKeys[k]] === 'object') {
delta[allowedKeys[k]] = JSON.parse(JSON.stringify(parsed[allowedKeys[k]]));
}
}
}
}
} catch (e) {
console.warn('[customizer-v2] Migration: invalid meshcore-user-theme JSON, skipping');
}
// Timestamp keys → delta.timestamps
var tsMode = localStorage.getItem('meshcore-timestamp-mode');
var tsTz = localStorage.getItem('meshcore-timestamp-timezone');
var tsFmt = localStorage.getItem('meshcore-timestamp-format');
var tsCustom = localStorage.getItem('meshcore-timestamp-custom-format');
if (tsMode || tsTz || tsFmt || tsCustom) {
delta.timestamps = {};
if (tsMode && (tsMode === 'ago' || tsMode === 'absolute')) delta.timestamps.defaultMode = tsMode;
if (tsTz && (tsTz === 'local' || tsTz === 'utc')) delta.timestamps.timezone = tsTz;
if (tsFmt && (tsFmt === 'iso' || tsFmt === 'iso-seconds' || tsFmt === 'locale')) delta.timestamps.formatPreset = tsFmt;
if (tsCustom != null && tsCustom !== '') delta.timestamps.customFormat = tsCustom;
if (Object.keys(delta.timestamps).length === 0) delete delta.timestamps;
}
// Heatmap opacities
var heatRaw = localStorage.getItem('meshcore-heatmap-opacity');
if (heatRaw != null && heatRaw !== '') {
var heatVal = parseFloat(heatRaw);
if (isFinite(heatVal)) delta.heatmapOpacity = Math.max(0, Math.min(1, heatVal));
}
var liveHeatRaw = localStorage.getItem('meshcore-live-heatmap-opacity');
if (liveHeatRaw != null && liveHeatRaw !== '') {
var liveHeatVal = parseFloat(liveHeatRaw);
if (isFinite(liveHeatVal)) delta.liveHeatmapOpacity = Math.max(0, Math.min(1, liveHeatVal));
}
// Write the migrated delta
writeOverrides(delta);
// Remove all legacy keys
for (var j = 0; j < LEGACY_KEYS.length; j++) {
localStorage.removeItem(LEGACY_KEYS[j]);
}
console.log('[customizer-v2] Migrated', Object.keys(delta).length, 'sections from legacy localStorage keys');
return delta;
}
// ── Validate shape (for import) ──
function validateShape(obj) {
var errors = [];
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) {
return { valid: false, errors: ['Input must be a plain object'] };
}
var colorSections = ['theme', 'themeDark', 'nodeColors', 'typeColors'];
for (var key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (VALID_SECTIONS.indexOf(key) === -1) {
console.warn('[customizer-v2] Unknown top-level key in import:', key);
continue; // warning, not error
}
// Check section types
if (OBJECT_SECTIONS.indexOf(key) !== -1) {
if (typeof obj[key] !== 'object' || obj[key] === null || Array.isArray(obj[key])) {
errors.push('Section "' + key + '" must be an object');
continue;
}
}
if (SCALAR_SECTIONS.indexOf(key) !== -1) {
var num = typeof obj[key] === 'string' ? parseFloat(obj[key]) : obj[key];
if (!isValidOpacity(num)) {
errors.push('"' + key + '" must be a number between 0 and 1');
}
continue;
}
// Validate colors in color sections
if (colorSections.indexOf(key) !== -1 && typeof obj[key] === 'object') {
for (var ck in obj[key]) {
if (!obj[key].hasOwnProperty(ck)) continue;
if ((key === 'theme' || key === 'themeDark') && (ck === 'font' || ck === 'mono')) continue;
if (typeof obj[key][ck] === 'string' && !isValidColor(obj[key][ck])) {
errors.push('Invalid color: ' + key + '.' + ck + ' = "' + obj[key][ck] + '"');
}
}
}
// Validate timestamps
if (key === 'timestamps' && typeof obj[key] === 'object') {
for (var tk in obj[key]) {
if (TS_ENUMS[tk] && TS_ENUMS[tk].indexOf(obj[key][tk]) === -1) {
errors.push('Invalid timestamp enum: ' + tk + ' = "' + obj[key][tk] + '"');
}
}
}
// Validate distanceUnit
if (key === 'distanceUnit' && DISTANCE_UNIT_VALUES.indexOf(obj[key]) === -1) {
errors.push('Invalid distanceUnit: "' + obj[key] + '" — must be km, mi, or auto');
}
// Validate favorites and myNodes arrays
if (key === 'favorites') {
if (!Array.isArray(obj[key])) {
errors.push('"favorites" must be an array of public key strings');
}
}
if (key === 'myNodes') {
if (!Array.isArray(obj[key])) {
errors.push('"myNodes" must be an array of node objects');
}
}
}
return { valid: errors.length === 0, errors: errors };
}
// ── Save status indicator ──
function _updateSaveStatus(status) {
_saveStatus = status;
var el = document.getElementById('cv2-save-status');
if (!el) return;
if (status === 'saved') { el.textContent = 'All changes saved'; el.style.color = 'var(--text-muted)'; }
else if (status === 'saving') { el.textContent = 'Saving...'; el.style.color = 'var(--text-muted)'; }
else if (status === 'error') { el.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> Storage full — changes may not be saved'; el.style.color = '#ef4444'; }
}
function _showQuotaWarning() {
// Surface a visible warning in the panel
_updateSaveStatus('error');
}
// ── Customizer panel UI ──
var _panelEl = null;
var _activeTab = 'branding';
var _styleEl = null;
// GeoFilter tab state
var _gfMap = null;
var _gfModalMap = null;
var _gfWriteEnabled = false;
var _gfPoints = [];
var _gfMarkers = [];
var _gfPolygon = null;
var _gfClosingLine = null;
var _gfLoaded = false; // true after initial server load
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function escAttr(s) { return (s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
function _getEffective() { return window.SITE_CONFIG || computeEffective(_serverDefaults || {}, readOverrides()); }
function _getOverrides() { return readOverrides(); }
function _getServer() { return _serverDefaults || {}; }
/** Check if a specific field is overridden (differs from server default) */
function _isOverridden(section, key) {
var overrides = _getOverrides();
var server = _getServer();
if (section) {
if (!overrides[section] || !overrides[section].hasOwnProperty(key)) return false;
var serverSection = server[section] || {};
var ov = overrides[section][key];
var sv = serverSection[key];
// Deep compare for arrays/objects
if (typeof ov === 'object' || typeof sv === 'object') {
return JSON.stringify(ov) !== JSON.stringify(sv);
}
return ov !== sv;
}
if (!overrides.hasOwnProperty(key)) return false;
var ov2 = overrides[key];
var sv2 = server[key];
if (typeof ov2 === 'object' || typeof sv2 === 'object') {
return JSON.stringify(ov2) !== JSON.stringify(sv2);
}
return ov2 !== sv2;
}
/** Count overridden fields in a section (only those that differ from server defaults) */
function _countOverrides(section) {
var overrides = _getOverrides();
if (!overrides[section] || typeof overrides[section] !== 'object') return 0;
var count = 0;
var keys = Object.keys(overrides[section]);
for (var i = 0; i < keys.length; i++) {
if (_isOverridden(section, keys[i])) count++;
}
return count;
}
function _overrideDot(section, key) {
if (!_isOverridden(section, key)) return '';
return '<span class="cv2-override-dot" data-reset-s="' + (section || '') + '" data-reset-k="' + key + '" title="Reset to server default"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>';
}
function _injectStyles() {
if (_styleEl) return;
_styleEl = document.createElement('style');
_styleEl.textContent = [
'.cust-overlay{position:fixed;top:56px;right:12px;z-index:1050;width:480px;height:calc(100vh - 68px);background:var(--card-bg);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.3);display:flex;flex-direction:column;resize:both;min-width:320px;min-height:300px;overflow:hidden}',
'.cust-overlay.hidden{display:none}',
'.cust-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);cursor:move;user-select:none;flex-shrink:0}',
'.cust-header h2{margin:0;font-size:15px}',
'.cv2-local-banner{font-size:10px;color:var(--text-muted);padding:4px 16px;background:var(--surface-1);border-bottom:1px solid var(--border);text-align:center;flex-shrink:0}',
'.cust-close{background:none;border:none;font-size:18px;cursor:pointer;color:var(--text-muted);padding:4px 8px;border-radius:4px}',
'.cust-close:hover{background:var(--surface-3);color:var(--text)}',
'.cust-inner{flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0}',
'.cust-body{flex:1;overflow-y:auto;min-height:0}',
'.cust-tabs{display:flex;gap:0;border-bottom:1px solid var(--border);flex-shrink:0}',
'.cust-tab{padding:8px 10px;cursor:pointer;border:none;background:none;color:var(--text-muted);font-size:12px;font-weight:500;border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;flex:1;text-align:center}',
'.cust-tab-text{font-size:10px;display:block}',
'.cust-tab:hover{color:var(--text)}',
'.cust-tab.active{color:var(--accent);border-bottom-color:var(--accent)}',
'.cust-tab .cv2-tab-badge{font-size:9px;background:var(--accent);color:#fff;border-radius:8px;padding:0 4px;margin-left:2px}',
'.cust-panel{display:none;padding:12px 16px}',
'.cust-panel.active{display:block}',
'.cust-field{margin-bottom:12px}',
'.cust-field label{display:block;font-size:12px;font-weight:600;margin-bottom:3px;color:var(--text)}',
'.cust-field input[type="text"],.cust-field textarea{width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--input-bg);color:var(--text);box-sizing:border-box}',
'.cust-field input[type="text"]:focus,.cust-field textarea:focus{outline:none;border-color:var(--accent)}',
'.cust-color-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}',
'.cust-color-row>div:first-child{min-width:160px;flex:1}',
'.cust-color-row label{font-size:12px;font-weight:600;margin:0;display:block}',
'.cust-hint{font-size:10px;color:var(--text-muted);margin-top:1px;line-height:1.2}',
'.cust-color-row input[type="color"]{width:40px;height:32px;border:1px solid var(--border);border-radius:6px;cursor:pointer;padding:2px;background:var(--input-bg)}',
'.cust-color-row .cust-hex{font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:70px}',
'.cv2-override-dot{color:var(--accent);cursor:pointer;font-size:10px;margin-left:4px;vertical-align:middle;title:"Reset to server default"}',
'.cv2-override-dot:hover{color:var(--status-red)}',
'.cust-node-dot{display:inline-block;width:16px;height:16px;border-radius:50%;vertical-align:middle}',
'.cust-preview-img{max-width:200px;max-height:60px;margin-top:6px;border-radius:6px;border:1px solid var(--border)}',
'.cust-list-item{display:flex;flex-direction:column;gap:4px;margin-bottom:8px;padding:8px;background:var(--surface-1);border:1px solid var(--border);border-radius:6px}',
'.cust-list-row{display:flex;gap:6px;align-items:center}',
'.cust-list-item input{flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:4px;font-size:12px;background:var(--input-bg);color:var(--text);min-width:0}',
'.cust-list-item textarea{width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:4px;font-size:11px;font-family:var(--mono);background:var(--input-bg);color:var(--text);resize:vertical;box-sizing:border-box}',
'.cust-list-item textarea:focus,.cust-list-item input:focus{outline:none;border-color:var(--accent)}',
'.cust-md-hint{font-size:9px;color:var(--text-muted);margin-top:2px}',
'.cust-md-hint code{background:var(--surface-2);padding:0 3px;border-radius:2px;font-size:9px}',
'.cust-list-item .cust-emoji-input{max-width:40px;text-align:center;flex:0 0 40px}',
'.cust-list-btn{padding:4px 10px;border:1px solid var(--border);border-radius:4px;background:var(--surface-2);color:var(--text-muted);cursor:pointer;font-size:12px}',
'.cust-list-btn:hover{background:var(--surface-3)}',
'.cust-list-btn.danger{color:#ef4444}',
'.cust-list-btn.danger:hover{background:#fef2f2}',
'.cust-add-btn{display:inline-flex;align-items:center;gap:4px;padding:6px 14px;border:1px dashed var(--border);border-radius:6px;background:none;color:var(--accent);cursor:pointer;font-size:13px;margin-top:4px}',
'.cust-add-btn:hover{background:var(--hover-bg)}',
'.cust-export-btns{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}',
'.cust-export-btns button{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500}',
'.cust-copy-btn{background:var(--accent);color:#fff}',
'.cust-copy-btn:hover{opacity:.9}',
'.cust-dl-btn{background:var(--surface-2);color:var(--text);border:1px solid var(--border)!important}',
'.cust-dl-btn:hover{background:var(--surface-3)}',
'.cust-reset-all{background:var(--surface-2);color:#ef4444;border:1px solid #ef4444!important}',
'.cust-reset-all:hover{background:#ef4444;color:#fff}',
'.cust-section-title{font-size:16px;font-weight:600;margin:0 0 12px}',
'.cust-preset-btn{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 10px;border:2px solid var(--border);border-radius:8px;background:var(--surface-1);cursor:pointer;min-width:72px;color:var(--text)}',
'.cust-preset-btn.active{border-color:var(--accent);background:var(--selected-bg)}',
'.cv2-footer{padding:6px 16px;border-top:1px solid var(--border);font-size:11px;color:var(--text-muted);flex-shrink:0;display:flex;justify-content:space-between;align-items:center}',
'@media(max-width:600px){.cust-overlay{left:8px;right:8px;width:auto;top:56px}.cust-tabs{gap:0}.cust-tab{padding:6px 8px;font-size:11px}.cust-color-row>div:first-child{min-width:120px}}'
].join('\n');
document.head.appendChild(_styleEl);
}
function _tabBadge(section) {
var n = _countOverrides(section);
return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : '';
}
function _renderTabs() {
var tabs = [
{ id: 'branding', glyph: 'ph:tag', title: 'Branding', badge: _tabBadge('branding') },
{ id: 'theme', glyph: 'ph:palette', title: 'Theme', badge: _tabBadge(isDarkMode() ? 'themeDark' : 'theme') },
{ id: 'nodes', glyph: 'ph:target', title: 'Colors', badge: (function () { var n = _countOverrides('nodeColors') + _countOverrides('typeColors'); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
{ id: 'home', glyph: 'ph:house', title: 'Home', badge: _tabBadge('home') },
{ id: 'display', glyph: 'ph:monitor', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
{ id: 'geofilter', glyph: 'ph:map-trifold', title: 'GeoFilter' },
{ id: 'export', glyph: 'ph:download-simple', title: 'Export' }
];
// #1508 — operators can hide individual tabs server-side via
// config.customizer.disabledTabs. The list arrives on
// window.MC_CUSTOMIZER_CFG (set by roles.js after /api/config/client).
// Default empty so behavior is unchanged when the field is absent.
var disabled = (typeof window !== 'undefined' && window.MC_CUSTOMIZER_CFG && window.MC_CUSTOMIZER_CFG.disabledTabs) || [];
if (disabled.length) {
tabs = tabs.filter(function (t) { return disabled.indexOf(t.id) === -1; });
}
return '<div class="cust-tabs">' + tabs.map(function (t) {
return '<button class="cust-tab' + (t.id === _activeTab ? ' active' : '') + '" data-tab="' + t.id + '" title="' + t.title + '">' +
renderConfigGlyph(t.glyph) + ' <span class="cust-tab-text">' + t.title + '</span>' + (t.badge || '') + '</button>';
}).join('') + '</div>';
}
function _renderColorRow(key, section, effectiveVal, serverDefault) {
var isFont = key === 'font' || key === 'mono';
var val = effectiveVal || '';
var def = serverDefault || '';
var dot = _overrideDot(section, key);
var inputHtml;
if (isFont) {
inputHtml = '<input type="text" data-cv2-field="' + section + '.' + key + '" value="' + escAttr(val) + '" style="width:160px;font-size:11px;font-family:var(--mono);padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--input-bg);color:var(--text)">';
} else {
// Ensure hex is 7 chars for color input
var hexVal = val.length === 7 ? val : (val.length === 4 ? '#' + val[1] + val[1] + val[2] + val[2] + val[3] + val[3] : val);
inputHtml = '<input type="color" data-cv2-field="' + section + '.' + key + '" value="' + hexVal + '">' +
'<span class="cust-hex">' + val + '</span>';
}
return '<div class="cust-color-row">' +
'<div><label>' + (THEME_LABELS[key] || key) + dot + '</label>' +
'<div class="cust-hint">' + (THEME_HINTS[key] || '') + '</div></div>' +
inputHtml +
'</div>';
}
function _detectActivePreset() {
var eff = _getEffective();
var effTheme = eff.theme || {};
var effDark = eff.themeDark || {};
for (var id in PRESETS) {
var p = PRESETS[id];
var pTheme = p.theme || {};
var pDark = p.themeDark || {};
var match = true;
for (var i = 0; i < THEME_COLOR_KEYS.length && match; i++) {
var k = THEME_COLOR_KEYS[i];
// An undefined value in the effective theme means the operator
// (and server config) has not customized this key — the preset's
// value will become effective via the CSS cascade, so it should
// match ANY preset value rather than invalidating the match.
// This keeps _detectActivePreset() correct as we add new themeable
// keys to PRESETS.default (e.g. navActiveBg in #1509).
if (effTheme[k] !== undefined && effTheme[k] !== pTheme[k]) match = false;
else if (effDark[k] !== undefined && effDark[k] !== pDark[k]) match = false;
}
if (match && p.nodeColors && eff.nodeColors) {
for (var nk in p.nodeColors) {
if (eff.nodeColors[nk] !== undefined && eff.nodeColors[nk] !== p.nodeColors[nk]) { match = false; break; }
}
}
if (match && p.typeColors && eff.typeColors) {
for (var tk in p.typeColors) {
if (eff.typeColors[tk] !== undefined && eff.typeColors[tk] !== p.typeColors[tk]) { match = false; break; }
}
}
if (match) return id;
}
return null;
}
function _renderPresets() {
var active = _detectActivePreset();
var html = '<div style="margin-bottom:16px"><p class="cust-section-title">Theme Presets</p><div style="display:flex;gap:8px;flex-wrap:wrap">';
for (var id in PRESETS) {
var p = PRESETS[id];
var isActive = id === active;
var dots = '';
for (var di = 0; di < p.preview.length; di++) {
dots += '<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:' + p.preview[di] + ';border:1px solid rgba(128,128,128,.3)"></span>';
}
html += '<button class="cust-preset-btn' + (isActive ? ' active' : '') + '" data-preset="' + id + '">' +
'<div style="display:flex;gap:3px">' + dots + '</div>' +
'<span style="font-size:11px;font-weight:' + (isActive ? '700' : '500') + '">' + esc(p.name) + '</span>' +
'<span style="font-size:9px;color:var(--text-muted)">' + esc(p.desc) + '</span></button>';
}
html += '</div></div>';
return html;
}
function _renderBranding() {
var eff = _getEffective();
var b = eff.branding || {};
var logoPreview = b.logoUrl ? '<img class="cust-preview-img" src="' + escAttr(b.logoUrl) + '" alt="Logo preview" onerror="this.style.display=\'none\'">' : '';
return '<div class="cust-panel' + (_activeTab === 'branding' ? ' active' : '') + '" data-panel="branding">' +
'<div class="cust-field"><label>Site Name' + _overrideDot('branding', 'siteName') + '</label><input type="text" data-cv2-field="branding.siteName" value="' + escAttr(b.siteName || '') + '"></div>' +
'<div class="cust-field"><label>Tagline' + _overrideDot('branding', 'tagline') + '</label><input type="text" data-cv2-field="branding.tagline" value="' + escAttr(b.tagline || '') + '"></div>' +
'<div class="cust-field"><label>Logo URL' + _overrideDot('branding', 'logoUrl') + '</label><input type="text" data-cv2-field="branding.logoUrl" value="' + escAttr(b.logoUrl || '') + '" placeholder="https://...">' + logoPreview + '</div>' +
'<div class="cust-field"><label>Home URL' + _overrideDot('branding', 'homeUrl') + '</label><input type="text" data-cv2-field="branding.homeUrl" value="' + escAttr(b.homeUrl || '') + '" placeholder="https://your-site.example/ or #/"></div>' +
'<div class="cust-field"><label>Favicon URL' + _overrideDot('branding', 'faviconUrl') + '</label><input type="text" data-cv2-field="branding.faviconUrl" value="' + escAttr(b.faviconUrl || '') + '" placeholder="https://..."></div>' +
'</div>';
}
function _renderTheme() {
var dark = isDarkMode();
var section = dark ? 'themeDark' : 'theme';
var eff = _getEffective();
var server = _getServer();
var current = dark ? Object.assign({}, eff.theme || {}, eff.themeDark || {}) : (eff.theme || {});
var serverCurrent = dark ? Object.assign({}, server.theme || {}, server.themeDark || {}) : (server.theme || {});
var modeLabel = dark ? (renderConfigGlyph('ph:moon') + ' Dark Mode') : (renderConfigGlyph('ph:sun') + ' Light Mode');
var basicRows = '';
for (var i = 0; i < BASIC_KEYS.length; i++) basicRows += _renderColorRow(BASIC_KEYS[i], section, current[BASIC_KEYS[i]], serverCurrent[BASIC_KEYS[i]]);
var advancedRows = '';
for (var j = 0; j < ADVANCED_KEYS.length; j++) advancedRows += _renderColorRow(ADVANCED_KEYS[j], section, current[ADVANCED_KEYS[j]], serverCurrent[ADVANCED_KEYS[j]]);
var fontRows = '';
for (var f = 0; f < FONT_KEYS.length; f++) fontRows += _renderColorRow(FONT_KEYS[f], section, current[FONT_KEYS[f]], serverCurrent[FONT_KEYS[f]]);
return '<div class="cust-panel' + (_activeTab === 'theme' ? ' active' : '') + '" data-panel="theme">' +
_renderPresets() +
'<p class="cust-section-title">' + modeLabel + '</p>' +
'<p style="font-size:11px;color:var(--text-muted);margin:0 0 10px">Toggle ' + renderConfigGlyph('ph:sun') + '/' + renderConfigGlyph('ph:moon') + ' in nav to edit the other mode.</p>' +
basicRows +
'<details class="cust-advanced"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted);margin:12px 0 8px">Advanced (' + ADVANCED_KEYS.length + ' options)</summary>' + advancedRows + '</details>' +
'<details class="cust-fonts" style="margin-top:12px"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted);margin:12px 0 8px">Fonts</summary>' + fontRows + '</details>' +
'</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 '';
// #1446 — currentPreset() now returns null when no preset is stored.
var current = MCP.currentPreset ? MCP.currentPreset() : null;
var clearOpt = _renderCbPresetClearOption(current);
var options = MCP.list.map(function (p) {
var checked = p.id === current ? ' checked' : '';
return '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
'<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">Optional: Colorblind-Safe Preset</p>' +
'<p class="cust-hint" style="margin-bottom:8px">A CB preset is an end-user opt-in that swaps the role/status palette for color-vision variants. ' +
'Leave unset to use the operator\'s configured colors (or pick from above). ' +
'Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.</p>' +
'<div class="cust-cb-presets" data-cv2-cb-preset-group>' + clearOpt + options + '</div>' +
_renderCbSimSelector() +
_renderCbResetButton() +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">';
}
// #1380 — Brettel/Vienot 1997 dichromatic simulation overlay.
// Preview-only: NOT persisted; clears on reload. Toggled via
// body[data-cb-sim] + CSS filter:url(#cb-*) defined in public/index.html.
function _renderCbSimSelector() {
var SIMS = [
{ id: '', label: 'Off', desc: 'No simulation (default).' },
{ id: 'deut', label: 'Deuteranopia', desc: 'Brettel/Vienot 1997 — green-cone deficit.' },
{ id: 'prot', label: 'Protanopia', desc: 'Brettel/Vienot 1997 — red-cone deficit.' },
{ id: 'trit', label: 'Tritanopia', desc: 'Brettel/Vienot 1997 — blue-cone deficit.' },
{ id: 'achromat', label: 'Achromatopsia', desc: 'Luminance-only (Rec.601).' }
];
// Hardcoded literal lookup so the source contains `value="deut"` etc.
// (See test-issue-1380-cb-sim-overlay.js, asserts on source text.)
var VALUE_ATTRS = {
'': 'value=""',
'deut': 'value="deut"',
'prot': 'value="prot"',
'trit': 'value="trit"',
'achromat': 'value="achromat"'
};
var current = '';
try {
if (typeof document !== 'undefined' && document.body && document.body.getAttribute) {
current = document.body.getAttribute('data-cb-sim') || '';
}
} catch (e) {}
var rows = SIMS.map(function (s) {
var checked = s.id === current ? ' checked' : '';
return '<label class="cust-cb-sim-row" style="display:flex;gap:8px;align-items:flex-start;margin:4px 0;cursor:pointer">' +
'<input type="radio" name="cv2-cb-sim" data-cv2-cb-sim ' + (VALUE_ATTRS[s.id] || ('value="' + escAttr(s.id) + '"')) + checked + ' style="margin-top:3px">' +
'<div style="flex:1">' +
'<div style="font-weight:600">' + esc(s.label) + '</div>' +
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">' + esc(s.desc) + '</div>' +
'</div>' +
'</label>';
}).join('');
return '<p class="cust-section-title" style="margin-top:12px">Simulation overlay <span style="font-weight:normal;color:var(--text-muted);font-size:11px">(preview-only)</span></p>' +
'<p class="cust-hint" style="margin-bottom:8px">Apply a Brettel/Vienot 1997 dichromatic filter to the entire page to preview how the UI reads to colorblind viewers. Not persisted — clears on reload.</p>' +
'<div class="cust-cb-sim" data-cv2-cb-sim-group>' + rows + '</div>';
}
// #1380 — Reset-to-default-Wong button. Clears any stored CB preset and
// re-applies the Wong palette via MeshCorePresets.applyPreset('default').
function _renderCbResetButton() {
return '<div style="margin-top:12px">' +
'<button type="button" data-cv2-cb-reset class="cust-btn" ' +
'style="padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--surface-1);color:var(--text);cursor:pointer;font-size:12px">' +
'Reset to default Wong</button>' +
'<div class="cust-hint" style="font-size:11px;color:var(--text-muted);margin-top:4px">' +
'Restores the Wong 2011 palette and clears any saved colorblind preset.</div>' +
'</div>';
}
// Exposed helper — toggles body[data-cb-sim]. Pure DOM, no persistence.
function _applyCbSim(id) {
if (typeof document === 'undefined' || !document.body) return false;
if (!id) {
if (document.body.removeAttribute) document.body.removeAttribute('data-cb-sim');
} else {
document.body.setAttribute('data-cb-sim', id);
}
return true;
}
// Exposed helper — applies the default Wong preset and clears storage.
function _resetCbPreset() {
var MCP = (typeof window !== 'undefined') && window.MeshCorePresets;
var ok = false;
if (MCP && typeof MCP.applyPreset === 'function') {
ok = MCP.applyPreset('default') || ok;
}
try {
if (typeof localStorage !== 'undefined') localStorage.removeItem('meshcore-cb-preset');
} catch (e) {}
return ok;
}
function _renderCbPresetClearOption(current) {
var checked = !current ? ' checked' : '';
return '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
'<input type="radio" name="cv2-cb-preset" data-cv2-cb-preset value="" data-cv2-cb-preset-none' + checked + ' style="margin-top:3px">' +
'<div style="flex:1">' +
'<div style="font-weight:600">No preset (use operator / custom colors)</div>' +
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">Default — server-configured colors apply, then any per-role overrides above.</div>' +
'</div>' +
'</label>';
}
function _renderCbPresetWarning(id) {
var MCP = window.MeshCorePresets;
if (!MCP || typeof MCP.validatePreset !== 'function') return '';
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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg> WCAG 1.4.11: ' + esc(names) + ' below 3:1 vs ' + (dark ? 'dark' : 'light') + ' tiles</div>';
}
function _renderNodes() {
var eff = _getEffective();
var server = _getServer();
var nc = eff.nodeColors || {};
var snc = server.nodeColors || {};
var rows = '';
for (var key in NODE_LABELS) {
var val = nc[key] || '#000000';
rows += '<div class="cust-color-row">' +
'<div><label>' + renderConfigGlyph(NODE_EMOJI[key]) + ' ' + NODE_LABELS[key] + _overrideDot('nodeColors', key) + '</label>' +
'<div class="cust-hint">' + (NODE_HINTS[key] || '') + '</div></div>' +
'<input type="color" data-cv2-field="nodeColors.' + key + '" value="' + val + '">' +
'<span class="cust-node-dot" style="background:' + val + '"></span>' +
'<span class="cust-hex">' + val + '</span></div>';
}
var fallbackTC = (typeof window !== 'undefined' && window.TYPE_COLORS) || {};
var tc = eff.typeColors || {};
var stc = server.typeColors || {};
var typeRows = '';
for (var tkey in TYPE_LABELS) {
var tval = tc[tkey] || fallbackTC[tkey] || '#000000';
typeRows += '<div class="cust-color-row">' +
'<div><label>' + renderConfigGlyph(TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + _overrideDot('typeColors', tkey) + '</label>' +
'<div class="cust-hint">' + (TYPE_HINTS[tkey] || '') + '</div></div>' +
'<input type="color" data-cv2-field="typeColors.' + tkey + '" value="' + tval + '">' +
'<span class="cust-node-dot" style="background:' + tval + '"></span>' +
'<span class="cust-hex">' + tval + '</span></div>';
}
// Heatmap opacity
var heatOpacity = typeof eff.heatmapOpacity === 'number' ? eff.heatmapOpacity : 0.25;
var heatPct = Math.round(heatOpacity * 100);
var liveHeatOpacity = typeof eff.liveHeatmapOpacity === 'number' ? eff.liveHeatmapOpacity : 0.3;
var liveHeatPct = Math.round(liveHeatOpacity * 100);
// #1488 — marker stroke controls. Defaults match the :root values in
// style.css; the UI shows the effective merged value (server config →
// local override) so the operator sees what's actually painted.
var ms = eff.markerStroke || {};
var msColor = typeof ms.color === 'string' && ms.color ? ms.color : '#ffffff';
var msWidth = ms.width != null && isFinite(ms.width) ? Number(ms.width) : 2;
var msOpacity = ms.opacity != null && isFinite(ms.opacity) ? Number(ms.opacity) : 1;
var msOpacityPct = Math.round(msOpacity * 100);
return '<div class="cust-panel' + (_activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
'<p class="cust-section-title">Node Role Colors</p>' +
'<p class="cust-hint" style="margin-bottom:8px">These are the canonical role colors used across the app. They inherit from your server config (or built-in defaults), and can be optionally remapped by a colorblind-safe preset below.</p>' +
rows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
_renderColorblindPresetSelector() +
'<p class="cust-section-title">Packet Type Colors</p>' + typeRows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Heatmap Opacity</p>' +
'<div class="cust-color-row"><div><label><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg> Nodes Map' + _overrideDot(null, 'heatmapOpacity') + '</label>' +
'<div class="cust-hint">Heatmap overlay on the Nodes → Map page (0100%)</div></div>' +
'<input type="range" data-cv2-slider="heatmapOpacity" min="0" max="100" value="' + heatPct + '" style="width:120px;cursor:pointer">' +
'<span class="cust-hex" id="cv2HeatPct">' + heatPct + '%</span></div>' +
'<div class="cust-color-row"><div><label><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Live Map' + _overrideDot(null, 'liveHeatmapOpacity') + '</label>' +
'<div class="cust-hint">Heatmap overlay on the Live page (0100%)</div></div>' +
'<input type="range" data-cv2-slider="liveHeatmapOpacity" min="0" max="100" value="' + liveHeatPct + '" style="width:120px;cursor:pointer">' +
'<span class="cust-hex" id="cv2LiveHeatPct">' + liveHeatPct + '%</span></div>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Marker Stroke <span style="font-weight:normal;color:var(--text-muted);font-size:11px">(#1488)</span></p>' +
'<p class="cust-hint" style="margin-bottom:8px">Outline around each map marker. Dial these down when hundreds of nodes make the default white border overwhelming. Changes are live on every mounted marker.</p>' +
'<div class="cust-color-row"><div><label>Color' + _overrideDot('markerStroke', 'color') + '</label>' +
'<div class="cust-hint">Marker outline color. Defaults to white for contrast on dark + light tiles.</div></div>' +
'<input type="color" data-cv2-field="markerStroke.color" value="' + esc(msColor) + '">' +
'<span class="cust-node-dot" style="background:' + esc(msColor) + '"></span>' +
'<span class="cust-hex">' + esc(msColor) + '</span></div>' +
'<div class="cust-color-row"><div><label>Width' + _overrideDot('markerStroke', 'width') + '</label>' +
'<div class="cust-hint">Stroke thickness in SVG units (04). Set to 0 to remove the outline entirely.</div></div>' +
'<input type="range" data-cv2-marker-stroke="width" min="0" max="4" step="0.1" value="' + msWidth + '" style="width:120px;cursor:pointer">' +
'<span class="cust-hex" id="cv2MarkerStrokeW">' + msWidth + '</span></div>' +
'<div class="cust-color-row"><div><label>Opacity' + _overrideDot('markerStroke', 'opacity') + '</label>' +
'<div class="cust-hint">Stroke alpha (0100%). Drop this to ~30% for a softer outline.</div></div>' +
'<input type="range" data-cv2-marker-stroke="opacity" min="0" max="100" value="' + msOpacityPct + '" style="width:120px;cursor:pointer">' +
'<span class="cust-hex" id="cv2MarkerStrokeO">' + msOpacityPct + '%</span></div>' +
'</div>';
}
function _renderDisplay() {
var eff = _getEffective();
var distUnit = typeof eff.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(eff.distanceUnit) >= 0 ? eff.distanceUnit : 'auto';
var ts = (eff.timestamps) || {};
var tsMode = ts.defaultMode === 'absolute' ? 'absolute' : 'ago';
var tsTz = ts.timezone === 'utc' ? 'utc' : 'local';
var tsFmt = (ts.formatPreset === 'iso-seconds' || ts.formatPreset === 'locale') ? ts.formatPreset : 'iso';
var customFmt = typeof ts.customFormat === 'string' ? ts.customFormat : '';
var canCustom = !!(eff.timestamps && eff.timestamps.allowCustomFormat === true);
var showAbs = tsMode === 'absolute' ? '' : ' style="display:none"';
return '<div class="cust-panel' + (_activeTab === 'display' ? ' active' : '') + '" data-panel="display">' +
'<p class="cust-section-title">Display Settings</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">UI preferences that affect how data is shown across pages.</p>' +
'<p class="cust-section-title" style="font-size:14px;margin-bottom:8px">Timestamps</p>' +
'<div class="cust-field"><label>Timestamp Display' + _overrideDot('timestamps', 'defaultMode') + '</label>' +
'<select data-cv2-select="timestamps.defaultMode" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="ago"' + (tsMode === 'ago' ? ' selected' : '') + '>Relative (3m ago)</option>' +
'<option value="absolute"' + (tsMode === 'absolute' ? ' selected' : '') + '>Absolute (ISO timestamp)</option></select></div>' +
'<div class="cust-field"><label>Timezone' + _overrideDot('timestamps', 'timezone') + '</label>' +
'<select data-cv2-select="timestamps.timezone" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="local"' + (tsTz === 'local' ? ' selected' : '') + '>Local time</option>' +
'<option value="utc"' + (tsTz === 'utc' ? ' selected' : '') + '>UTC</option></select></div>' +
'<div class="cust-field" data-ts-abs="format"' + showAbs + '><label>Format' + _overrideDot('timestamps', 'formatPreset') + '</label>' +
'<select data-cv2-select="timestamps.formatPreset" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="iso"' + (tsFmt === 'iso' ? ' selected' : '') + '>ISO (2024-01-15 14:30:00)</option>' +
'<option value="iso-seconds"' + (tsFmt === 'iso-seconds' ? ' selected' : '') + '>ISO + ms</option>' +
'<option value="locale"' + (tsFmt === 'locale' ? ' selected' : '') + '>Locale (browser)</option></select></div>' +
(canCustom ? '<div class="cust-field" data-ts-abs="custom"' + showAbs + '><label>Custom Format' + _overrideDot('timestamps', 'customFormat') + '</label>' +
'<input type="text" data-cv2-field="timestamps.customFormat" value="' + escAttr(customFmt) + '" placeholder="YYYY-MM-DD HH:mm:ss"></div>' : '') +
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Distances</p>' +
'<div class="cust-field"><label>Distance Unit' + _overrideDot(null, 'distanceUnit') + '</label>' +
'<select data-cv2-select="distanceUnit" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="auto"' + (distUnit === 'auto' ? ' selected' : '') + '>Auto (browser locale)</option>' +
'<option value="km"' + (distUnit === 'km' ? ' selected' : '') + '>Kilometers (km)</option>' +
'<option value="mi"' + (distUnit === 'mi' ? ' selected' : '') + '>Miles (mi)</option>' +
'</select></div>' +
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Gesture Hints</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
_renderChannelsShowEncryptedToggle() +
_renderTileProviderSelector() +
'</div>';
}
// ── #1454 Show-encrypted-channels toggle ──
// Writes localStorage["channels-show-encrypted"]. Default OFF: key is
// removed (not set to "false") so the read-gate in channels.js cleanly
// returns false. Fires `mc-channels-show-encrypted-changed`; channels.js
// re-fetches the list live without a page reload.
function _renderChannelsShowEncryptedToggle() {
var on = false;
try { on = localStorage.getItem('channels-show-encrypted') === 'true'; } catch (_e) {}
return '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Channels</p>' +
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Encrypted channels appear as "Encrypted (0xAB)" with no name. Operators usually leave this off.</p>' +
'<div class="cust-field" style="display:flex;align-items:center;gap:8px">' +
'<input type="checkbox" id="cv2-channels-show-encrypted" data-cv2-channels-show-encrypted' +
(on ? ' checked' : '') +
' style="width:16px;height:16px;cursor:pointer">' +
'<label for="cv2-channels-show-encrypted" style="cursor:pointer;margin:0">Show encrypted channels</label>' +
'</div>';
}
// ── #1420 Dark-tile provider selector ──
// Persists per-browser via MC_setDarkTileProvider; map.js / live.js
// listen for `mc-tile-provider-changed` and swap tiles live.
function _renderTileProviderSelector() {
var reg = (typeof window !== 'undefined') && window.MC_TILE_PROVIDERS;
if (!reg) return '';
var activeDark = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
var activeLight = (typeof window.MC_getLightTileProvider === 'function') ? window.MC_getLightTileProvider() : 'carto-light';
var darkIds = Object.keys(reg).filter(function(id) { return reg[id].type === 'dark'; });
var lightIds = Object.keys(reg).filter(function(id) { return reg[id].type === 'light'; });
var hasMultipleDark = darkIds.length > 1;
var hasMultipleLight = lightIds.length > 1;
if (darkIds.length === 0 && lightIds.length === 0) return ''; // hide if no options
var html = '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Map Tiles</p>' +
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Choose the basemap providers. Available options depend on server configuration.</p>';
if (lightIds.length > 0) {
var optionsLight = lightIds.map(function (id) {
var label = reg[id].label || id;
var sel = id === activeLight ? ' selected' : '';
return '<option value="' + escAttr(id) + '"' + sel + '>' + esc(label) + '</option>';
}).join('');
html += '<div class="cust-field"><label for="cv2-light-tile-provider">Light Mode Provider</label>' +
'<select id="cv2-light-tile-provider" data-cv2-light-tile-provider style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
optionsLight +
'</select></div>';
}
if (darkIds.length > 0) {
var optionsDark = darkIds.map(function (id) {
var label = reg[id].label || id;
var sel = id === activeDark ? ' selected' : '';
return '<option value="' + escAttr(id) + '"' + sel + '>' + esc(label) + '</option>';
}).join('');
html += '<div class="cust-field"><label for="cv2-dark-tile-provider">Dark Mode Provider</label>' +
'<select id="cv2-dark-tile-provider" data-cv2-dark-tile-provider style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
optionsDark +
'</select></div>';
}
return html;
}
function _renderHome() {
var eff = _getEffective();
var h = eff.home || {};
var steps = h.steps || [];
var checklist = h.checklist || [];
var footerLinks = h.footerLinks || [];
var stepsHtml = steps.map(function (s, i) {
return '<div class="cust-list-item">' +
'<div class="cust-list-row">' +
// Glyph preview + freeform input. EMOJI-OK-LEGACY-RENDER: legacy
// operator emoji values still display via renderConfigGlyph().
'<span class="cust-emoji-preview" aria-hidden="true" style="min-width:1em;display:inline-block">' + renderConfigGlyph(s.emoji) + '</span>' +
'<input class="cust-emoji-input" data-cv2-home="steps.' + i + '.emoji" value="' + escAttr(s.emoji) + '" placeholder="ph:broadcast">' +
'<input data-cv2-home="steps.' + i + '.title" value="' + escAttr(s.title) + '" placeholder="Title">' +
'<button class="cust-list-btn" data-cv2-move="steps.' + i + '.up"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-up"/></svg></button>' +
'<button class="cust-list-btn" data-cv2-move="steps.' + i + '.down"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-caret-down"/></svg></button>' +
'<button class="cust-list-btn danger" data-cv2-rm="steps.' + i + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>' +
'</div>' +
'<textarea data-cv2-home="steps.' + i + '.description" placeholder="Description" rows="2">' + esc(s.description) + '</textarea>' +
'<div class="cust-md-hint">Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>[text](url)</code></div></div>';
}).join('');
var checkHtml = checklist.map(function (c, i) {
return '<div class="cust-list-item">' +
'<div class="cust-list-row"><input data-cv2-home="checklist.' + i + '.question" value="' + escAttr(c.question) + '" placeholder="Question">' +
'<button class="cust-list-btn danger" data-cv2-rm="checklist.' + i + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button></div>' +
'<textarea data-cv2-home="checklist.' + i + '.answer" placeholder="Answer" rows="2">' + esc(c.answer) + '</textarea></div>';
}).join('');
var linksHtml = footerLinks.map(function (l, i) {
return '<div class="cust-list-item">' +
'<div class="cust-list-row"><input data-cv2-home="footerLinks.' + i + '.label" value="' + escAttr(l.label) + '" placeholder="Label (e.g. ph:package Packets)">' +
'<button class="cust-list-btn danger" data-cv2-rm="footerLinks.' + i + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button></div>' +
'<input data-cv2-home="footerLinks.' + i + '.url" value="' + escAttr(l.url) + '" placeholder="URL"></div>';
}).join('');
return '<div class="cust-panel' + (_activeTab === 'home' ? ' active' : '') + '" data-panel="home">' +
'<div class="cust-field"><label>Hero Title' + _overrideDot('home', 'heroTitle') + '</label>' +
'<input type="text" data-cv2-field="home.heroTitle" value="' + escAttr(h.heroTitle || '') + '"></div>' +
'<div class="cust-field"><label>Hero Subtitle' + _overrideDot('home', 'heroSubtitle') + '</label>' +
'<input type="text" data-cv2-field="home.heroSubtitle" value="' + escAttr(h.heroSubtitle || '') + '"></div>' +
'<p class="cust-section-title" style="margin-top:20px">Steps</p>' + stepsHtml +
'<button class="cust-add-btn" data-cv2-add="steps">+ Add Step</button>' +
'<p class="cust-section-title" style="margin-top:24px">FAQ / Checklist</p>' + checkHtml +
'<button class="cust-add-btn" data-cv2-add="checklist">+ Add Question</button>' +
'<p class="cust-section-title" style="margin-top:24px">Footer Links</p>' + linksHtml +
'<button class="cust-add-btn" data-cv2-add="footerLinks">+ Add Link</button>' +
'</div>';
}
function _renderGeoFilter() {
return '<div class="cust-panel' + (_activeTab === 'geofilter' ? ' active' : '') + '" data-panel="geofilter">' +
'<p class="cust-section-title">Geographic Filter</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Shows the active geographic filter. Nodes outside this area are excluded at ingest time and in API responses.</p>' +
'<div style="position:relative;margin-bottom:8px">' +
'<div id="cv2-gf-map" style="height:200px;border-radius:6px;border:1px solid var(--border);background:var(--surface-1);cursor:pointer"></div>' +
'<div style="position:absolute;top:7px;right:7px;background:rgba(255,255,255,0.88);border-radius:4px;padding:3px 8px;font-size:11px;color:#444;pointer-events:none;box-shadow:0 1px 3px rgba(0,0,0,0.15)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> click to expand</div>' +
'</div>' +
'<div id="cv2-gf-status" style="font-size:12px;color:var(--text-muted);margin-bottom:10px">Loading current filter…</div>' +
// Edit controls — hidden until server confirms write access (writeEnabled=true)
'<div id="cv2-gf-edit" style="display:none">' +
'<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">' +
'<label style="font-size:12px;color:var(--text-muted)">Buffer km:</label>' +
'<input type="number" id="cv2-gf-buffer" value="20" min="0" max="500" style="width:64px;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-size:12px">' +
'</div>' +
'<div class="cust-field"><label>Server API Key</label>' +
'<input type="password" id="cv2-gf-apikey" placeholder="apiKey from config.json" style="width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-size:12px">' +
'</div>' +
'<div style="display:flex;gap:8px;margin-top:12px">' +
'<button id="cv2-gf-save" style="padding:7px 16px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500">Save to server</button>' +
'<button id="cv2-gf-remove" style="padding:7px 14px;background:var(--surface-1);color:var(--status-red);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:13px">Remove filter</button>' +
'</div>' +
'<div id="cv2-gf-msg" style="margin-top:8px;font-size:12px;display:none"></div>' +
// Prune section — only shown when a polygon is active (toggled in _initGeoFilterTab)
'<div id="cv2-gf-prune-section" style="display:none;margin-top:16px;border-top:1px solid var(--border);padding-top:14px">' +
'<p class="cust-section-title" style="font-size:13px;margin-bottom:6px">Prune historical nodes</p>' +
'<p style="font-size:11px;color:var(--text-muted);margin-bottom:10px">Remove nodes already in the database that fall outside the current filter. Run once after first enabling geo filtering.</p>' +
'<button id="cv2-gf-prune-preview" style="padding:6px 14px;background:var(--surface-1);color:var(--text-muted);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:12px">Preview prune</button>' +
'<div id="cv2-gf-prune-result" style="display:none;margin-top:10px">' +
'<div id="cv2-gf-prune-list" style="font-size:11px;color:var(--text-muted);max-height:100px;overflow-y:auto;margin-bottom:8px;background:var(--surface-1);border:1px solid var(--border);border-radius:4px;padding:6px 8px"></div>' +
'<button id="cv2-gf-prune-confirm" style="padding:6px 14px;background:var(--status-red);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Delete nodes</button>' +
'</div>' +
'<div id="cv2-gf-prune-msg" style="margin-top:8px;font-size:12px;display:none"></div>' +
'</div>' +
'</div>' +
'</div>';
}
function _gfOpenModal(container) {
var existing = document.getElementById('cv2-gf-modal-overlay');
if (existing) existing.remove();
if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; }
var overlay = document.createElement('div');
overlay.id = 'cv2-gf-modal-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:99999;display:flex;align-items:center;justify-content:center;';
var dialog = document.createElement('div');
dialog.style.cssText = 'width:92vw;height:86vh;background:#fff;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.4);';
var toolbarEl = document.createElement('div');
toolbarEl.style.cssText = 'padding:10px 14px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #e0e0e0;background:#f5f5f5;flex-shrink:0;';
var title = document.createElement('span');
title.style.cssText = 'font-weight:600;color:#333;font-size:14px;';
title.textContent = _gfWriteEnabled ? 'Edit GeoFilter — click map to add points' : 'GeoFilter — read only';
toolbarEl.appendChild(title);
if (_gfWriteEnabled) {
var undoBtn = document.createElement('button');
undoBtn.id = 'cv2-gfm-undo';
undoBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-arrow-u-up-left"/></svg> Undo';
undoBtn.style.cssText = 'padding:5px 10px;background:#eee;color:#555;border:1px solid #ccc;border-radius:6px;cursor:pointer;font-size:12px;';
var clearBtn = document.createElement('button');
clearBtn.id = 'cv2-gfm-clear';
clearBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg> Clear';
clearBtn.style.cssText = 'padding:5px 10px;background:#fee;color:#c44;border:1px solid #fcc;border-radius:6px;cursor:pointer;font-size:12px;';
var countEl = document.createElement('span');
countEl.id = 'cv2-gfm-count';
countEl.style.cssText = 'font-size:12px;color:#888;';
var spacer = document.createElement('span');
spacer.style.cssText = 'flex:1;';
var doneBtn = document.createElement('button');
doneBtn.id = 'cv2-gfm-done';
doneBtn.textContent = 'Done';
doneBtn.style.cssText = 'padding:7px 18px;background:#4a9eff;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;';
toolbarEl.appendChild(undoBtn);
toolbarEl.appendChild(clearBtn);
toolbarEl.appendChild(countEl);
toolbarEl.appendChild(spacer);
toolbarEl.appendChild(doneBtn);
} else {
var spacer2 = document.createElement('span');
spacer2.style.cssText = 'flex:1;';
toolbarEl.appendChild(spacer2);
}
var closeBtn = document.createElement('button');
closeBtn.id = 'cv2-gfm-close';
closeBtn.textContent = _gfWriteEnabled ? 'Cancel' : 'Close';
closeBtn.style.cssText = 'padding:7px 14px;background:#eee;color:#555;border:1px solid #ccc;border-radius:6px;cursor:pointer;font-size:13px;';
toolbarEl.appendChild(closeBtn);
var mapDiv = document.createElement('div');
mapDiv.id = 'cv2-gf-modal-map';
mapDiv.style.cssText = 'flex:1;';
dialog.appendChild(toolbarEl);
dialog.appendChild(mapDiv);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
var modalPoints = _gfPoints.map(function (p) { return [p[0], p[1]]; });
var modalMarkers = [];
var modalPolygon = null;
var modalClosingLine = null;
_gfModalMap = L.map(mapDiv, { zoomControl: true });
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
}).addTo(_gfModalMap);
function renderModal() {
if (modalPolygon) { _gfModalMap.removeLayer(modalPolygon); modalPolygon = null; }
if (modalClosingLine) { _gfModalMap.removeLayer(modalClosingLine); modalClosingLine = null; }
modalMarkers.forEach(function (m) { _gfModalMap.removeLayer(m); });
modalMarkers = [];
modalPoints.forEach(function (pt, i) {
var m = L.circleMarker(pt, { radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9 })
.addTo(_gfModalMap)
.bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8] });
modalMarkers.push(m);
});
if (modalPoints.length >= 3) {
modalPolygon = L.polygon(modalPoints, { color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12 }).addTo(_gfModalMap);
} else if (modalPoints.length === 2) {
modalClosingLine = L.polyline(modalPoints, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(_gfModalMap);
}
var ce = document.getElementById('cv2-gfm-count');
if (ce) ce.textContent = modalPoints.length + ' point' + (modalPoints.length !== 1 ? 's' : '');
}
function closeModal() {
if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; }
overlay.remove();
}
setTimeout(function () {
_gfModalMap.invalidateSize();
renderModal();
if (modalPoints.length >= 3) {
_gfModalMap.fitBounds(L.latLngBounds(modalPoints), { padding: [40, 40] });
} else {
_gfModalMap.setView([50.5, 4.4], 5);
}
}, 80);
if (_gfWriteEnabled) {
_gfModalMap.on('click', function (e) {
modalPoints.push([parseFloat(e.latlng.lat.toFixed(6)), parseFloat(e.latlng.lng.toFixed(6))]);
renderModal();
});
document.getElementById('cv2-gfm-undo').addEventListener('click', function () {
if (!modalPoints.length) return;
modalPoints.pop();
renderModal();
});
document.getElementById('cv2-gfm-clear').addEventListener('click', function () {
modalPoints = [];
renderModal();
});
document.getElementById('cv2-gfm-done').addEventListener('click', function () {
_gfPoints = modalPoints;
_gfRender();
var prune = container.querySelector('#cv2-gf-prune-section');
if (prune) prune.style.display = _gfPoints.length >= 3 ? '' : 'none';
_gfStatus(container, _gfPoints.length + ' point' + (_gfPoints.length !== 1 ? 's' : '') + '.');
closeModal();
});
}
closeBtn.addEventListener('click', closeModal);
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
}
function _gfRender() {
if (!_gfMap) return;
if (_gfPolygon) { _gfMap.removeLayer(_gfPolygon); _gfPolygon = null; }
if (_gfClosingLine) { _gfMap.removeLayer(_gfClosingLine); _gfClosingLine = null; }
_gfMarkers.forEach(function (m) { _gfMap.removeLayer(m); });
_gfMarkers = [];
_gfPoints.forEach(function (pt, i) {
var m = L.circleMarker(pt, { radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9 })
.addTo(_gfMap)
.bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8] });
_gfMarkers.push(m);
});
if (_gfPoints.length >= 3) {
_gfPolygon = L.polygon(_gfPoints, { color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12 }).addTo(_gfMap);
} else if (_gfPoints.length === 2) {
_gfClosingLine = L.polyline(_gfPoints, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(_gfMap);
}
}
function _gfStatus(container, msg) {
var el = container.querySelector('#cv2-gf-status');
if (el) el.textContent = msg;
}
function _gfMsg(container, msg, ok) {
var el = container.querySelector('#cv2-gf-msg');
if (!el) return;
el.textContent = msg;
el.style.display = msg ? '' : 'none';
el.style.color = ok ? 'var(--status-green)' : 'var(--status-red)';
}
function _gfSave(container) {
if (_gfPoints.length < 3) { _gfMsg(container, 'Need at least 3 polygon points.', false); return; }
var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || '';
if (!apiKey) { _gfMsg(container, 'API key required to save.', false); return; }
var bufferKm = parseFloat((container.querySelector('#cv2-gf-buffer') || {}).value) || 0;
fetch('/api/config/geo-filter', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
body: JSON.stringify({ polygon: _gfPoints, bufferKm: bufferKm })
}).then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); });
_gfMsg(container, 'Saved. Filter is active immediately.', true);
_gfStatus(container, _gfPoints.length + ' points · bufferKm=' + bufferKm + ' · saved');
}).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); });
}
function _gfRemove(container) {
var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || '';
if (!apiKey) { _gfMsg(container, 'API key required.', false); return; }
if (!confirm('Remove geo filter? All nodes will be allowed through.')) return;
fetch('/api/config/geo-filter', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
body: JSON.stringify({ polygon: null })
}).then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); });
_gfPoints = []; _gfLoaded = true;
_gfRender();
_gfStatus(container, 'No geo filter. Click the map to draw a polygon.');
_gfMsg(container, 'Geo filter removed.', true);
}).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); });
}
var _gfPruneNodes = []; // nodes returned by last dry-run preview
function _gfPruneMsg(container, msg, ok) {
var el = container.querySelector('#cv2-gf-prune-msg');
if (!el) return;
el.textContent = msg;
el.style.display = msg ? '' : 'none';
el.style.color = ok ? 'var(--status-green)' : 'var(--status-red)';
}
function _gfPrunePreview(container) {
var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || '';
if (!apiKey) { _gfPruneMsg(container, 'API key required.', false); return; }
var btn = container.querySelector('#cv2-gf-prune-preview');
if (btn) btn.textContent = 'Loading…';
fetch('/api/admin/prune-geo-filter', {
method: 'POST',
headers: { 'X-API-Key': apiKey }
}).then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); });
return r.json();
}).then(function (data) {
if (btn) btn.textContent = 'Preview prune';
_gfPruneNodes = data.nodes || [];
var count = data.count || 0;
var resultEl = container.querySelector('#cv2-gf-prune-result');
var listEl = container.querySelector('#cv2-gf-prune-list');
var confirmBtn = container.querySelector('#cv2-gf-prune-confirm');
if (!resultEl || !listEl || !confirmBtn) return;
if (count === 0) {
_gfPruneMsg(container, 'No nodes outside the filter. Nothing to prune.', true);
resultEl.style.display = 'none';
return;
}
listEl.innerHTML = _gfPruneNodes.map(function (n) {
var coords = n.lat != null ? (' · ' + n.lat.toFixed(4) + ', ' + n.lon.toFixed(4)) : '';
return '<div>' + (n.name || n.pubkey.slice(0, 12)) + coords + '</div>';
}).join('');
confirmBtn.textContent = 'Delete ' + count + ' node' + (count !== 1 ? 's' : '');
resultEl.style.display = '';
_gfPruneMsg(container, '', true);
}).catch(function (e) {
if (btn) btn.textContent = 'Preview prune';
_gfPruneMsg(container, 'Error: ' + e.message, false);
});
}
function _gfPruneConfirm(container) {
if (!_gfPruneNodes.length) { _gfPruneMsg(container, 'Run preview first.', false); return; }
var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || '';
if (!apiKey) { _gfPruneMsg(container, 'API key required.', false); return; }
var count = _gfPruneNodes.length;
if (!confirm('Delete ' + count + ' node' + (count !== 1 ? 's' : '') + ' from the database? This cannot be undone.')) return;
var pubkeys = _gfPruneNodes.map(function (n) { return n.pubkey; });
fetch('/api/admin/prune-geo-filter?confirm=true', {
method: 'POST',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkeys: pubkeys })
}).then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); });
return r.json();
}).then(function (data) {
_gfPruneNodes = [];
var resultEl = container.querySelector('#cv2-gf-prune-result');
if (resultEl) resultEl.style.display = 'none';
var n = data.deleted;
_gfPruneMsg(container, 'Deleted ' + n + ' node' + (n !== 1 ? 's' : '') + '.', true);
}).catch(function (e) { _gfPruneMsg(container, 'Error: ' + e.message, false); });
}
function _initGeoFilterTab(container) {
var mapEl = container.querySelector('#cv2-gf-map');
if (!mapEl || typeof L === 'undefined') return;
_gfMap = L.map(mapEl, { zoomControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, touchZoom: false });
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
}).addTo(_gfMap);
if (!_gfLoaded) {
api('/config/geo-filter', { ttl: 0 }).then(function (gf) {
// Show edit controls only on servers that have a write-capable API key configured
if (gf && gf.writeEnabled) {
_gfWriteEnabled = true;
var editEl = container.querySelector('#cv2-gf-edit');
if (editEl) editEl.style.display = '';
}
if (gf && gf.polygon && gf.polygon.length >= 3) {
_gfPoints = gf.polygon.map(function (p) { return [p[0], p[1]]; });
var buf = container.querySelector('#cv2-gf-buffer');
if (buf) buf.value = gf.bufferKm || 0;
_gfRender();
if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] });
_gfStatus(container, gf.polygon.length + ' points · bufferKm=' + (gf.bufferKm || 0));
// Show prune section when a polygon is active and write access is available
if (gf.writeEnabled) {
var pruneEl = container.querySelector('#cv2-gf-prune-section');
if (pruneEl) pruneEl.style.display = '';
}
} else {
_gfPoints = [];
_gfStatus(container, gf && gf.writeEnabled ? 'No geo filter. Click the map to open the editor.' : 'No geo filter configured.');
_gfMap.setView([50.5, 4.4], 5);
}
_gfLoaded = true;
setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100);
}).catch(function () {
_gfStatus(container, 'Could not load current filter.');
_gfMap.setView([50.5, 4.4], 5);
_gfLoaded = true;
setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100);
});
} else {
if (_gfPoints.length >= 3) {
_gfRender();
if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] });
_gfStatus(container, _gfPoints.length + ' points.');
} else {
_gfMap.setView([50.5, 4.4], 5);
_gfStatus(container, _gfPoints.length ? _gfPoints.length + ' points (need at least 3).' : 'Click the map to draw a polygon.');
_gfRender();
}
setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100);
}
_gfMap.on('click', function () { _gfOpenModal(container); });
container.querySelector('#cv2-gf-save').addEventListener('click', function () { _gfSave(container); });
container.querySelector('#cv2-gf-remove').addEventListener('click', function () { _gfRemove(container); });
var prunePreviewBtn = container.querySelector('#cv2-gf-prune-preview');
var pruneConfirmBtn = container.querySelector('#cv2-gf-prune-confirm');
if (prunePreviewBtn) prunePreviewBtn.addEventListener('click', function () { _gfPrunePreview(container); });
if (pruneConfirmBtn) pruneConfirmBtn.addEventListener('click', function () { _gfPruneConfirm(container); });
}
function _renderExport() {
var delta = readOverrides();
var json = JSON.stringify(delta, null, 2);
var hasDelta = Object.keys(delta).length > 0;
return '<div class="cust-panel' + (_activeTab === 'export' ? ' active' : '') + '" data-panel="export">' +
'<p class="cust-section-title">Export / Import</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Your customizations are stored in your browser. Export to share or back up.</p>' +
'<div class="cust-export-btns">' +
'<button class="cust-dl-btn" id="cv2Download"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-floppy-disk"/></svg> Download JSON</button>' +
'<button class="cust-dl-btn" id="cv2ImportFile"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-folder-open"/></svg> Import File</button>' +
'<input type="file" id="cv2ImportInput" accept=".json,application/json" style="display:none">' +
'<button class="cust-copy-btn" id="cv2Copy"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy</button>' +
'</div>' +
(hasDelta ? '<div style="margin-top:12px"><button class="cust-reset-all" id="cv2ResetAll"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-trash"/></svg> Reset All Customizations</button></div>' : '') +
'<details style="margin-top:12px"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted)">Raw JSON</summary>' +
'<textarea id="cv2ExportJson" style="width:100%;min-height:200px;font-family:var(--mono);font-size:12px;background:var(--surface-1);border:1px solid var(--border);border-radius:6px;padding:12px;color:var(--text);resize:vertical;box-sizing:border-box;margin-top:8px">' + esc(json) + '</textarea>' +
'</details>' +
'<p class="cust-section-title" style="margin-top:20px">Tools</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:10px">Server-side configuration helpers.</p>' +
'<a href="/geofilter-builder.html" target="_blank" style="display:inline-block;padding:7px 14px;background:var(--surface-1);border:1px solid var(--border);border-radius:6px;color:var(--accent);font-size:13px;text-decoration:none;font-weight:500"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg> GeoFilter Builder →</a>' +
'<p style="font-size:11px;color:var(--text-muted);margin-top:6px">Draw a polygon on the map to generate a <code style="font-family:var(--mono)">geo_filter</code> block for <code style="font-family:var(--mono)">config.json</code>.</p>' +
'</div>';
}
function _renderPanel(container) {
container.innerHTML =
_renderTabs() +
'<div class="cust-body">' +
_renderBranding() +
_renderTheme() +
_renderNodes() +
_renderHome() +
_renderDisplay() +
_renderGeoFilter() +
_renderExport() +
'</div>';
_bindEvents(container);
}
/** Remove phantom overrides that match server defaults on startup */
function _cleanPhantomOverrides() {
var delta = readOverrides();
if (!delta || Object.keys(delta).length === 0) return;
var server = _serverDefaults || {};
var changed = false;
// Clean object sections
for (var i = 0; i < OBJECT_SECTIONS.length; i++) {
var sec = OBJECT_SECTIONS[i];
if (!delta[sec] || typeof delta[sec] !== 'object') continue;
var serverSec = server[sec];
// If server has no defaults for this section, only remove values that
// are clearly phantom (empty arrays/objects or undefined equivalents).
// Non-trivial values may be legitimate user choices.
if (!serverSec) {
var dKeys = Object.keys(delta[sec]);
for (var di = 0; di < dKeys.length; di++) {
var dv = delta[sec][dKeys[di]];
var isPhantom = (Array.isArray(dv) && dv.length === 0) ||
(typeof dv === 'object' && dv !== null && !Array.isArray(dv) && Object.keys(dv).length === 0);
if (isPhantom) { delete delta[sec][dKeys[di]]; changed = true; }
}
if (Object.keys(delta[sec]).length === 0) { delete delta[sec]; changed = true; }
continue;
}
var keys = Object.keys(delta[sec]);
for (var j = 0; j < keys.length; j++) {
var k = keys[j];
var ov = delta[sec][k];
var sv = serverSec[k];
var match = false;
if (typeof ov === 'object' || typeof sv === 'object') {
match = JSON.stringify(ov) === JSON.stringify(sv);
} else {
match = ov === sv;
}
if (match) { delete delta[sec][k]; changed = true; }
}
if (Object.keys(delta[sec]).length === 0) { delete delta[sec]; changed = true; }
}
// Clean scalar sections
for (var si = 0; si < SCALAR_SECTIONS.length; si++) {
var sk = SCALAR_SECTIONS[si];
if (delta.hasOwnProperty(sk) && delta[sk] === server[sk]) {
delete delta[sk]; changed = true;
}
}
if (changed) writeOverrides(delta);
}
function _refreshPanel() {
if (!_panelEl) return;
var inner = _panelEl.querySelector('.cust-inner');
if (inner) _renderPanel(inner);
}
function _bindEvents(container) {
// Tab switching
container.querySelectorAll('.cust-tab').forEach(function (btn) {
btn.addEventListener('click', function () {
if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove();
_activeTab = btn.dataset.tab;
_renderPanel(container);
});
});
// GeoFilter tab init
if (_activeTab === 'geofilter') _initGeoFilterTab(container);
// #1361 Colorblind preset radio — switches preset via MeshCorePresets.applyPreset
// #1446 — empty-value radio = "no preset" → clearPreset(), then re-run
// the customizer pipeline so server-config colors take over.
container.querySelectorAll('[data-cv2-cb-preset]').forEach(function (radio) {
radio.addEventListener('change', function () {
if (!radio.checked) return;
var id = radio.value;
var MCP = window.MeshCorePresets;
if (!MCP) return;
if (!id) {
if (typeof MCP.clearPreset === 'function') MCP.clearPreset();
_runPipeline();
} else if (typeof MCP.applyPreset === 'function') {
MCP.applyPreset(id);
}
_refreshPanel();
});
});
// #1380 — Brettel/Vienot sim overlay radio: toggle body[data-cb-sim].
// Preview-only — no persistence.
container.querySelectorAll('[data-cv2-cb-sim]').forEach(function (radio) {
radio.addEventListener('change', function () {
if (!radio.checked) return;
_applyCbSim(radio.value || '');
});
});
// #1380 — Reset-to-default-Wong button.
container.querySelectorAll('[data-cv2-cb-reset]').forEach(function (btn) {
btn.addEventListener('click', function () {
_resetCbPreset();
_refreshPanel();
});
});
// #1420 Dark-tile provider dropdown — persists + fires mc-tile-provider-changed
container.querySelectorAll('[data-cv2-dark-tile-provider]').forEach(function (sel) {
sel.addEventListener('change', function () {
var id = sel.value;
if (typeof window.MC_setDarkTileProvider === 'function') {
window.MC_setDarkTileProvider(id);
}
});
});
// Light-tile provider dropdown — persists + fires mc-tile-provider-changed
container.querySelectorAll('[data-cv2-light-tile-provider]').forEach(function (sel) {
sel.addEventListener('change', function () {
var id = sel.value;
if (typeof window.MC_setLightTileProvider === 'function') {
window.MC_setLightTileProvider(id);
}
});
});
// #1454 Show-encrypted-channels checkbox — persists + fires
// mc-channels-show-encrypted-changed; channels.js re-fetches live.
container.querySelectorAll('[data-cv2-channels-show-encrypted]').forEach(function (cb) {
cb.addEventListener('change', function () {
var on = !!cb.checked;
try {
if (on) localStorage.setItem('channels-show-encrypted', 'true');
else localStorage.removeItem('channels-show-encrypted');
} catch (_e) { /* private mode etc. */ }
window.dispatchEvent(new CustomEvent('mc-channels-show-encrypted-changed', {
detail: { value: on }
}));
});
});
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.dataset.preset;
var p = PRESETS[id];
if (!p) return;
// "Reset to Default" preset = clear all overrides (full reset per spec)
if (id === 'default') {
localStorage.removeItem(STORAGE_KEY);
} else {
// Other presets: write preset data as delta (replaces entire delta per spec)
var delta = {};
if (p.theme) delta.theme = JSON.parse(JSON.stringify(p.theme));
if (p.themeDark) delta.themeDark = JSON.parse(JSON.stringify(p.themeDark));
if (p.nodeColors) delta.nodeColors = JSON.parse(JSON.stringify(p.nodeColors));
if (p.typeColors) delta.typeColors = JSON.parse(JSON.stringify(p.typeColors));
writeOverrides(delta);
}
_runPipeline();
_renderPanel(container);
});
});
// Override dot reset buttons
container.querySelectorAll('.cv2-override-dot').forEach(function (dot) {
dot.addEventListener('click', function (e) {
e.stopPropagation();
var s = dot.dataset.resetS || null;
var k = dot.dataset.resetK;
if (s === '') s = null;
clearOverride(s, k);
});
});
// Text/color inputs (unified via data-cv2-field="section.key")
container.querySelectorAll('[data-cv2-field]').forEach(function (inp) {
var parts = inp.dataset.cv2Field.split('.');
var section = parts[0];
var key = parts[1];
// Optimistic CSS for color pickers on input event
if (inp.type === 'color') {
inp.addEventListener('input', function () {
// Optimistic CSS update (Decision #12)
var cssVar = THEME_CSS_MAP[key];
if (cssVar) document.documentElement.style.setProperty(cssVar, inp.value);
// Mirror to logo brand vars so the wordmark recolors live too.
if (key === 'accent') document.documentElement.style.setProperty('--logo-accent', inp.value);
if (key === 'accentHover') document.documentElement.style.setProperty('--logo-accent-hi', inp.value);
// #1488 — marker stroke color also gets a live CSS-var write
// so the operator sees outlines repaint on drag.
if (section === 'markerStroke' && key === 'color') {
document.documentElement.style.setProperty('--mc-marker-stroke-color', inp.value);
}
// Update hex display
var hex = inp.parentElement.querySelector('.cust-hex');
if (hex) hex.textContent = inp.value;
// Update node dot
var dot = inp.parentElement.querySelector('.cust-node-dot');
if (dot) dot.style.background = inp.value;
});
inp.addEventListener('change', function () {
setOverride(section, key, inp.value);
});
} else {
// Text inputs — debounced write on input
inp.addEventListener('input', function () {
setOverride(section, key, inp.value);
// Live branding updates
if (section === 'branding' && key === 'siteName') {
_setBrandAlt(inp.value);
var el = document.querySelector('.brand-text');
if (el) el.textContent = inp.value;
document.title = inp.value;
}
if (section === 'branding' && key === 'logoUrl') {
_setBrandLogoUrl(inp.value || '', null);
var iconEl = document.querySelector('.brand-icon');
if (iconEl) {
if (inp.value) iconEl.innerHTML = '<img src="' + inp.value + '" style="height:24px" onerror="this.style.display=\'none\'">';
else iconEl.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg>';
}
}
if (section === 'branding' && key === 'faviconUrl') {
var link = document.querySelector('link[rel="icon"]');
if (link && inp.value) link.href = inp.value;
}
if (section === 'branding' && key === 'homeUrl') {
// #1518 — live nav-brand[href] swap. Validator silently drops
// bogus schemes so typing 'javascript:' never sets the attribute.
_setBrandHomeUrl(inp.value);
}
});
}
});
// Select elements
container.querySelectorAll('[data-cv2-select]').forEach(function (sel) {
sel.addEventListener('change', function () {
var parts = sel.dataset.cv2Select.split('.');
if (parts.length === 1) {
setOverride(null, parts[0], sel.value);
} else {
setOverride(parts[0], parts[1], sel.value);
// Show/hide absolute-only fields
if (parts[1] === 'defaultMode') {
container.querySelectorAll('[data-ts-abs]').forEach(function (el) {
el.style.display = sel.value === 'absolute' ? '' : 'none';
});
}
}
window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
});
});
// Slider inputs (heatmap opacity)
container.querySelectorAll('[data-cv2-slider]').forEach(function (inp) {
var key = inp.dataset.cv2Slider;
inp.addEventListener('input', function () {
var pct = parseInt(inp.value);
var label = key === 'heatmapOpacity' ? document.getElementById('cv2HeatPct') : document.getElementById('cv2LiveHeatPct');
if (label) label.textContent = pct + '%';
var opacity = pct / 100;
// Optimistic: update heatmap layer directly
if (key === 'heatmapOpacity' && window._meshcoreHeatLayer) {
var canvas = window._meshcoreHeatLayer._canvas || (window._meshcoreHeatLayer.getContainer && window._meshcoreHeatLayer.getContainer());
if (canvas) canvas.style.opacity = opacity;
}
if (key === 'liveHeatmapOpacity' && window._meshcoreLiveHeatLayer) {
var canvas2 = window._meshcoreLiveHeatLayer._canvas || (window._meshcoreLiveHeatLayer.getContainer && window._meshcoreLiveHeatLayer.getContainer());
if (canvas2) canvas2.style.opacity = opacity;
}
});
inp.addEventListener('change', function () {
setOverride(null, key, parseInt(inp.value) / 100);
});
});
// #1488 — Marker stroke width/opacity sliders. Color picker uses the
// generic data-cv2-field handler (markerStroke.color) above.
container.querySelectorAll('[data-cv2-marker-stroke]').forEach(function (inp) {
var which = inp.dataset.cv2MarkerStroke; // 'width' | 'opacity'
inp.addEventListener('input', function () {
var raw = parseFloat(inp.value);
if (!isFinite(raw)) return;
if (which === 'opacity') {
var lbl = document.getElementById('cv2MarkerStrokeO');
if (lbl) lbl.textContent = Math.round(raw) + '%';
// Optimistic CSS write so the markers repaint on drag.
document.documentElement.style.setProperty('--mc-marker-stroke-opacity', String(raw / 100));
} else {
var lblW = document.getElementById('cv2MarkerStrokeW');
if (lblW) lblW.textContent = String(raw);
document.documentElement.style.setProperty('--mc-marker-stroke-width', String(raw));
}
});
inp.addEventListener('change', function () {
var raw = parseFloat(inp.value);
if (!isFinite(raw)) return;
var eff = _getEffective();
var current = JSON.parse(JSON.stringify(eff.markerStroke || {}));
if (which === 'opacity') current.opacity = raw / 100;
else current.width = raw;
// Persist the whole object so partial picks survive (color +
// width + opacity coexist in a single section).
var delta = JSON.parse(JSON.stringify(readOverrides()));
delta.markerStroke = Object.assign({}, delta.markerStroke || {}, current);
writeOverrides(delta);
_runPipeline();
});
});
// Home page list editing
container.querySelectorAll('[data-cv2-home]').forEach(function (inp) {
inp.addEventListener('input', function () {
// Parse: steps.0.title → home.steps[0].title
var path = inp.dataset.cv2Home.split('.');
var eff = _getEffective();
var home = JSON.parse(JSON.stringify(eff.home || {}));
var arr = home[path[0]];
if (arr && arr[parseInt(path[1])]) {
arr[parseInt(path[1])][path[2]] = inp.value;
setOverride('home', path[0], arr);
}
// #1648 M6 (M5 CDP carry-forward): live re-render of the glyph
// preview when an operator types a new `ph:<name>` token or
// legacy emoji into a steps.N.emoji input. Without this, the
// .cust-emoji-preview swatch only updated on Save+reload.
if (inp.classList.contains('cust-emoji-input')) {
var preview = inp.previousElementSibling;
if (preview && preview.classList && preview.classList.contains('cust-emoji-preview')) {
preview.innerHTML = renderConfigGlyph(inp.value);
}
}
});
});
// Home list move/remove
container.querySelectorAll('[data-cv2-move]').forEach(function (btn) {
btn.addEventListener('click', function () {
var parts = btn.dataset.cv2Move.split('.');
var listKey = parts[0];
var idx = parseInt(parts[1]);
var dir = parts[2] === 'up' ? -1 : 1;
var eff = _getEffective();
var home = JSON.parse(JSON.stringify(eff.home || {}));
var arr = home[listKey];
if (!arr) return;
var j = idx + dir;
if (j < 0 || j >= arr.length) return;
var tmp = arr[idx]; arr[idx] = arr[j]; arr[j] = tmp;
setOverride('home', listKey, arr);
});
});
container.querySelectorAll('[data-cv2-rm]').forEach(function (btn) {
btn.addEventListener('click', function () {
var parts = btn.dataset.cv2Rm.split('.');
var listKey = parts[0];
var idx = parseInt(parts[1]);
var eff = _getEffective();
var home = JSON.parse(JSON.stringify(eff.home || {}));
var arr = home[listKey];
if (!arr) return;
arr.splice(idx, 1);
setOverride('home', listKey, arr);
});
});
container.querySelectorAll('[data-cv2-add]').forEach(function (btn) {
btn.addEventListener('click', function () {
var listKey = btn.dataset.cv2Add;
var eff = _getEffective();
var home = JSON.parse(JSON.stringify(eff.home || {}));
var arr = home[listKey] || [];
if (listKey === 'steps') arr.push({ emoji: 'ph:push-pin', title: '', description: '' });
else if (listKey === 'checklist') arr.push({ question: '', answer: '' });
else if (listKey === 'footerLinks') arr.push({ label: '', url: '' });
setOverride('home', listKey, arr);
});
});
// Export buttons
var dlBtn = document.getElementById('cv2Download');
if (dlBtn) dlBtn.addEventListener('click', function () {
var json = JSON.stringify(readOverrides(), null, 2);
var blob = new Blob([json], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'corescope-theme.json';
a.click();
URL.revokeObjectURL(a.href);
});
var copyBtn = document.getElementById('cv2Copy');
if (copyBtn) copyBtn.addEventListener('click', function () {
var json = JSON.stringify(readOverrides(), null, 2);
if (window.copyToClipboard) {
window.copyToClipboard(json, function () {
copyBtn.textContent = 'Copied!';
setTimeout(function () { copyBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy'; }, 2000);
});
}
});
// Import
var importBtn = document.getElementById('cv2ImportFile');
var importInput = document.getElementById('cv2ImportInput');
if (importBtn && importInput) {
importBtn.addEventListener('click', function () { importInput.click(); });
importInput.addEventListener('change', function () {
var file = importInput.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function () {
try {
var data = JSON.parse(reader.result);
var result = validateShape(data);
if (!result.valid) {
importBtn.textContent = result.errors[0];
setTimeout(function () { importBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-folder-open"/></svg> Import File'; }, 3000);
return;
}
writeOverrides(data);
_runPipeline();
_renderPanel(container);
importBtn.textContent = 'Imported!';
setTimeout(function () { importBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-folder-open"/></svg> Import File'; }, 2000);
} catch (e) {
importBtn.textContent = 'Invalid JSON';
setTimeout(function () { importBtn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-folder-open"/></svg> Import File'; }, 3000);
}
};
reader.readAsText(file);
importInput.value = '';
});
}
// Reset All — #1496: clear every customizer-touched piece of state,
// not just STORAGE_KEY. Implementation lives in resetAll() on the
// public API so tests can drive it without a DOM button.
var resetBtn = document.getElementById('cv2ResetAll');
if (resetBtn) resetBtn.addEventListener('click', function () {
if (!confirm('Reset all customizations to server defaults?')) return;
_resetAll();
_runPipeline();
_renderPanel(container);
});
// Reset gesture hints (#1065)
var hintsBtn = container.querySelector('[data-cv2-reset-hints]');
if (hintsBtn) hintsBtn.addEventListener('click', function () {
if (window.GestureHints && typeof window.GestureHints.reset === 'function') {
window.GestureHints.reset();
} else {
// Fallback: clear known keys directly.
['row-swipe', 'tab-swipe', 'edge-drawer', 'pull-refresh'].forEach(function (k) {
try { localStorage.removeItem('meshcore-gesture-hints-' + k); } catch (_e) {}
});
}
});
}
// ── Panel toggle ──
function toggle() {
if (_panelEl) {
_panelEl.classList.toggle('hidden');
if (!_panelEl.classList.contains('hidden')) _refreshPanel();
return;
}
_injectStyles();
_panelEl = document.createElement('div');
_panelEl.className = 'cust-overlay';
_panelEl.innerHTML =
'<div class="cust-header"><h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-palette"/></svg> Customize</h2><button class="cust-close" title="Close"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button></div>' +
'<div class="cv2-local-banner">These settings are saved in your browser only and don\'t affect other users.</div>' +
'<div class="cust-inner"></div>' +
'<div class="cv2-footer"><span id="cv2-save-status">All changes saved</span></div>';
document.body.appendChild(_panelEl);
_panelEl.querySelector('.cust-close').addEventListener('click', function () {
if (_gfMap) { _gfMap.remove(); _gfMap = null; _gfMarkers = []; _gfPolygon = null; _gfClosingLine = null; } if (_gfModalMap) { _gfModalMap.remove(); _gfModalMap = null; } var _ov = document.getElementById('cv2-gf-modal-overlay'); if (_ov) _ov.remove();
_panelEl.classList.add('hidden');
});
// Drag support
var header = _panelEl.querySelector('.cust-header');
header.addEventListener('mousedown', function (e) {
if (e.target.closest('.cust-close')) return;
var dragX = _panelEl.offsetLeft, dragY = _panelEl.offsetTop;
var startX = e.clientX, startY = e.clientY;
var onMove = function (ev) {
_panelEl.style.left = Math.max(0, dragX + ev.clientX - startX) + 'px';
_panelEl.style.top = Math.max(56, dragY + ev.clientY - startY) + 'px';
_panelEl.style.right = 'auto';
};
var onUp = function () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
_renderPanel(_panelEl.querySelector('.cust-inner'));
_updateSaveStatus(_saveStatus);
}
// ── Initialization (runs immediately on script load) ──
// 1. Migration check
migrateOldKeys();
// #1446 — when a CB preset is cleared (or applied), re-run the customizer
// pipeline so server-config nodeColors take over the --mc-role-{role}
// CSS vars (the gating logic in applyCSS checks the body[data-cb-preset]
// attribute to decide whether to write them).
try {
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('cb-preset-changed', function () {
if (_initDone) _runPipeline();
});
}
} catch (e) {}
// 2. Read overrides and apply CSS immediately (before DOMContentLoaded)
// Server defaults will be set later when /api/config/theme completes.
// For now, apply whatever overrides exist on top of current SITE_CONFIG.
var earlyOverrides = readOverrides();
if (Object.keys(earlyOverrides).length > 0) {
var earlyServer = window.SITE_CONFIG || {};
var earlyEffective = computeEffective(earlyServer, earlyOverrides);
// Don't fully overwrite SITE_CONFIG yet — just apply CSS vars
var dark = isDarkMode();
var themeSection = dark
? Object.assign({}, earlyEffective.theme || {}, earlyEffective.themeDark || {})
: (earlyEffective.theme || {});
var root = document.documentElement.style;
for (var key in THEME_CSS_MAP) {
if (themeSection[key]) root.setProperty(THEME_CSS_MAP[key], themeSection[key]);
}
// Mirror accent → logo brand vars ONLY when present in overrides (so the
// server-default accent never clobbers the sage/teal :root brand defaults).
var ovTheme = dark
? Object.assign({}, earlyOverrides.theme || {}, earlyOverrides.themeDark || {})
: (earlyOverrides.theme || {});
if (ovTheme.accent) root.setProperty('--logo-accent', ovTheme.accent);
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface2 || themeSection.surface1);
// Apply node colors from overrides early — --node-X CSS var only.
// #1412: do NOT write to window.ROLE_COLORS / ROLE_STYLE here.
if (earlyOverrides.nodeColors) {
for (var role in earlyOverrides.nodeColors) {
root.setProperty('--node-' + role, earlyOverrides.nodeColors[role]);
}
}
if (earlyOverrides.typeColors && window.TYPE_COLORS) {
for (var type in earlyOverrides.typeColors) {
if (type in window.TYPE_COLORS) window.TYPE_COLORS[type] = earlyOverrides.typeColors[type];
}
if (window.syncBadgeColors) window.syncBadgeColors();
}
}
// 3. Wire up toggle button + watch dark mode changes (needs DOM)
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('customizeToggle');
if (btn) btn.addEventListener('click', toggle);
// Re-apply branding from overrides once DOM is ready
var overrides = readOverrides();
if (overrides.branding) {
if (overrides.branding.siteName) {
_setBrandAlt(overrides.branding.siteName);
var brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = overrides.branding.siteName;
document.title = overrides.branding.siteName;
}
if (overrides.branding.logoUrl) {
_setBrandLogoUrl(overrides.branding.logoUrl, overrides.branding.siteName || null);
var iconEl = document.querySelector('.brand-icon');
if (iconEl) iconEl.innerHTML = '<img src="' + overrides.branding.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
}
if (overrides.branding.faviconUrl) {
var link = document.querySelector('link[rel="icon"]');
if (link) link.href = overrides.branding.faviconUrl;
}
// #1518 — re-apply homeUrl override after DOM is ready (handles cold
// page loads where the customizer pipeline ran before .nav-brand mounted).
if (overrides.branding.homeUrl) {
_setBrandHomeUrl(overrides.branding.homeUrl);
}
}
// Watch dark/light mode toggle and re-apply
new MutationObserver(function () {
_runPipeline();
if (_panelEl && !_panelEl.classList.contains('hidden')) _refreshPanel();
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
});
// ── Reset All (#1496) ──
//
// The original "Reset All" only cleared STORAGE_KEY (cs-theme-overrides),
// leaving CB-preset, encrypted-channel toggle, dark-tile pick, marker
// stroke vars and per-role body.style writes stuck — each subsequent
// customizer feature (PRs #1430, #1454, #1488, #1448) added its own
// out-of-band side effect and forgot to teach Reset about it.
//
// Single source of truth: this function. Adding a new customizer
// feature requires adding its teardown here. Each removal is wrapped
// in try/catch because we run before/after page lifecycle in tests
// and operators' browsers where any one surface (localStorage,
// document, body) could be missing.
function _resetAll() {
// 1. localStorage — customizer-owned keys ONLY. Theme / favorites /
// gesture hints / channel selection state are explicitly preserved
// (see issue #1496 "Out of scope").
var CUSTOMIZER_LS_KEYS = [
STORAGE_KEY, // 'cs-theme-overrides'
'meshcore-cb-preset', // #1361 CB preset id
'channels-show-encrypted', // #1454 encrypted-channel toggle
'mc-dark-tile-provider' // #1430 dark-tile provider pick
];
for (var i = 0; i < CUSTOMIZER_LS_KEYS.length; i++) {
try { localStorage.removeItem(CUSTOMIZER_LS_KEYS[i]); } catch (_e) { /* ignore */ }
}
// 2. CB preset — body[data-cb-preset] + CSS var teardown lives in
// MeshCorePresets.clearPreset(). Prefer the canonical helper so
// the dispatch (cb-preset-changed event) fires and downstream
// consumers re-sync to server config without a reload.
try {
var MCP = (typeof window !== 'undefined') && window.MeshCorePresets;
if (MCP && typeof MCP.clearPreset === 'function') {
MCP.clearPreset();
} else if (typeof document !== 'undefined' && document.body) {
// Defensive fallback (e.g. cb-presets.js failed to load).
document.body.removeAttribute('data-cb-preset');
}
} catch (_e) { /* ignore */ }
// 3. body.style — #1446/#1448 wrote --mc-role-{role} / -text with
// !important so they trump :root. Strip per role + text role.
try {
if (typeof document !== 'undefined' && document.body && document.body.style) {
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
document.body.style.removeProperty('--mc-role-' + role);
document.body.style.removeProperty('--mc-role-' + role + '-text');
});
}
} catch (_e) { /* ignore */ }
// 4. :root style — everything the customizer pipeline + cb-presets
// + marker-stroke + tile providers ever wrote onto documentElement.
// A fresh _runPipeline() will re-set whatever server config /
// remaining (theme-only) preferences dictate.
try {
if (typeof document !== 'undefined' && document.documentElement && document.documentElement.style) {
var rs = document.documentElement.style;
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
rs.removeProperty('--mc-role-' + role);
rs.removeProperty('--mc-role-' + role + '-text');
rs.removeProperty('--node-' + role);
});
// #1488 marker stroke vars.
rs.removeProperty('--mc-marker-stroke-color');
rs.removeProperty('--mc-marker-stroke-width');
rs.removeProperty('--mc-marker-stroke-opacity');
// CB-preset MB / RT-ramp vars (also cleared by clearPreset above
// but stripped here defensively in case clearPreset is absent).
['confirmed', 'suspected', 'unknown'].forEach(function (k) {
rs.removeProperty('--mc-mb-' + k);
});
for (var ri = 0; ri < 5; ri++) rs.removeProperty('--mc-rt-ramp-' + ri);
// Theme vars the customizer might have stamped onto :root via
// applyCSS (logo accents + every entry in THEME_CSS_MAP).
rs.removeProperty('--logo-accent');
rs.removeProperty('--logo-accent-hi');
for (var tkey in THEME_CSS_MAP) {
if (Object.prototype.hasOwnProperty.call(THEME_CSS_MAP, tkey)) {
rs.removeProperty(THEME_CSS_MAP[tkey]);
}
}
}
} catch (_e) { /* ignore */ }
// 5. Tile provider — clearing the localStorage key (step 1) takes
// care of persistence, but a live consumer needs the
// mc-tile-provider-changed event to swap back to the server
// default. Re-applying the effective active id achieves that
// without forcing a particular value (it falls through to the
// server default / DEFAULT_ID inside getActiveId()).
try {
if (typeof window !== 'undefined' &&
typeof window.MC_setDarkTileProvider === 'function' &&
typeof window.MC_getDarkTileProvider === 'function') {
window.MC_setDarkTileProvider(window.MC_getDarkTileProvider());
// Re-clear so we don't re-persist the just-re-applied value.
try { localStorage.removeItem('mc-dark-tile-provider'); } catch (_e) {}
}
} catch (_e) { /* ignore */ }
// 6. Encrypted-channel toggle — removing the LS key (step 1) fixes
// persistence, but channels.js subscribes to
// mc-channels-show-encrypted-changed for live re-render. Fire it
// explicitly with on:false so the channels list reverts to "hide
// encrypted" without a page reload.
try {
if (typeof window !== 'undefined' &&
typeof window.dispatchEvent === 'function' &&
typeof window.CustomEvent === 'function') {
window.dispatchEvent(new window.CustomEvent('mc-channels-show-encrypted-changed', {
detail: { on: false }
}));
}
} catch (_e) { /* ignore */ }
}
// ── Public API for app.js integration ──
/**
* Called by app.js after /api/config/theme fetch completes.
* Sets server defaults and runs the full pipeline.
*/
window._customizerV2 = {
init: function (serverConfig) {
_serverDefaults = serverConfig || {};
_cleanPhantomOverrides();
_runPipeline();
_initDone = true;
},
/** True after init() has been called with server config and pipeline has run */
get initDone() { return _initDone; },
readOverrides: readOverrides,
writeOverrides: writeOverrides,
computeEffective: computeEffective,
setOverride: setOverride,
clearOverride: clearOverride,
migrateOldKeys: migrateOldKeys,
validateShape: validateShape,
applyCSS: applyCSS,
isValidColor: isValidColor,
isValidHomeUrl: isValidHomeUrl,
isOverridden: _isOverridden,
// #1496 — full reset (not just STORAGE_KEY). See _resetAll() above.
resetAll: _resetAll,
// Exposed for tests — see test-issue-1509-detect-preset.js.
detectActivePreset: _detectActivePreset,
// #1380 — exposed for unit tests; see test-issue-1380-*.
applyCbSim: _applyCbSim,
resetCbPreset: _resetCbPreset,
THEME_CSS_MAP: THEME_CSS_MAP
};
})();