diff --git a/public/customize.js b/public/customize.js
index afb325e2..2fc6bd16 100644
--- a/public/customize.js
+++ b/public/customize.js
@@ -1,1384 +1,1388 @@
-/* === CoreScope β customize.js === */
-/* Tools β Customization: visual config builder with live preview & JSON export */
-'use strict';
-
-(function () {
- let styleEl = null;
- let originalValues = {};
- let activeTab = 'branding';
-
- const DEFAULTS = {
- branding: {
- siteName: 'CoreScope',
- tagline: 'Real-time MeshCore LoRa mesh network analyzer',
- logoUrl: '',
- faviconUrl: ''
- },
- theme: {
- accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
- statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
- accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#5b6370', border: '#e2e5ea',
- surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
- detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
- font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
- mono: '"SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace',
- },
- themeDark: {
- accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
- statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
- accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#a8b8cc', border: '#334155',
- surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
- detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
- font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
- mono: '"SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace',
- },
- nodeColors: {
- repeater: '#dc2626',
- companion: '#2563eb',
- room: '#16a34a',
- sensor: '#d97706',
- observer: '#8b5cf6'
- },
- typeColors: {
- ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
- REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
- ANON_REQ: '#f43f5e'
- },
- home: {
- heroTitle: 'CoreScope',
- heroSubtitle: 'Find your nodes to start monitoring them.',
- steps: [
- { emoji: 'π¬', title: 'Join the Bay Area MeshCore Discord', description: 'The community Discord is the best place to get help and find local mesh enthusiasts.' },
- { emoji: 'π΅', title: 'Connect via Bluetooth', description: 'Flash BLE companion firmware and pair with your device.' },
- { emoji: 'π»', title: 'Set the right frequency preset', description: 'Match the frequency preset used by your local mesh community.' },
- { emoji: 'π‘', title: 'Advertise yourself', description: 'Send an ADVERT so repeaters and observers can see you.' },
- { emoji: 'π', title: 'Check "Heard N repeats"', description: 'Verify your node is being relayed through the mesh.' },
- { emoji: 'π', title: 'Repeaters near you?', description: 'Check the map for nearby repeaters and coverage.' }
- ],
- checklist: [],
- footerLinks: [
- { label: 'π¦ Packets', url: '#/packets' },
- { label: 'πΊοΈ Network Map', url: '#/map' },
- { label: 'π΄ Live', url: '#/live' },
- { label: 'π‘ All Nodes', url: '#/nodes' },
- { label: 'π¬ Channels', url: '#/channels' }
- ]
- },
- ui: {
- timestampMode: 'ago'
- }
- };
-
- // CSS variable name β theme key mapping
- const THEME_CSS_MAP = {
- // Basic
- accent: '--accent',
- navBg: '--nav-bg',
- navText: '--nav-text',
- background: '--surface-0',
- text: '--text',
- statusGreen: '--status-green',
- statusYellow: '--status-yellow',
- statusRed: '--status-red',
- // Advanced (derived from basic by default)
- accentHover: '--accent-hover',
- navBg2: '--nav-bg2',
- navTextMuted: '--nav-text-muted',
- textMuted: '--text-muted',
- border: '--border',
- surface1: '--surface-1',
- surface2: '--surface-2',
- 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',
- };
-
- /* ββ Theme Presets ββ */
- const THEME_COLOR_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed',
- 'accentHover', 'navBg2', 'navTextMuted', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg',
- 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
-
- const PRESETS = {
- default: {
- name: 'Default', desc: 'MeshCore blue',
- preview: ['#4a9eff', '#0f0f23', '#f4f5f7', '#1a1a2e', '#22c55e'],
- light: {
- accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
- statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
- accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#5b6370', border: '#e2e5ea',
- surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
- detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
- },
- dark: {
- accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
- statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
- accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#a8b8cc', border: '#334155',
- surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
- detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
- }
- },
- ocean: {
- name: 'Ocean', desc: 'Deep blues & teals',
- preview: ['#0077b6', '#03045e', '#f0f7fa', '#48cae4', '#15803d'],
- light: {
- 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',
- },
- dark: {
- 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',
- }
- },
- forest: {
- name: 'Forest', desc: 'Greens & earth tones',
- preview: ['#2d6a4f', '#1b3a2d', '#f2f7f4', '#52b788', '#15803d'],
- light: {
- 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',
- },
- dark: {
- 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',
- }
- },
- sunset: {
- name: 'Sunset', desc: 'Warm oranges & ambers',
- preview: ['#c2410c', '#431407', '#fef7f2', '#fb923c', '#dc2626'],
- light: {
- 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',
- },
- dark: {
- 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',
- }
- },
- mono: {
- name: 'Monochrome', desc: 'Pure grays, no color',
- preview: ['#525252', '#171717', '#f5f5f5', '#a3a3a3', '#737373'],
- light: {
- 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',
- },
- dark: {
- 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',
- }
- },
- highContrast: {
- name: 'High Contrast', desc: 'WCAG AAA, max readability',
- preview: ['#0050a0', '#000000', '#ffffff', '#66b3ff', '#006400'],
- light: {
- 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',
- },
- dark: {
- 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',
- },
- 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'],
- light: {
- 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',
- },
- dark: {
- 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',
- }
- },
- ember: {
- name: 'Ember', desc: 'Warm red/orange, cyberpunk',
- preview: ['#dc2626', '#1a0a0a', '#faf5f5', '#ef4444', '#15803d'],
- light: {
- 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',
- },
- dark: {
- 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',
- }
- }
- };
-
- function detectActivePreset() {
- for (var id in PRESETS) {
- var p = PRESETS[id];
- var match = true;
- for (var i = 0; i < THEME_COLOR_KEYS.length; i++) {
- var k = THEME_COLOR_KEYS[i];
- if (state.theme[k] !== p.light[k] || state.themeDark[k] !== p.dark[k]) { match = false; break; }
- }
- if (match && p.nodeColors) {
- for (var nk in p.nodeColors) { if (state.nodeColors[nk] !== p.nodeColors[nk]) { match = false; break; } }
- }
- if (match && p.typeColors) {
- for (var tk in p.typeColors) { if (state.typeColors[tk] !== p.typeColors[tk]) { match = false; break; } }
- }
- if (match) return id;
- }
- return null;
- }
-
- function renderPresets(container) {
- var active = detectActivePreset();
- var html = '
' +
- '
Theme Presets
' +
- '
';
- 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 += '
';
- }
- html += '
' +
- '' + dots + '
' +
- '' + esc(p.name) + ' ' +
- '' + esc(p.desc) + ' ' +
- ' ';
- }
- html += '
';
- return html;
- }
-
- function applyPreset(id, container) {
- var p = PRESETS[id];
- if (!p) return;
- // Apply light theme colors
- for (var i = 0; i < THEME_COLOR_KEYS.length; i++) {
- var k = THEME_COLOR_KEYS[i];
- state.theme[k] = p.light[k];
- state.themeDark[k] = p.dark[k];
- }
- // Apply node/type colors
- if (p.nodeColors) {
- Object.assign(state.nodeColors, p.nodeColors);
- if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, p.nodeColors);
- if (window.ROLE_STYLE) {
- for (var role in p.nodeColors) {
- if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = p.nodeColors[role];
- }
- }
- } else {
- // Reset to defaults
- Object.assign(state.nodeColors, DEFAULTS.nodeColors);
- if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, DEFAULTS.nodeColors);
- }
- if (p.typeColors) {
- Object.assign(state.typeColors, p.typeColors);
- if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, p.typeColors);
- } else {
- Object.assign(state.typeColors, DEFAULTS.typeColors);
- if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, DEFAULTS.typeColors);
- }
- applyThemePreview();
- if (window.syncBadgeColors) window.syncBadgeColors();
- window.dispatchEvent(new CustomEvent('theme-changed'));
- autoSave();
- render(container);
- }
-
- const BASIC_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed'];
- const ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
- const FONT_KEYS = ['font', 'mono'];
-
- const THEME_LABELS = {
- accent: 'Brand Color',
- navBg: 'Navigation',
- navText: 'Nav Text',
- background: 'Background',
- text: 'Text',
- statusGreen: 'Healthy',
- statusYellow: 'Warning',
- statusRed: 'Error',
- accentHover: 'Accent Hover',
- navBg2: 'Nav Gradient End',
- navTextMuted: 'Nav Muted Text',
- textMuted: 'Muted Text',
- border: 'Borders',
- surface1: 'Cards',
- surface2: 'Panels',
- 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',
- };
-
- const 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',
- textMuted: 'Labels, timestamps, secondary text',
- border: 'Dividers, table borders, card borders',
- surface1: 'Card and panel backgrounds',
- surface2: 'Nested surfaces, secondary panels',
- 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',
- };
-
- const NODE_LABELS = {
- repeater: 'Repeater',
- companion: 'Companion',
- room: 'Room Server',
- sensor: 'Sensor',
- observer: 'Observer'
- };
-
- const NODE_HINTS = {
- repeater: 'Infrastructure nodes that relay packets β map markers, packet path badges, node list',
- companion: 'End-user devices β map markers, packet detail, node list',
- room: 'Room/chat server nodes β map markers, node list',
- sensor: 'Sensor/telemetry nodes β map markers, node list',
- observer: 'MQTT observer stations β map markers (purple stars), observer list, packet headers'
- };
-
- const NODE_EMOJI = { repeater: 'β', companion: 'β', room: 'β ', sensor: 'β²', observer: 'β
' };
-
- const 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'
- };
- const TYPE_HINTS = {
- ADVERT: 'Node advertisements β map, feed, packet list',
- GRP_TXT: 'Group/channel messages β map, feed, channels',
- TXT_MSG: 'Direct messages β map, feed',
- ACK: 'Acknowledgments β packet list',
- REQUEST: 'Requests β packet list, feed',
- RESPONSE: 'Responses β packet list',
- TRACE: 'Traceroute β map, traces page',
- PATH: 'Path packets β packet list',
- ANON_REQ: 'Encrypted anonymous requests β sender identity hidden via ephemeral key'
- };
- const TYPE_EMOJI = {
- ADVERT: 'π‘', GRP_TXT: 'π¬', TXT_MSG: 'βοΈ', ACK: 'β', REQUEST: 'β', RESPONSE: 'π¨', TRACE: 'π', PATH: 'π€οΈ', ANON_REQ: 'π΅οΈ'
- };
-
- // Current state
- let state = {};
-
- function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
-
- function initState() {
- const cfg = window.SITE_CONFIG || {};
- // Merge: DEFAULTS β server config β localStorage saved values
- var local = {};
- try { var s = localStorage.getItem('meshcore-user-theme'); if (s) local = JSON.parse(s); } catch {}
- var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
- var serverTsMode = (cfg.timestamps && cfg.timestamps.defaultMode === 'absolute') ? 'absolute' : 'ago';
- var mergedUi = Object.assign({}, DEFAULTS.ui, cfg.ui || {}, local.ui || {});
- mergedUi.timestampMode = (localTsMode === 'ago' || localTsMode === 'absolute')
- ? localTsMode
- : (mergedUi.timestampMode === 'absolute' || serverTsMode === 'absolute' ? 'absolute' : 'ago');
- state = {
- branding: Object.assign({}, DEFAULTS.branding, cfg.branding || {}, local.branding || {}),
- theme: Object.assign({}, DEFAULTS.theme, cfg.theme || {}, local.theme || {}),
- themeDark: Object.assign({}, DEFAULTS.themeDark, cfg.themeDark || {}, local.themeDark || {}),
- nodeColors: Object.assign({}, DEFAULTS.nodeColors, cfg.nodeColors || {}, local.nodeColors || {}),
- typeColors: Object.assign({}, DEFAULTS.typeColors, cfg.typeColors || {}, local.typeColors || {}),
- home: {
- heroTitle: (local.home && local.home.heroTitle) || (cfg.home && cfg.home.heroTitle) || DEFAULTS.home.heroTitle,
- heroSubtitle: (local.home && local.home.heroSubtitle) || (cfg.home && cfg.home.heroSubtitle) || DEFAULTS.home.heroSubtitle,
- steps: deepClone((local.home && local.home.steps) || (cfg.home && cfg.home.steps) || DEFAULTS.home.steps),
- checklist: deepClone((local.home && local.home.checklist) || (cfg.home && cfg.home.checklist) || DEFAULTS.home.checklist),
- footerLinks: deepClone((local.home && local.home.footerLinks) || (cfg.home && cfg.home.footerLinks) || DEFAULTS.home.footerLinks)
- },
- ui: mergedUi
- };
- }
-
- function isDarkMode() {
- return document.documentElement.getAttribute('data-theme') === 'dark' ||
- (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
- }
-
- function activeTheme() { return isDarkMode() ? state.themeDark : state.theme; }
- function activeDefaults() { return isDarkMode() ? DEFAULTS.themeDark : DEFAULTS.theme; }
-
- function saveOriginalCSS() {
- var cs = getComputedStyle(document.documentElement);
- originalValues = {};
- for (var key in THEME_CSS_MAP) {
- originalValues[key] = cs.getPropertyValue(THEME_CSS_MAP[key]).trim();
- }
- }
-
- function applyThemePreview() {
- var t = activeTheme();
- for (var key in THEME_CSS_MAP) {
- if (t[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], t[key]);
- }
- // Derived vars that reference other vars β need explicit override
- if (t.background) {
- document.documentElement.style.setProperty('--content-bg', t.background);
- }
- if (t.surface1) {
- document.documentElement.style.setProperty('--card-bg', t.surface1);
- }
- // Force nav bar to re-render gradient
- var nav = document.querySelector('.top-nav');
- if (nav) {
- nav.style.background = 'none';
- void nav.offsetHeight;
- nav.style.background = '';
- }
- // Sync badge CSS from TYPE_COLORS
- if (window.syncBadgeColors) window.syncBadgeColors();
- }
-
- function applyTypeColorCSS() {
- if (window.syncBadgeColors) window.syncBadgeColors();
- }
-
- // Auto-save to localStorage on every change
- let _autoSaveTimer = null;
- function autoSave() {
- if (_autoSaveTimer) clearTimeout(_autoSaveTimer);
- _autoSaveTimer = setTimeout(function() {
- _autoSaveTimer = null;
- try {
- var data = buildExport();
- localStorage.setItem('meshcore-user-theme', JSON.stringify(data));
- // Sync to SITE_CONFIG so live pages (home, etc.) pick up changes
- if (window.SITE_CONFIG) {
- if (state.branding) window.SITE_CONFIG.branding = Object.assign(window.SITE_CONFIG.branding || {}, state.branding);
- if (state.home) window.SITE_CONFIG.home = deepClone(state.home);
- }
- // Re-render current page to reflect home/branding changes
- window.dispatchEvent(new HashChangeEvent('hashchange'));
- } catch (e) { console.error('[customize] autoSave error:', e); }
- }, 500);
- }
-
- function resetPreview() {
- for (var key in THEME_CSS_MAP) {
- document.documentElement.style.removeProperty(THEME_CSS_MAP[key]);
- }
- }
-
- function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
- function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/ 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; }
- .cust-color-row .cust-reset-btn { font-size: 11px; padding: 2px 8px; border: 1px solid var(--border);
- border-radius: 4px; background: var(--surface-2); color: var(--text-muted); cursor: pointer; }
- .cust-color-row .cust-reset-btn:hover { background: var(--surface-3); }
- .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-area { width: 100%; min-height: 300px; 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; }
- .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: 0.9; }
- .cust-dl-btn { background: var(--surface-2); color: var(--text); border: 1px solid var(--border) !important; }
- .cust-save-user { background: #22c55e; color: #fff; }
- .cust-save-user:hover { background: #16a34a; }
- .cust-reset-user { background: var(--surface-2); color: #ef4444; border: 1px solid #ef4444 !important; }
- .cust-reset-user:hover { background: #ef4444; color: #fff; }
- .cust-dl-btn:hover { background: var(--surface-3); }
- .cust-reset-preview { margin-top: 12px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
- background: var(--surface-2); color: var(--text); cursor: pointer; font-size: 13px; }
- .cust-reset-preview:hover { background: var(--surface-3); }
- .cust-instructions { background: var(--surface-1); border: 1px solid var(--border); border-radius: 6px;
- padding: 12px 16px; margin-top: 16px; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
- .cust-instructions code { background: var(--surface-2); padding: 2px 6px; border-radius: 3px; font-family: var(--mono); font-size: 12px; }
- .cust-section-title { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
- @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; }
- .cust-list-item { flex-wrap: wrap; }
- }
- `;
- document.head.appendChild(styleEl);
- }
-
- function removeStyles() {
- if (styleEl) { styleEl.remove(); styleEl = null; }
- }
-
- function renderTabs() {
- var tabs = [
- { id: 'branding', label: 'π·οΈ', title: 'Branding' },
- { id: 'theme', label: 'π¨', title: 'Theme Colors' },
- { id: 'nodes', label: 'π―', title: 'Colors' },
- { id: 'home', label: 'π ', title: 'Home Page' },
- { id: 'display', label: 'π₯οΈ', title: 'Display' },
- { id: 'export', label: 'π€', title: 'Export / Save' }
- ];
- return '' +
- tabs.map(function (t) {
- return '' + t.label + ' ' + t.title + ' ';
- }).join('') + '
';
- }
-
- function renderBranding() {
- var b = state.branding;
- var logoPreview = b.logoUrl ? ' ' : '';
- return '';
- }
-
- function renderDisplay() {
- var tsMode = state.ui.timestampMode === 'absolute' ? 'absolute' : 'ago';
- return '' +
- '
Display Settings
' +
- '
UI preferences that affect how data is shown across pages.
' +
- '
Timestamps
' +
- '
Global setting β applies to all pages.
' +
- '
Timestamp Display ' +
- '' +
- 'Relative (3m ago) ' +
- 'Absolute (ISO timestamp) ' +
- ' ' +
- '
' +
- '
More display controls (UTC/local and format presets) can be added here in future.
' +
- '
';
- }
-
- function renderColorRow(key, val, def, dataAttr) {
- var isFont = key === 'font' || key === 'mono';
- var inputHtml = isFont
- ? ' '
- : ' ' +
- '' + val + ' ';
- return '' +
- '
' + THEME_LABELS[key] + ' ' +
- '
' + (THEME_HINTS[key] || '') + '
' +
- inputHtml +
- (val !== def ? '
Reset ' : '') +
- '
';
- }
-
- function renderTheme() {
- var dark = isDarkMode();
- var modeLabel = dark ? 'π Dark Mode' : 'βοΈ Light Mode';
- var defs = activeDefaults();
- var current = activeTheme();
-
- var basicRows = '';
- for (var i = 0; i < BASIC_KEYS.length; i++) {
- var key = BASIC_KEYS[i];
- basicRows += renderColorRow(key, current[key] || defs[key] || '#000000', defs[key] || '#000000', 'theme');
- }
-
- var advancedRows = '';
- for (var j = 0; j < ADVANCED_KEYS.length; j++) {
- var akey = ADVANCED_KEYS[j];
- advancedRows += renderColorRow(akey, current[akey] || defs[akey] || '#000000', defs[akey] || '#000000', 'theme');
- }
-
- var fontRows = '';
- for (var f = 0; f < FONT_KEYS.length; f++) {
- var fkey = FONT_KEYS[f];
- fontRows += renderColorRow(fkey, current[fkey] || defs[fkey] || '', defs[fkey] || '', 'theme');
- }
-
- return '' +
- renderPresets() +
- '
' + modeLabel + '
' +
- '
Toggle βοΈ/π in nav to edit the other mode.
' +
- basicRows +
- '
Advanced (' + ADVANCED_KEYS.length + ' options) ' +
- advancedRows +
- '' +
- '
Fonts ' +
- fontRows +
- '' +
- '
β© Reset Preview ' +
- '
';
- }
-
- function renderNodes() {
- var rows = '';
- for (var key in NODE_LABELS) {
- var val = state.nodeColors[key];
- var def = DEFAULTS.nodeColors[key];
- rows += '' +
- '
' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + ' ' +
- '
' + (NODE_HINTS[key] || '') + '
' +
- '
' +
- '
' +
- '
' + val + ' ' +
- (val !== def ? '
Reset ' : '') +
- '
';
- }
- var typeRows = '';
- for (var tkey in TYPE_LABELS) {
- var tval = state.typeColors[tkey];
- var tdef = DEFAULTS.typeColors[tkey];
- typeRows += '' +
- '
' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + ' ' +
- '
' + (TYPE_HINTS[tkey] || '') + '
' +
- '
' +
- '
' +
- '
' + tval + ' ' +
- (tval !== tdef ? '
Reset ' : '') +
- '
';
- }
- var heatOpacity = parseFloat(localStorage.getItem('meshcore-heatmap-opacity'));
- if (isNaN(heatOpacity)) heatOpacity = 0.25;
- var heatPct = Math.round(heatOpacity * 100);
- var liveHeatOpacity = parseFloat(localStorage.getItem('meshcore-live-heatmap-opacity'));
- if (isNaN(liveHeatOpacity)) liveHeatOpacity = 0.3;
- var liveHeatPct = Math.round(liveHeatOpacity * 100);
- return '' +
- '
Node Role Colors
' + rows +
- '
' +
- '
Packet Type Colors
' + typeRows +
- '
' +
- '
Heatmap Opacity
' +
- '
' +
- '
πΊοΈ Nodes Map ' +
- '
Heatmap overlay on the Nodes β Map page (0β100%)
' +
- '
' +
- '
' + heatPct + '% ' +
- '
' +
- '
' +
- '
π‘ Live Map ' +
- '
Heatmap overlay on the Live page (0β100%)
' +
- '
' +
- '
' + liveHeatPct + '% ' +
- '
' +
- '
';
- }
-
- function renderHome() {
- var h = state.home;
- var stepsHtml = h.steps.map(function (s, i) {
- return '' +
- '
' +
- ' ' +
- ' ' +
- 'β ' +
- 'β ' +
- 'β ' +
- '
' +
- '
' +
- '
Markdown: **bold** *italic* `code` [text](url) - list
' +
- '
';
- }).join('');
-
- var checkHtml = h.checklist.map(function (c, i) {
- return '' +
- '
' +
- ' ' +
- 'β ' +
- '
' +
- '
' +
- '
Markdown: **bold** *italic* `code` [text](url) - list
' +
- '
';
- }).join('');
-
- var linksHtml = h.footerLinks.map(function (l, i) {
- return '';
- }).join('');
-
- return '' +
- '
Hero Title
' +
- '
Hero Subtitle
' +
- '
Steps
' + stepsHtml +
- '
+ Add Step ' +
- '
FAQ / Checklist
' + checkHtml +
- '
+ Add Question ' +
- '
Footer Links
' + linksHtml +
- '
+ Add Link ' +
- '
';
- }
-
- function buildExport() {
- var out = {};
- // Branding β only changed values
- var bd = {};
- for (var bk in DEFAULTS.branding) {
- if (state.branding[bk] && state.branding[bk] !== DEFAULTS.branding[bk]) bd[bk] = state.branding[bk];
- }
- if (Object.keys(bd).length) out.branding = bd;
-
- // Theme
- var th = {};
- for (var tk in DEFAULTS.theme) {
- if (state.theme[tk] !== DEFAULTS.theme[tk]) th[tk] = state.theme[tk];
- }
- if (Object.keys(th).length) out.theme = th;
-
- // Dark theme
- var thd = {};
- for (var tdk in DEFAULTS.themeDark) {
- if (state.themeDark[tdk] !== DEFAULTS.themeDark[tdk]) thd[tdk] = state.themeDark[tdk];
- }
- if (Object.keys(thd).length) out.themeDark = thd;
-
- // Node colors
- var nc = {};
- for (var nk in DEFAULTS.nodeColors) {
- if (state.nodeColors[nk] !== DEFAULTS.nodeColors[nk]) nc[nk] = state.nodeColors[nk];
- }
- if (Object.keys(nc).length) out.nodeColors = nc;
-
- // Packet type colors
- var tc = {};
- for (var tck in DEFAULTS.typeColors) {
- if (state.typeColors[tck] !== DEFAULTS.typeColors[tck]) tc[tck] = state.typeColors[tck];
- }
- if (Object.keys(tc).length) out.typeColors = tc;
-
- // Home
- var hm = {};
- if (state.home.heroTitle !== DEFAULTS.home.heroTitle) hm.heroTitle = state.home.heroTitle;
- if (state.home.heroSubtitle !== DEFAULTS.home.heroSubtitle) hm.heroSubtitle = state.home.heroSubtitle;
- if (JSON.stringify(state.home.steps) !== JSON.stringify(DEFAULTS.home.steps)) hm.steps = state.home.steps;
- if (JSON.stringify(state.home.checklist) !== JSON.stringify(DEFAULTS.home.checklist)) hm.checklist = state.home.checklist;
- if (JSON.stringify(state.home.footerLinks) !== JSON.stringify(DEFAULTS.home.footerLinks)) hm.footerLinks = state.home.footerLinks;
- if (Object.keys(hm).length) out.home = hm;
-
- // UI
- var ui = {};
- if ((state.ui.timestampMode || 'ago') !== DEFAULTS.ui.timestampMode) ui.timestampMode = state.ui.timestampMode;
- if (Object.keys(ui).length) out.ui = ui;
-
- return out;
- }
-
- function renderExport() {
- var json = JSON.stringify(buildExport(), null, 2);
- var hasUserTheme = !!localStorage.getItem('meshcore-user-theme');
- return '' +
- '
My Preferences
' +
- '
Save these colors just for you β stored in your browser, works on any instance.
' +
- '
' +
- 'πΎ Save as my theme ' +
- (hasUserTheme ? 'ποΈ Reset my theme ' : '') +
- '
' +
- '
' +
- '
Admin
' +
- '
Download or import a theme file. Admins place it as theme.json next to the server.
' +
- '
' +
- 'πΎ Download theme.json ' +
- 'π Import File ' +
- ' ' +
- 'π Copy ' +
- '
' +
- '
Raw JSON ' +
- '' +
- '' +
- '
';
- }
-
- let panelEl = null;
-
- function render(container) {
- container.innerHTML =
- renderTabs() +
- '' +
- renderBranding() +
- renderTheme() +
- renderNodes() +
- renderHome() +
- renderDisplay() +
- renderExport() +
- '
';
- bindEvents(container);
- }
-
- function bindEvents(container) {
- // Tab switching
- container.querySelectorAll('.cust-tab').forEach(function (btn) {
- btn.addEventListener('click', function () {
- activeTab = btn.dataset.tab;
- render(container);
- });
- });
-
- // Preset buttons
- container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
- btn.addEventListener('click', function () {
- applyPreset(btn.dataset.preset, container);
- });
- });
-
- // Text inputs (branding + home hero)
- container.querySelectorAll('input[data-key]').forEach(function (inp) {
- inp.addEventListener('input', function () {
- var parts = inp.dataset.key.split('.');
- if (parts.length === 2) {
- state[parts[0]][parts[1]] = inp.value;
- autoSave();
- }
- // Live DOM updates for branding
- if (inp.dataset.key === 'branding.siteName') {
- var brandEl = document.querySelector('.brand-text');
- if (brandEl) brandEl.textContent = inp.value;
- document.title = inp.value;
- }
- if (inp.dataset.key === 'branding.logoUrl') {
- var iconEl = document.querySelector('.brand-icon');
- if (iconEl) {
- if (inp.value) { iconEl.innerHTML = ' '; }
- else { iconEl.textContent = 'π‘'; }
- }
- }
- if (inp.dataset.key === 'branding.faviconUrl') {
- var link = document.querySelector('link[rel="icon"]');
- if (link && inp.value) link.href = inp.value;
- }
- });
- });
-
- // UI settings
- container.querySelectorAll('select[data-ui]').forEach(function (sel) {
- sel.addEventListener('change', function () {
- var key = sel.dataset.ui;
- state.ui[key] = sel.value;
- if (key === 'timestampMode') {
- localStorage.setItem('meshcore-timestamp-mode', sel.value);
- if (!window.SITE_CONFIG) window.SITE_CONFIG = {};
- if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
- window.SITE_CONFIG.timestamps.defaultMode = sel.value;
- window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
- }
- autoSave();
- });
- });
-
- // Theme color pickers
- container.querySelectorAll('input[data-theme]').forEach(function (inp) {
- inp.addEventListener('input', function () {
- var key = inp.dataset.theme;
- var themeKey = isDarkMode() ? 'themeDark' : 'theme';
- state[themeKey][key] = inp.value;
- var hex = container.querySelector('[data-hex="' + key + '"]');
- if (hex) hex.textContent = inp.value;
- applyThemePreview(); autoSave();
- });
- });
-
- // Theme reset buttons
- container.querySelectorAll('[data-reset-theme]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- var key = btn.dataset.resetTheme;
- var themeKey = isDarkMode() ? 'themeDark' : 'theme';
- state[themeKey][key] = activeDefaults()[key];
- applyThemePreview(); autoSave();
- render(container);
- });
- });
-
- // Reset preview button
- var resetBtn = document.getElementById('custResetPreview');
- if (resetBtn) {
- resetBtn.addEventListener('click', function () {
- state.theme = Object.assign({}, DEFAULTS.theme);
- resetPreview();
- render(container);
- });
- }
-
- // Node color pickers
- container.querySelectorAll('input[data-node]').forEach(function (inp) {
- inp.addEventListener('input', function () {
- var key = inp.dataset.node;
- state.nodeColors[key] = inp.value;
- // Sync to global role colors used by map/packets/etc
- if (window.ROLE_COLORS) window.ROLE_COLORS[key] = inp.value;
- if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = inp.value;
- // Trigger re-render of current page
- window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
- var dot = container.querySelector('[data-dot="' + key + '"]');
- if (dot) dot.style.background = inp.value;
- var hex = container.querySelector('[data-nhex="' + key + '"]');
- if (hex) hex.textContent = inp.value;
- });
- });
-
- // Node reset buttons
- container.querySelectorAll('[data-reset-node]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- var key = btn.dataset.resetNode;
- state.nodeColors[key] = DEFAULTS.nodeColors[key];
- if (window.ROLE_COLORS) window.ROLE_COLORS[key] = DEFAULTS.nodeColors[key];
- if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = DEFAULTS.nodeColors[key];
- render(container);
- });
- });
-
- // Packet type color pickers
- container.querySelectorAll('input[data-type-color]').forEach(function (inp) {
- inp.addEventListener('input', function () {
- var key = inp.dataset.typeColor;
- state.typeColors[key] = inp.value;
- if (window.TYPE_COLORS) window.TYPE_COLORS[key] = inp.value;
- if (window.syncBadgeColors) window.syncBadgeColors();
- window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
- var dot = container.querySelector('[data-tdot="' + key + '"]');
- if (dot) dot.style.background = inp.value;
- var hex = container.querySelector('[data-thex="' + key + '"]');
- if (hex) hex.textContent = inp.value;
- });
- });
- container.querySelectorAll('[data-reset-type]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- var key = btn.dataset.resetType;
- state.typeColors[key] = DEFAULTS.typeColors[key];
- if (window.TYPE_COLORS) window.TYPE_COLORS[key] = DEFAULTS.typeColors[key];
- render(container);
- });
- });
-
- // Heatmap opacity slider
- var heatSlider = container.querySelector('#custHeatOpacity');
- if (heatSlider) {
- heatSlider.addEventListener('input', function () {
- var pct = parseInt(heatSlider.value);
- var label = container.querySelector('#custHeatOpacityVal');
- if (label) label.textContent = pct + '%';
- var opacity = pct / 100;
- localStorage.setItem('meshcore-heatmap-opacity', opacity);
- // Live-update the heatmap if visible β set canvas opacity for whole layer
- if (window._meshcoreHeatLayer) {
- var canvas = window._meshcoreHeatLayer._canvas ||
- (window._meshcoreHeatLayer.getContainer && window._meshcoreHeatLayer.getContainer());
- if (canvas) canvas.style.opacity = opacity;
- }
- });
- }
-
- // Live heatmap opacity slider
- var liveHeatSlider = container.querySelector('#custLiveHeatOpacity');
- if (liveHeatSlider) {
- liveHeatSlider.addEventListener('input', function () {
- var pct = parseInt(liveHeatSlider.value);
- var label = container.querySelector('#custLiveHeatOpacityVal');
- if (label) label.textContent = pct + '%';
- var opacity = pct / 100;
- localStorage.setItem('meshcore-live-heatmap-opacity', opacity);
- // Live-update the live page heatmap if visible
- if (window._meshcoreLiveHeatLayer) {
- var canvas = window._meshcoreLiveHeatLayer._canvas ||
- (window._meshcoreLiveHeatLayer.getContainer && window._meshcoreLiveHeatLayer.getContainer());
- if (canvas) canvas.style.opacity = opacity;
- }
- });
- }
-
- // Steps
- container.querySelectorAll('[data-step-field]').forEach(function (inp) {
- inp.addEventListener('input', function () {
- var i = parseInt(inp.dataset.idx);
- state.home.steps[i][inp.dataset.stepField] = inp.value; autoSave();
- });
- });
- container.querySelectorAll('[data-move-step]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- var i = parseInt(btn.dataset.moveStep);
- var dir = btn.dataset.dir === 'up' ? -1 : 1;
- var j = i + dir;
- if (j < 0 || j >= state.home.steps.length) return;
- var tmp = state.home.steps[i];
- state.home.steps[i] = state.home.steps[j];
- state.home.steps[j] = tmp;
- render(container);
- });
- });
- container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
- render(container);
- });
- });
- var addStepBtn = document.getElementById('addStep');
- if (addStepBtn) addStepBtn.addEventListener('click', function () {
- state.home.steps.push({ emoji: 'π', title: '', description: '' });
- render(container);
- });
-
- // Checklist
- container.querySelectorAll('[data-check-field]').forEach(function (inp) {
- inp.addEventListener('input', function () {
- var i = parseInt(inp.dataset.idx);
- state.home.checklist[i][inp.dataset.checkField] = inp.value; autoSave();
- });
- });
- container.querySelectorAll('[data-rm-check]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- state.home.checklist.splice(parseInt(btn.dataset.rmCheck), 1);
- render(container);
- });
- });
- var addCheckBtn = document.getElementById('addCheck');
- if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
- state.home.checklist.push({ question: '', answer: '' });
- render(container);
- });
-
- // Footer links
- container.querySelectorAll('[data-link-field]').forEach(function (inp) {
- inp.addEventListener('input', function () {
- var i = parseInt(inp.dataset.idx);
- state.home.footerLinks[i][inp.dataset.linkField] = inp.value; autoSave();
- });
- });
- container.querySelectorAll('[data-rm-link]').forEach(function (btn) {
- btn.addEventListener('click', function () {
- state.home.footerLinks.splice(parseInt(btn.dataset.rmLink), 1);
- render(container);
- });
- });
- var addLinkBtn = document.getElementById('addLink');
- if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
- state.home.footerLinks.push({ label: '', url: '' });
- render(container);
- });
-
- // Export copy
- var copyBtn = document.getElementById('custCopy');
- if (copyBtn) copyBtn.addEventListener('click', function () {
- var ta = document.getElementById('custExportJson');
- if (ta) {
- window.copyToClipboard(ta.value, function () {
- copyBtn.textContent = 'β Copied!';
- setTimeout(function () { copyBtn.textContent = 'π Copy to Clipboard'; }, 2000);
- });
- }
- });
-
- // Export download
- var dlBtn = document.getElementById('custDownload');
- if (dlBtn) dlBtn.addEventListener('click', function () {
- var json = JSON.stringify(buildExport(), null, 2);
- var blob = new Blob([json], { type: 'application/json' });
- var a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = 'config-theme.json';
- a.click();
- URL.revokeObjectURL(a.href);
- });
-
- // Save user theme to localStorage
- var saveUserBtn = document.getElementById('custSaveUser');
- if (saveUserBtn) saveUserBtn.addEventListener('click', function () {
- var exportData = buildExport();
- localStorage.setItem('meshcore-user-theme', JSON.stringify(exportData));
- saveUserBtn.textContent = 'β Saved!';
- setTimeout(function () { saveUserBtn.textContent = 'πΎ Save as my theme'; }, 2000);
- });
-
- // Reset user theme
- var resetUserBtn = document.getElementById('custResetUser');
- if (resetUserBtn) resetUserBtn.addEventListener('click', function () {
- localStorage.removeItem('meshcore-user-theme');
- resetPreview();
- initState();
- render(container);
- applyThemePreview(); autoSave();
- });
-
- // Import from file
- var importBtn = document.getElementById('custImportFile');
- var importInput = document.getElementById('custImportInput');
- 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);
- // Merge imported data into state
- if (data.branding) Object.assign(state.branding, data.branding);
- if (data.theme) Object.assign(state.theme, data.theme);
- if (data.themeDark) Object.assign(state.themeDark, data.themeDark);
- if (data.nodeColors) {
- Object.assign(state.nodeColors, data.nodeColors);
- if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, data.nodeColors);
- if (window.ROLE_STYLE) {
- for (var role in data.nodeColors) {
- if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = data.nodeColors[role];
- }
- }
- }
- if (data.typeColors) {
- Object.assign(state.typeColors, data.typeColors);
- if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, data.typeColors);
- }
- if (data.home) {
- if (data.home.heroTitle) state.home.heroTitle = data.home.heroTitle;
- if (data.home.heroSubtitle) state.home.heroSubtitle = data.home.heroSubtitle;
- if (data.home.steps) state.home.steps = deepClone(data.home.steps);
- if (data.home.checklist) state.home.checklist = deepClone(data.home.checklist);
- if (data.home.footerLinks) state.home.footerLinks = deepClone(data.home.footerLinks);
- }
- applyThemePreview();
- autoSave();
- window.dispatchEvent(new CustomEvent('theme-changed'));
- render(container);
- importBtn.textContent = 'β Imported!';
- setTimeout(function () { importBtn.textContent = 'π Import File'; }, 2000);
- } catch (e) {
- importBtn.textContent = 'β Invalid JSON';
- setTimeout(function () { importBtn.textContent = 'π Import File'; }, 3000);
- }
- };
- reader.readAsText(file);
- importInput.value = '';
- });
- }
- }
-
- function toggle() {
- if (panelEl) {
- panelEl.classList.toggle('hidden');
- return;
- }
- // First open β create the panel
- injectStyles();
- saveOriginalCSS();
- initState();
-
- panelEl = document.createElement('div');
- panelEl.className = 'cust-overlay';
- panelEl.innerHTML =
- '' +
- '
';
- document.body.appendChild(panelEl);
-
- panelEl.querySelector('.cust-close').addEventListener('click', () => panelEl.classList.add('hidden'));
-
- // Drag support
- const header = panelEl.querySelector('.cust-header');
- let dragX = 0, dragY = 0, startX = 0, startY = 0;
- header.addEventListener('mousedown', (e) => {
- if (e.target.closest('.cust-close')) return;
- dragX = panelEl.offsetLeft; dragY = panelEl.offsetTop;
- startX = e.clientX; startY = e.clientY;
- const onMove = (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';
- };
- const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
- document.addEventListener('mousemove', onMove);
- document.addEventListener('mouseup', onUp);
- });
-
- render(panelEl.querySelector('.cust-inner'));
- applyThemePreview(); autoSave();
- }
-
- // Restore saved user theme IMMEDIATELY (before DOMContentLoaded, before map/app init)
- // roles.js has already loaded ROLE_COLORS, ROLE_STYLE, TYPE_COLORS at this point
- try {
- const saved = localStorage.getItem('meshcore-user-theme');
- if (saved) {
- const userTheme = JSON.parse(saved);
- const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
- (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
- const themeData = dark ? (userTheme.themeDark || userTheme.theme) : userTheme.theme;
- if (themeData) {
- for (const [key, val] of Object.entries(themeData)) {
- if (THEME_CSS_MAP[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], val);
- }
- // Derived vars
- if (themeData.background) document.documentElement.style.setProperty('--content-bg', themeData.background);
- if (themeData.surface1) document.documentElement.style.setProperty('--card-bg', themeData.surface1);
- }
- if (userTheme.nodeColors) {
- if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, userTheme.nodeColors);
- if (window.ROLE_STYLE) {
- for (const [role, color] of Object.entries(userTheme.nodeColors)) {
- if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
- }
- }
- }
- if (userTheme.typeColors && window.TYPE_COLORS) {
- Object.assign(window.TYPE_COLORS, userTheme.typeColors);
- if (window.syncBadgeColors) window.syncBadgeColors();
- }
- }
- } catch {}
-
- // Wire up toggle button (needs DOM)
- document.addEventListener('DOMContentLoaded', () => {
- const btn = document.getElementById('customizeToggle');
- if (btn) btn.addEventListener('click', toggle);
-
- // Restore branding from localStorage (needs DOM elements to exist)
- try {
- const saved = localStorage.getItem('meshcore-user-theme');
- if (saved) {
- const userTheme = JSON.parse(saved);
- if (userTheme.branding) {
- if (userTheme.branding.siteName) {
- const brandEl = document.querySelector('.brand-text');
- if (brandEl) brandEl.textContent = userTheme.branding.siteName;
- document.title = userTheme.branding.siteName;
- }
- if (userTheme.branding.logoUrl) {
- const iconEl = document.querySelector('.brand-icon');
- if (iconEl) iconEl.innerHTML = ' ';
- }
- if (userTheme.branding.faviconUrl) {
- const link = document.querySelector('link[rel="icon"]');
- if (link) link.href = userTheme.branding.faviconUrl;
- }
- }
- }
- } catch {}
-
- // Watch for dark/light mode toggle and re-apply theme preview
- new MutationObserver(function() {
- if (state.theme) applyThemePreview();
- }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
- });
-})();
+/* === CoreScope β customize.js === */
+/* Tools β Customization: visual config builder with live preview & JSON export */
+'use strict';
+
+(function () {
+ let styleEl = null;
+ let originalValues = {};
+ let activeTab = 'branding';
+
+ const DEFAULTS = {
+ branding: {
+ siteName: 'CoreScope',
+ tagline: 'Real-time MeshCore LoRa mesh network analyzer',
+ logoUrl: '',
+ faviconUrl: ''
+ },
+ theme: {
+ accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
+ statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
+ accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#5b6370', border: '#e2e5ea',
+ surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
+ detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
+ mono: '"SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace',
+ },
+ themeDark: {
+ accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
+ statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
+ accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#a8b8cc', border: '#334155',
+ surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
+ detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
+ mono: '"SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace',
+ },
+ nodeColors: {
+ repeater: '#dc2626',
+ companion: '#2563eb',
+ room: '#16a34a',
+ sensor: '#d97706',
+ observer: '#8b5cf6'
+ },
+ typeColors: {
+ ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
+ REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
+ ANON_REQ: '#f43f5e'
+ },
+ home: {
+ heroTitle: 'CoreScope',
+ heroSubtitle: 'Find your nodes to start monitoring them.',
+ steps: [
+ { emoji: 'π¬', title: 'Join the Bay Area MeshCore Discord', description: 'The community Discord is the best place to get help and find local mesh enthusiasts.' },
+ { emoji: 'π΅', title: 'Connect via Bluetooth', description: 'Flash BLE companion firmware and pair with your device.' },
+ { emoji: 'π»', title: 'Set the right frequency preset', description: 'Match the frequency preset used by your local mesh community.' },
+ { emoji: 'π‘', title: 'Advertise yourself', description: 'Send an ADVERT so repeaters and observers can see you.' },
+ { emoji: 'π', title: 'Check "Heard N repeats"', description: 'Verify your node is being relayed through the mesh.' },
+ { emoji: 'π', title: 'Repeaters near you?', description: 'Check the map for nearby repeaters and coverage.' }
+ ],
+ checklist: [],
+ footerLinks: [
+ { label: 'π¦ Packets', url: '#/packets' },
+ { label: 'πΊοΈ Network Map', url: '#/map' },
+ { label: 'π΄ Live', url: '#/live' },
+ { label: 'π‘ All Nodes', url: '#/nodes' },
+ { label: 'π¬ Channels', url: '#/channels' }
+ ]
+ },
+ ui: {
+ timestampMode: 'ago'
+ }
+ };
+
+ // CSS variable name β theme key mapping
+ const THEME_CSS_MAP = {
+ // Basic
+ accent: '--accent',
+ navBg: '--nav-bg',
+ navText: '--nav-text',
+ background: '--surface-0',
+ text: '--text',
+ statusGreen: '--status-green',
+ statusYellow: '--status-yellow',
+ statusRed: '--status-red',
+ // Advanced (derived from basic by default)
+ accentHover: '--accent-hover',
+ navBg2: '--nav-bg2',
+ navTextMuted: '--nav-text-muted',
+ textMuted: '--text-muted',
+ border: '--border',
+ surface1: '--surface-1',
+ surface2: '--surface-2',
+ 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',
+ };
+
+ /* ββ Theme Presets ββ */
+ const THEME_COLOR_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed',
+ 'accentHover', 'navBg2', 'navTextMuted', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg',
+ 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
+
+ const PRESETS = {
+ default: {
+ name: 'Default', desc: 'MeshCore blue',
+ preview: ['#4a9eff', '#0f0f23', '#f4f5f7', '#1a1a2e', '#22c55e'],
+ light: {
+ accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#f4f5f7', text: '#1a1a2e',
+ statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
+ accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#5b6370', border: '#e2e5ea',
+ surface1: '#ffffff', surface2: '#ffffff', cardBg: '#ffffff', contentBg: '#f4f5f7',
+ detailBg: '#ffffff', inputBg: '#ffffff', rowStripe: '#f9fafb', rowHover: '#eef2ff', selectedBg: '#dbeafe',
+ },
+ dark: {
+ accent: '#4a9eff', navBg: '#0f0f23', navText: '#ffffff', background: '#0f0f23', text: '#e2e8f0',
+ statusGreen: '#22c55e', statusYellow: '#eab308', statusRed: '#ef4444',
+ accentHover: '#6db3ff', navBg2: '#1a1a2e', navTextMuted: '#cbd5e1', textMuted: '#a8b8cc', border: '#334155',
+ surface1: '#1a1a2e', surface2: '#232340', cardBg: '#1a1a2e', contentBg: '#0f0f23',
+ detailBg: '#232340', inputBg: '#1e1e34', rowStripe: '#1e1e34', rowHover: '#2d2d50', selectedBg: '#1e3a5f',
+ }
+ },
+ ocean: {
+ name: 'Ocean', desc: 'Deep blues & teals',
+ preview: ['#0077b6', '#03045e', '#f0f7fa', '#48cae4', '#15803d'],
+ light: {
+ 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',
+ },
+ dark: {
+ 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',
+ }
+ },
+ forest: {
+ name: 'Forest', desc: 'Greens & earth tones',
+ preview: ['#2d6a4f', '#1b3a2d', '#f2f7f4', '#52b788', '#15803d'],
+ light: {
+ 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',
+ },
+ dark: {
+ 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',
+ }
+ },
+ sunset: {
+ name: 'Sunset', desc: 'Warm oranges & ambers',
+ preview: ['#c2410c', '#431407', '#fef7f2', '#fb923c', '#dc2626'],
+ light: {
+ 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',
+ },
+ dark: {
+ 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',
+ }
+ },
+ mono: {
+ name: 'Monochrome', desc: 'Pure grays, no color',
+ preview: ['#525252', '#171717', '#f5f5f5', '#a3a3a3', '#737373'],
+ light: {
+ 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',
+ },
+ dark: {
+ 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',
+ }
+ },
+ highContrast: {
+ name: 'High Contrast', desc: 'WCAG AAA, max readability',
+ preview: ['#0050a0', '#000000', '#ffffff', '#66b3ff', '#006400'],
+ light: {
+ 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',
+ },
+ dark: {
+ 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',
+ },
+ 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'],
+ light: {
+ 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',
+ },
+ dark: {
+ 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',
+ }
+ },
+ ember: {
+ name: 'Ember', desc: 'Warm red/orange, cyberpunk',
+ preview: ['#dc2626', '#1a0a0a', '#faf5f5', '#ef4444', '#15803d'],
+ light: {
+ 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',
+ },
+ dark: {
+ 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',
+ }
+ }
+ };
+
+ function detectActivePreset() {
+ for (var id in PRESETS) {
+ var p = PRESETS[id];
+ var match = true;
+ for (var i = 0; i < THEME_COLOR_KEYS.length; i++) {
+ var k = THEME_COLOR_KEYS[i];
+ if (state.theme[k] !== p.light[k] || state.themeDark[k] !== p.dark[k]) { match = false; break; }
+ }
+ if (match && p.nodeColors) {
+ for (var nk in p.nodeColors) { if (state.nodeColors[nk] !== p.nodeColors[nk]) { match = false; break; } }
+ }
+ if (match && p.typeColors) {
+ for (var tk in p.typeColors) { if (state.typeColors[tk] !== p.typeColors[tk]) { match = false; break; } }
+ }
+ if (match) return id;
+ }
+ return null;
+ }
+
+ function renderPresets(container) {
+ var active = detectActivePreset();
+ var html = '' +
+ '
Theme Presets
' +
+ '
';
+ 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 += '
';
+ }
+ html += '
' +
+ '' + dots + '
' +
+ '' + esc(p.name) + ' ' +
+ '' + esc(p.desc) + ' ' +
+ ' ';
+ }
+ html += '
';
+ return html;
+ }
+
+ function applyPreset(id, container) {
+ var p = PRESETS[id];
+ if (!p) return;
+ // Apply light theme colors
+ for (var i = 0; i < THEME_COLOR_KEYS.length; i++) {
+ var k = THEME_COLOR_KEYS[i];
+ state.theme[k] = p.light[k];
+ state.themeDark[k] = p.dark[k];
+ }
+ // Apply node/type colors
+ if (p.nodeColors) {
+ Object.assign(state.nodeColors, p.nodeColors);
+ if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, p.nodeColors);
+ if (window.ROLE_STYLE) {
+ for (var role in p.nodeColors) {
+ if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = p.nodeColors[role];
+ }
+ }
+ } else {
+ // Reset to defaults
+ Object.assign(state.nodeColors, DEFAULTS.nodeColors);
+ if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, DEFAULTS.nodeColors);
+ }
+ if (p.typeColors) {
+ Object.assign(state.typeColors, p.typeColors);
+ if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, p.typeColors);
+ } else {
+ Object.assign(state.typeColors, DEFAULTS.typeColors);
+ if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, DEFAULTS.typeColors);
+ }
+ applyThemePreview();
+ if (window.syncBadgeColors) window.syncBadgeColors();
+ window.dispatchEvent(new CustomEvent('theme-changed'));
+ autoSave();
+ render(container);
+ }
+
+ const BASIC_KEYS = ['accent', 'navBg', 'navText', 'background', 'text', 'statusGreen', 'statusYellow', 'statusRed'];
+ const ADVANCED_KEYS = ['accentHover', 'navBg2', 'navTextMuted', 'textMuted', 'border', 'surface1', 'surface2', 'cardBg', 'contentBg', 'detailBg', 'inputBg', 'rowStripe', 'rowHover', 'selectedBg'];
+ const FONT_KEYS = ['font', 'mono'];
+
+ const THEME_LABELS = {
+ accent: 'Brand Color',
+ navBg: 'Navigation',
+ navText: 'Nav Text',
+ background: 'Background',
+ text: 'Text',
+ statusGreen: 'Healthy',
+ statusYellow: 'Warning',
+ statusRed: 'Error',
+ accentHover: 'Accent Hover',
+ navBg2: 'Nav Gradient End',
+ navTextMuted: 'Nav Muted Text',
+ textMuted: 'Muted Text',
+ border: 'Borders',
+ surface1: 'Cards',
+ surface2: 'Panels',
+ 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',
+ };
+
+ const 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',
+ textMuted: 'Labels, timestamps, secondary text',
+ border: 'Dividers, table borders, card borders',
+ surface1: 'Card and panel backgrounds',
+ surface2: 'Nested surfaces, secondary panels',
+ 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',
+ };
+
+ const NODE_LABELS = {
+ repeater: 'Repeater',
+ companion: 'Companion',
+ room: 'Room Server',
+ sensor: 'Sensor',
+ observer: 'Observer'
+ };
+
+ const NODE_HINTS = {
+ repeater: 'Infrastructure nodes that relay packets β map markers, packet path badges, node list',
+ companion: 'End-user devices β map markers, packet detail, node list',
+ room: 'Room/chat server nodes β map markers, node list',
+ sensor: 'Sensor/telemetry nodes β map markers, node list',
+ observer: 'MQTT observer stations β map markers (purple stars), observer list, packet headers'
+ };
+
+ const NODE_EMOJI = { repeater: 'β', companion: 'β', room: 'β ', sensor: 'β²', observer: 'β
' };
+
+ const 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'
+ };
+ const TYPE_HINTS = {
+ ADVERT: 'Node advertisements β map, feed, packet list',
+ GRP_TXT: 'Group/channel messages β map, feed, channels',
+ TXT_MSG: 'Direct messages β map, feed',
+ ACK: 'Acknowledgments β packet list',
+ REQUEST: 'Requests β packet list, feed',
+ RESPONSE: 'Responses β packet list',
+ TRACE: 'Traceroute β map, traces page',
+ PATH: 'Path packets β packet list',
+ ANON_REQ: 'Encrypted anonymous requests β sender identity hidden via ephemeral key'
+ };
+ const TYPE_EMOJI = {
+ ADVERT: 'π‘', GRP_TXT: 'π¬', TXT_MSG: 'βοΈ', ACK: 'β', REQUEST: 'β', RESPONSE: 'π¨', TRACE: 'π', PATH: 'π€οΈ', ANON_REQ: 'π΅οΈ'
+ };
+
+ // Current state
+ let state = {};
+
+ function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
+
+ function initState() {
+ const cfg = window.SITE_CONFIG || {};
+ // Merge: DEFAULTS β server config β localStorage saved values
+ var local = {};
+ try { var s = localStorage.getItem('meshcore-user-theme'); if (s) local = JSON.parse(s); } catch {}
+ function mergeSection(key) {
+ return Object.assign({}, DEFAULTS[key], cfg[key] || {}, local[key] || {});
+ }
+ var mergedHome = mergeSection('home');
+ var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
+ var serverTsMode = (cfg.timestamps && cfg.timestamps.defaultMode === 'absolute') ? 'absolute' : 'ago';
+ var mergedUi = mergeSection('ui');
+ mergedUi.timestampMode = (localTsMode === 'ago' || localTsMode === 'absolute')
+ ? localTsMode
+ : (mergedUi.timestampMode === 'absolute' || serverTsMode === 'absolute' ? 'absolute' : 'ago');
+ state = {
+ branding: mergeSection('branding'),
+ theme: mergeSection('theme'),
+ themeDark: mergeSection('themeDark'),
+ nodeColors: mergeSection('nodeColors'),
+ typeColors: mergeSection('typeColors'),
+ home: {
+ heroTitle: mergedHome.heroTitle,
+ heroSubtitle: mergedHome.heroSubtitle,
+ steps: deepClone(mergedHome.steps),
+ checklist: deepClone(mergedHome.checklist),
+ footerLinks: deepClone(mergedHome.footerLinks)
+ },
+ ui: mergedUi
+ };
+ }
+
+ function isDarkMode() {
+ return document.documentElement.getAttribute('data-theme') === 'dark' ||
+ (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ }
+
+ function activeTheme() { return isDarkMode() ? state.themeDark : state.theme; }
+ function activeDefaults() { return isDarkMode() ? DEFAULTS.themeDark : DEFAULTS.theme; }
+
+ function saveOriginalCSS() {
+ var cs = getComputedStyle(document.documentElement);
+ originalValues = {};
+ for (var key in THEME_CSS_MAP) {
+ originalValues[key] = cs.getPropertyValue(THEME_CSS_MAP[key]).trim();
+ }
+ }
+
+ function applyThemePreview() {
+ var t = activeTheme();
+ for (var key in THEME_CSS_MAP) {
+ if (t[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], t[key]);
+ }
+ // Derived vars that reference other vars β need explicit override
+ if (t.background) {
+ document.documentElement.style.setProperty('--content-bg', t.background);
+ }
+ if (t.surface1) {
+ document.documentElement.style.setProperty('--card-bg', t.surface1);
+ }
+ // Force nav bar to re-render gradient
+ var nav = document.querySelector('.top-nav');
+ if (nav) {
+ nav.style.background = 'none';
+ void nav.offsetHeight;
+ nav.style.background = '';
+ }
+ // Sync badge CSS from TYPE_COLORS
+ if (window.syncBadgeColors) window.syncBadgeColors();
+ }
+
+ function applyTypeColorCSS() {
+ if (window.syncBadgeColors) window.syncBadgeColors();
+ }
+
+ // Auto-save to localStorage on every change
+ let _autoSaveTimer = null;
+ function autoSave() {
+ if (_autoSaveTimer) clearTimeout(_autoSaveTimer);
+ _autoSaveTimer = setTimeout(function() {
+ _autoSaveTimer = null;
+ try {
+ var data = buildExport();
+ localStorage.setItem('meshcore-user-theme', JSON.stringify(data));
+ // Sync to SITE_CONFIG so live pages (home, etc.) pick up changes
+ if (window.SITE_CONFIG) {
+ if (state.branding) window.SITE_CONFIG.branding = Object.assign(window.SITE_CONFIG.branding || {}, state.branding);
+ if (state.home) window.SITE_CONFIG.home = deepClone(state.home);
+ }
+ // Re-render current page to reflect home/branding changes
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
+ } catch (e) { console.error('[customize] autoSave error:', e); }
+ }, 500);
+ }
+
+ function resetPreview() {
+ for (var key in THEME_CSS_MAP) {
+ document.documentElement.style.removeProperty(THEME_CSS_MAP[key]);
+ }
+ }
+
+ function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
+ function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/ 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; }
+ .cust-color-row .cust-reset-btn { font-size: 11px; padding: 2px 8px; border: 1px solid var(--border);
+ border-radius: 4px; background: var(--surface-2); color: var(--text-muted); cursor: pointer; }
+ .cust-color-row .cust-reset-btn:hover { background: var(--surface-3); }
+ .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-area { width: 100%; min-height: 300px; 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; }
+ .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: 0.9; }
+ .cust-dl-btn { background: var(--surface-2); color: var(--text); border: 1px solid var(--border) !important; }
+ .cust-save-user { background: #22c55e; color: #fff; }
+ .cust-save-user:hover { background: #16a34a; }
+ .cust-reset-user { background: var(--surface-2); color: #ef4444; border: 1px solid #ef4444 !important; }
+ .cust-reset-user:hover { background: #ef4444; color: #fff; }
+ .cust-dl-btn:hover { background: var(--surface-3); }
+ .cust-reset-preview { margin-top: 12px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
+ background: var(--surface-2); color: var(--text); cursor: pointer; font-size: 13px; }
+ .cust-reset-preview:hover { background: var(--surface-3); }
+ .cust-instructions { background: var(--surface-1); border: 1px solid var(--border); border-radius: 6px;
+ padding: 12px 16px; margin-top: 16px; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
+ .cust-instructions code { background: var(--surface-2); padding: 2px 6px; border-radius: 3px; font-family: var(--mono); font-size: 12px; }
+ .cust-section-title { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
+ @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; }
+ .cust-list-item { flex-wrap: wrap; }
+ }
+ `;
+ document.head.appendChild(styleEl);
+ }
+
+ function removeStyles() {
+ if (styleEl) { styleEl.remove(); styleEl = null; }
+ }
+
+ function renderTabs() {
+ var tabs = [
+ { id: 'branding', label: 'π·οΈ', title: 'Branding' },
+ { id: 'theme', label: 'π¨', title: 'Theme Colors' },
+ { id: 'nodes', label: 'π―', title: 'Colors' },
+ { id: 'home', label: 'π ', title: 'Home Page' },
+ { id: 'display', label: 'π₯οΈ', title: 'Display' },
+ { id: 'export', label: 'π€', title: 'Export / Save' }
+ ];
+ return '' +
+ tabs.map(function (t) {
+ return '' + t.label + ' ' + t.title + ' ';
+ }).join('') + '
';
+ }
+
+ function renderBranding() {
+ var b = state.branding;
+ var logoPreview = b.logoUrl ? ' ' : '';
+ return '';
+ }
+
+ function renderDisplay() {
+ var tsMode = state.ui.timestampMode === 'absolute' ? 'absolute' : 'ago';
+ return '' +
+ '
Display Settings
' +
+ '
UI preferences that affect how data is shown across pages.
' +
+ '
Timestamps
' +
+ '
Global setting β applies to all pages.
' +
+ '
Timestamp Display ' +
+ '' +
+ 'Relative (3m ago) ' +
+ 'Absolute (ISO timestamp) ' +
+ ' ' +
+ '
' +
+ '
More display controls (UTC/local and format presets) can be added here in future.
' +
+ '
';
+ }
+
+ function renderColorRow(key, val, def, dataAttr) {
+ var isFont = key === 'font' || key === 'mono';
+ var inputHtml = isFont
+ ? ' '
+ : ' ' +
+ '' + val + ' ';
+ return '' +
+ '
' + THEME_LABELS[key] + ' ' +
+ '
' + (THEME_HINTS[key] || '') + '
' +
+ inputHtml +
+ (val !== def ? '
Reset ' : '') +
+ '
';
+ }
+
+ function renderTheme() {
+ var dark = isDarkMode();
+ var modeLabel = dark ? 'π Dark Mode' : 'βοΈ Light Mode';
+ var defs = activeDefaults();
+ var current = activeTheme();
+
+ var basicRows = '';
+ for (var i = 0; i < BASIC_KEYS.length; i++) {
+ var key = BASIC_KEYS[i];
+ basicRows += renderColorRow(key, current[key] || defs[key] || '#000000', defs[key] || '#000000', 'theme');
+ }
+
+ var advancedRows = '';
+ for (var j = 0; j < ADVANCED_KEYS.length; j++) {
+ var akey = ADVANCED_KEYS[j];
+ advancedRows += renderColorRow(akey, current[akey] || defs[akey] || '#000000', defs[akey] || '#000000', 'theme');
+ }
+
+ var fontRows = '';
+ for (var f = 0; f < FONT_KEYS.length; f++) {
+ var fkey = FONT_KEYS[f];
+ fontRows += renderColorRow(fkey, current[fkey] || defs[fkey] || '', defs[fkey] || '', 'theme');
+ }
+
+ return '' +
+ renderPresets() +
+ '
' + modeLabel + '
' +
+ '
Toggle βοΈ/π in nav to edit the other mode.
' +
+ basicRows +
+ '
Advanced (' + ADVANCED_KEYS.length + ' options) ' +
+ advancedRows +
+ '' +
+ '
Fonts ' +
+ fontRows +
+ '' +
+ '
β© Reset Preview ' +
+ '
';
+ }
+
+ function renderNodes() {
+ var rows = '';
+ for (var key in NODE_LABELS) {
+ var val = state.nodeColors[key];
+ var def = DEFAULTS.nodeColors[key];
+ rows += '' +
+ '
' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + ' ' +
+ '
' + (NODE_HINTS[key] || '') + '
' +
+ '
' +
+ '
' +
+ '
' + val + ' ' +
+ (val !== def ? '
Reset ' : '') +
+ '
';
+ }
+ var typeRows = '';
+ for (var tkey in TYPE_LABELS) {
+ var tval = state.typeColors[tkey];
+ var tdef = DEFAULTS.typeColors[tkey];
+ typeRows += '' +
+ '
' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + ' ' +
+ '
' + (TYPE_HINTS[tkey] || '') + '
' +
+ '
' +
+ '
' +
+ '
' + tval + ' ' +
+ (tval !== tdef ? '
Reset ' : '') +
+ '
';
+ }
+ var heatOpacity = parseFloat(localStorage.getItem('meshcore-heatmap-opacity'));
+ if (isNaN(heatOpacity)) heatOpacity = 0.25;
+ var heatPct = Math.round(heatOpacity * 100);
+ var liveHeatOpacity = parseFloat(localStorage.getItem('meshcore-live-heatmap-opacity'));
+ if (isNaN(liveHeatOpacity)) liveHeatOpacity = 0.3;
+ var liveHeatPct = Math.round(liveHeatOpacity * 100);
+ return '' +
+ '
Node Role Colors
' + rows +
+ '
' +
+ '
Packet Type Colors
' + typeRows +
+ '
' +
+ '
Heatmap Opacity
' +
+ '
' +
+ '
πΊοΈ Nodes Map ' +
+ '
Heatmap overlay on the Nodes β Map page (0β100%)
' +
+ '
' +
+ '
' + heatPct + '% ' +
+ '
' +
+ '
' +
+ '
π‘ Live Map ' +
+ '
Heatmap overlay on the Live page (0β100%)
' +
+ '
' +
+ '
' + liveHeatPct + '% ' +
+ '
' +
+ '
';
+ }
+
+ function renderHome() {
+ var h = state.home;
+ var stepsHtml = h.steps.map(function (s, i) {
+ return '' +
+ '
' +
+ ' ' +
+ ' ' +
+ 'β ' +
+ 'β ' +
+ 'β ' +
+ '
' +
+ '
' +
+ '
Markdown: **bold** *italic* `code` [text](url) - list
' +
+ '
';
+ }).join('');
+
+ var checkHtml = h.checklist.map(function (c, i) {
+ return '' +
+ '
' +
+ ' ' +
+ 'β ' +
+ '
' +
+ '
' +
+ '
Markdown: **bold** *italic* `code` [text](url) - list
' +
+ '
';
+ }).join('');
+
+ var linksHtml = h.footerLinks.map(function (l, i) {
+ return '';
+ }).join('');
+
+ return '' +
+ '
Hero Title
' +
+ '
Hero Subtitle
' +
+ '
Steps
' + stepsHtml +
+ '
+ Add Step ' +
+ '
FAQ / Checklist
' + checkHtml +
+ '
+ Add Question ' +
+ '
Footer Links
' + linksHtml +
+ '
+ Add Link ' +
+ '
';
+ }
+
+ function buildExport() {
+ var out = {};
+ // Branding β only changed values
+ var bd = {};
+ for (var bk in DEFAULTS.branding) {
+ if (state.branding[bk] && state.branding[bk] !== DEFAULTS.branding[bk]) bd[bk] = state.branding[bk];
+ }
+ if (Object.keys(bd).length) out.branding = bd;
+
+ // Theme
+ var th = {};
+ for (var tk in DEFAULTS.theme) {
+ if (state.theme[tk] !== DEFAULTS.theme[tk]) th[tk] = state.theme[tk];
+ }
+ if (Object.keys(th).length) out.theme = th;
+
+ // Dark theme
+ var thd = {};
+ for (var tdk in DEFAULTS.themeDark) {
+ if (state.themeDark[tdk] !== DEFAULTS.themeDark[tdk]) thd[tdk] = state.themeDark[tdk];
+ }
+ if (Object.keys(thd).length) out.themeDark = thd;
+
+ // Node colors
+ var nc = {};
+ for (var nk in DEFAULTS.nodeColors) {
+ if (state.nodeColors[nk] !== DEFAULTS.nodeColors[nk]) nc[nk] = state.nodeColors[nk];
+ }
+ if (Object.keys(nc).length) out.nodeColors = nc;
+
+ // Packet type colors
+ var tc = {};
+ for (var tck in DEFAULTS.typeColors) {
+ if (state.typeColors[tck] !== DEFAULTS.typeColors[tck]) tc[tck] = state.typeColors[tck];
+ }
+ if (Object.keys(tc).length) out.typeColors = tc;
+
+ // Home
+ var hm = {};
+ if (state.home.heroTitle !== DEFAULTS.home.heroTitle) hm.heroTitle = state.home.heroTitle;
+ if (state.home.heroSubtitle !== DEFAULTS.home.heroSubtitle) hm.heroSubtitle = state.home.heroSubtitle;
+ if (JSON.stringify(state.home.steps) !== JSON.stringify(DEFAULTS.home.steps)) hm.steps = state.home.steps;
+ if (JSON.stringify(state.home.checklist) !== JSON.stringify(DEFAULTS.home.checklist)) hm.checklist = state.home.checklist;
+ if (JSON.stringify(state.home.footerLinks) !== JSON.stringify(DEFAULTS.home.footerLinks)) hm.footerLinks = state.home.footerLinks;
+ if (Object.keys(hm).length) out.home = hm;
+
+ // UI
+ var ui = {};
+ if ((state.ui.timestampMode || 'ago') !== DEFAULTS.ui.timestampMode) ui.timestampMode = state.ui.timestampMode;
+ if (Object.keys(ui).length) out.ui = ui;
+
+ return out;
+ }
+
+ function renderExport() {
+ var json = JSON.stringify(buildExport(), null, 2);
+ var hasUserTheme = !!localStorage.getItem('meshcore-user-theme');
+ return '' +
+ '
My Preferences
' +
+ '
Save these colors just for you β stored in your browser, works on any instance.
' +
+ '
' +
+ 'πΎ Save as my theme ' +
+ (hasUserTheme ? 'ποΈ Reset my theme ' : '') +
+ '
' +
+ '
' +
+ '
Admin
' +
+ '
Download or import a theme file. Admins place it as theme.json next to the server.
' +
+ '
' +
+ 'πΎ Download theme.json ' +
+ 'π Import File ' +
+ ' ' +
+ 'π Copy ' +
+ '
' +
+ '
Raw JSON ' +
+ '' +
+ '' +
+ '
';
+ }
+
+ let panelEl = null;
+
+ function render(container) {
+ container.innerHTML =
+ renderTabs() +
+ '' +
+ renderBranding() +
+ renderTheme() +
+ renderNodes() +
+ renderHome() +
+ renderDisplay() +
+ renderExport() +
+ '
';
+ bindEvents(container);
+ }
+
+ function bindEvents(container) {
+ // Tab switching
+ container.querySelectorAll('.cust-tab').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ activeTab = btn.dataset.tab;
+ render(container);
+ });
+ });
+
+ // Preset buttons
+ container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ applyPreset(btn.dataset.preset, container);
+ });
+ });
+
+ // Text inputs (branding + home hero)
+ container.querySelectorAll('input[data-key]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var parts = inp.dataset.key.split('.');
+ if (parts.length === 2) {
+ state[parts[0]][parts[1]] = inp.value;
+ autoSave();
+ }
+ // Live DOM updates for branding
+ if (inp.dataset.key === 'branding.siteName') {
+ var brandEl = document.querySelector('.brand-text');
+ if (brandEl) brandEl.textContent = inp.value;
+ document.title = inp.value;
+ }
+ if (inp.dataset.key === 'branding.logoUrl') {
+ var iconEl = document.querySelector('.brand-icon');
+ if (iconEl) {
+ if (inp.value) { iconEl.innerHTML = ' '; }
+ else { iconEl.textContent = 'π‘'; }
+ }
+ }
+ if (inp.dataset.key === 'branding.faviconUrl') {
+ var link = document.querySelector('link[rel="icon"]');
+ if (link && inp.value) link.href = inp.value;
+ }
+ });
+ });
+
+ // UI settings
+ container.querySelectorAll('select[data-ui]').forEach(function (sel) {
+ sel.addEventListener('change', function () {
+ var key = sel.dataset.ui;
+ state.ui[key] = sel.value;
+ if (key === 'timestampMode') {
+ localStorage.setItem('meshcore-timestamp-mode', sel.value);
+ if (!window.SITE_CONFIG) window.SITE_CONFIG = {};
+ if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
+ window.SITE_CONFIG.timestamps.defaultMode = sel.value;
+ window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
+ }
+ autoSave();
+ });
+ });
+
+ // Theme color pickers
+ container.querySelectorAll('input[data-theme]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var key = inp.dataset.theme;
+ var themeKey = isDarkMode() ? 'themeDark' : 'theme';
+ state[themeKey][key] = inp.value;
+ var hex = container.querySelector('[data-hex="' + key + '"]');
+ if (hex) hex.textContent = inp.value;
+ applyThemePreview(); autoSave();
+ });
+ });
+
+ // Theme reset buttons
+ container.querySelectorAll('[data-reset-theme]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var key = btn.dataset.resetTheme;
+ var themeKey = isDarkMode() ? 'themeDark' : 'theme';
+ state[themeKey][key] = activeDefaults()[key];
+ applyThemePreview(); autoSave();
+ render(container);
+ });
+ });
+
+ // Reset preview button
+ var resetBtn = document.getElementById('custResetPreview');
+ if (resetBtn) {
+ resetBtn.addEventListener('click', function () {
+ state.theme = Object.assign({}, DEFAULTS.theme);
+ resetPreview();
+ render(container);
+ });
+ }
+
+ // Node color pickers
+ container.querySelectorAll('input[data-node]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var key = inp.dataset.node;
+ state.nodeColors[key] = inp.value;
+ // Sync to global role colors used by map/packets/etc
+ if (window.ROLE_COLORS) window.ROLE_COLORS[key] = inp.value;
+ if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = inp.value;
+ // Trigger re-render of current page
+ window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
+ var dot = container.querySelector('[data-dot="' + key + '"]');
+ if (dot) dot.style.background = inp.value;
+ var hex = container.querySelector('[data-nhex="' + key + '"]');
+ if (hex) hex.textContent = inp.value;
+ });
+ });
+
+ // Node reset buttons
+ container.querySelectorAll('[data-reset-node]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var key = btn.dataset.resetNode;
+ state.nodeColors[key] = DEFAULTS.nodeColors[key];
+ if (window.ROLE_COLORS) window.ROLE_COLORS[key] = DEFAULTS.nodeColors[key];
+ if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = DEFAULTS.nodeColors[key];
+ render(container);
+ });
+ });
+
+ // Packet type color pickers
+ container.querySelectorAll('input[data-type-color]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var key = inp.dataset.typeColor;
+ state.typeColors[key] = inp.value;
+ if (window.TYPE_COLORS) window.TYPE_COLORS[key] = inp.value;
+ if (window.syncBadgeColors) window.syncBadgeColors();
+ window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
+ var dot = container.querySelector('[data-tdot="' + key + '"]');
+ if (dot) dot.style.background = inp.value;
+ var hex = container.querySelector('[data-thex="' + key + '"]');
+ if (hex) hex.textContent = inp.value;
+ });
+ });
+ container.querySelectorAll('[data-reset-type]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var key = btn.dataset.resetType;
+ state.typeColors[key] = DEFAULTS.typeColors[key];
+ if (window.TYPE_COLORS) window.TYPE_COLORS[key] = DEFAULTS.typeColors[key];
+ render(container);
+ });
+ });
+
+ // Heatmap opacity slider
+ var heatSlider = container.querySelector('#custHeatOpacity');
+ if (heatSlider) {
+ heatSlider.addEventListener('input', function () {
+ var pct = parseInt(heatSlider.value);
+ var label = container.querySelector('#custHeatOpacityVal');
+ if (label) label.textContent = pct + '%';
+ var opacity = pct / 100;
+ localStorage.setItem('meshcore-heatmap-opacity', opacity);
+ // Live-update the heatmap if visible β set canvas opacity for whole layer
+ if (window._meshcoreHeatLayer) {
+ var canvas = window._meshcoreHeatLayer._canvas ||
+ (window._meshcoreHeatLayer.getContainer && window._meshcoreHeatLayer.getContainer());
+ if (canvas) canvas.style.opacity = opacity;
+ }
+ });
+ }
+
+ // Live heatmap opacity slider
+ var liveHeatSlider = container.querySelector('#custLiveHeatOpacity');
+ if (liveHeatSlider) {
+ liveHeatSlider.addEventListener('input', function () {
+ var pct = parseInt(liveHeatSlider.value);
+ var label = container.querySelector('#custLiveHeatOpacityVal');
+ if (label) label.textContent = pct + '%';
+ var opacity = pct / 100;
+ localStorage.setItem('meshcore-live-heatmap-opacity', opacity);
+ // Live-update the live page heatmap if visible
+ if (window._meshcoreLiveHeatLayer) {
+ var canvas = window._meshcoreLiveHeatLayer._canvas ||
+ (window._meshcoreLiveHeatLayer.getContainer && window._meshcoreLiveHeatLayer.getContainer());
+ if (canvas) canvas.style.opacity = opacity;
+ }
+ });
+ }
+
+ // Steps
+ container.querySelectorAll('[data-step-field]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var i = parseInt(inp.dataset.idx);
+ state.home.steps[i][inp.dataset.stepField] = inp.value; autoSave();
+ });
+ });
+ container.querySelectorAll('[data-move-step]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var i = parseInt(btn.dataset.moveStep);
+ var dir = btn.dataset.dir === 'up' ? -1 : 1;
+ var j = i + dir;
+ if (j < 0 || j >= state.home.steps.length) return;
+ var tmp = state.home.steps[i];
+ state.home.steps[i] = state.home.steps[j];
+ state.home.steps[j] = tmp;
+ render(container);
+ });
+ });
+ container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
+ render(container);
+ });
+ });
+ var addStepBtn = document.getElementById('addStep');
+ if (addStepBtn) addStepBtn.addEventListener('click', function () {
+ state.home.steps.push({ emoji: 'π', title: '', description: '' });
+ render(container);
+ });
+
+ // Checklist
+ container.querySelectorAll('[data-check-field]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var i = parseInt(inp.dataset.idx);
+ state.home.checklist[i][inp.dataset.checkField] = inp.value; autoSave();
+ });
+ });
+ container.querySelectorAll('[data-rm-check]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.home.checklist.splice(parseInt(btn.dataset.rmCheck), 1);
+ render(container);
+ });
+ });
+ var addCheckBtn = document.getElementById('addCheck');
+ if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
+ state.home.checklist.push({ question: '', answer: '' });
+ render(container);
+ });
+
+ // Footer links
+ container.querySelectorAll('[data-link-field]').forEach(function (inp) {
+ inp.addEventListener('input', function () {
+ var i = parseInt(inp.dataset.idx);
+ state.home.footerLinks[i][inp.dataset.linkField] = inp.value; autoSave();
+ });
+ });
+ container.querySelectorAll('[data-rm-link]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.home.footerLinks.splice(parseInt(btn.dataset.rmLink), 1);
+ render(container);
+ });
+ });
+ var addLinkBtn = document.getElementById('addLink');
+ if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
+ state.home.footerLinks.push({ label: '', url: '' });
+ render(container);
+ });
+
+ // Export copy
+ var copyBtn = document.getElementById('custCopy');
+ if (copyBtn) copyBtn.addEventListener('click', function () {
+ var ta = document.getElementById('custExportJson');
+ if (ta) {
+ window.copyToClipboard(ta.value, function () {
+ copyBtn.textContent = 'β Copied!';
+ setTimeout(function () { copyBtn.textContent = 'π Copy to Clipboard'; }, 2000);
+ });
+ }
+ });
+
+ // Export download
+ var dlBtn = document.getElementById('custDownload');
+ if (dlBtn) dlBtn.addEventListener('click', function () {
+ var json = JSON.stringify(buildExport(), null, 2);
+ var blob = new Blob([json], { type: 'application/json' });
+ var a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = 'config-theme.json';
+ a.click();
+ URL.revokeObjectURL(a.href);
+ });
+
+ // Save user theme to localStorage
+ var saveUserBtn = document.getElementById('custSaveUser');
+ if (saveUserBtn) saveUserBtn.addEventListener('click', function () {
+ var exportData = buildExport();
+ localStorage.setItem('meshcore-user-theme', JSON.stringify(exportData));
+ saveUserBtn.textContent = 'β Saved!';
+ setTimeout(function () { saveUserBtn.textContent = 'πΎ Save as my theme'; }, 2000);
+ });
+
+ // Reset user theme
+ var resetUserBtn = document.getElementById('custResetUser');
+ if (resetUserBtn) resetUserBtn.addEventListener('click', function () {
+ localStorage.removeItem('meshcore-user-theme');
+ resetPreview();
+ initState();
+ render(container);
+ applyThemePreview(); autoSave();
+ });
+
+ // Import from file
+ var importBtn = document.getElementById('custImportFile');
+ var importInput = document.getElementById('custImportInput');
+ 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);
+ // Merge imported data into state
+ if (data.branding) Object.assign(state.branding, data.branding);
+ if (data.theme) Object.assign(state.theme, data.theme);
+ if (data.themeDark) Object.assign(state.themeDark, data.themeDark);
+ if (data.nodeColors) {
+ Object.assign(state.nodeColors, data.nodeColors);
+ if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, data.nodeColors);
+ if (window.ROLE_STYLE) {
+ for (var role in data.nodeColors) {
+ if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = data.nodeColors[role];
+ }
+ }
+ }
+ if (data.typeColors) {
+ Object.assign(state.typeColors, data.typeColors);
+ if (window.TYPE_COLORS) Object.assign(window.TYPE_COLORS, data.typeColors);
+ }
+ if (data.home) {
+ if (data.home.heroTitle) state.home.heroTitle = data.home.heroTitle;
+ if (data.home.heroSubtitle) state.home.heroSubtitle = data.home.heroSubtitle;
+ if (data.home.steps) state.home.steps = deepClone(data.home.steps);
+ if (data.home.checklist) state.home.checklist = deepClone(data.home.checklist);
+ if (data.home.footerLinks) state.home.footerLinks = deepClone(data.home.footerLinks);
+ }
+ applyThemePreview();
+ autoSave();
+ window.dispatchEvent(new CustomEvent('theme-changed'));
+ render(container);
+ importBtn.textContent = 'β Imported!';
+ setTimeout(function () { importBtn.textContent = 'π Import File'; }, 2000);
+ } catch (e) {
+ importBtn.textContent = 'β Invalid JSON';
+ setTimeout(function () { importBtn.textContent = 'π Import File'; }, 3000);
+ }
+ };
+ reader.readAsText(file);
+ importInput.value = '';
+ });
+ }
+ }
+
+ function toggle() {
+ if (panelEl) {
+ panelEl.classList.toggle('hidden');
+ return;
+ }
+ // First open β create the panel
+ injectStyles();
+ saveOriginalCSS();
+ initState();
+
+ panelEl = document.createElement('div');
+ panelEl.className = 'cust-overlay';
+ panelEl.innerHTML =
+ '' +
+ '
';
+ document.body.appendChild(panelEl);
+
+ panelEl.querySelector('.cust-close').addEventListener('click', () => panelEl.classList.add('hidden'));
+
+ // Drag support
+ const header = panelEl.querySelector('.cust-header');
+ let dragX = 0, dragY = 0, startX = 0, startY = 0;
+ header.addEventListener('mousedown', (e) => {
+ if (e.target.closest('.cust-close')) return;
+ dragX = panelEl.offsetLeft; dragY = panelEl.offsetTop;
+ startX = e.clientX; startY = e.clientY;
+ const onMove = (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';
+ };
+ const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+
+ render(panelEl.querySelector('.cust-inner'));
+ applyThemePreview(); autoSave();
+ }
+
+ // Restore saved user theme IMMEDIATELY (before DOMContentLoaded, before map/app init)
+ // roles.js has already loaded ROLE_COLORS, ROLE_STYLE, TYPE_COLORS at this point
+ try {
+ const saved = localStorage.getItem('meshcore-user-theme');
+ if (saved) {
+ const userTheme = JSON.parse(saved);
+ const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
+ (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ const themeData = dark ? (userTheme.themeDark || userTheme.theme) : userTheme.theme;
+ if (themeData) {
+ for (const [key, val] of Object.entries(themeData)) {
+ if (THEME_CSS_MAP[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], val);
+ }
+ // Derived vars
+ if (themeData.background) document.documentElement.style.setProperty('--content-bg', themeData.background);
+ if (themeData.surface1) document.documentElement.style.setProperty('--card-bg', themeData.surface1);
+ }
+ if (userTheme.nodeColors) {
+ if (window.ROLE_COLORS) Object.assign(window.ROLE_COLORS, userTheme.nodeColors);
+ if (window.ROLE_STYLE) {
+ for (const [role, color] of Object.entries(userTheme.nodeColors)) {
+ if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
+ }
+ }
+ }
+ if (userTheme.typeColors && window.TYPE_COLORS) {
+ Object.assign(window.TYPE_COLORS, userTheme.typeColors);
+ if (window.syncBadgeColors) window.syncBadgeColors();
+ }
+ }
+ } catch {}
+
+ // Wire up toggle button (needs DOM)
+ document.addEventListener('DOMContentLoaded', () => {
+ const btn = document.getElementById('customizeToggle');
+ if (btn) btn.addEventListener('click', toggle);
+
+ // Restore branding from localStorage (needs DOM elements to exist)
+ try {
+ const saved = localStorage.getItem('meshcore-user-theme');
+ if (saved) {
+ const userTheme = JSON.parse(saved);
+ if (userTheme.branding) {
+ if (userTheme.branding.siteName) {
+ const brandEl = document.querySelector('.brand-text');
+ if (brandEl) brandEl.textContent = userTheme.branding.siteName;
+ document.title = userTheme.branding.siteName;
+ }
+ if (userTheme.branding.logoUrl) {
+ const iconEl = document.querySelector('.brand-icon');
+ if (iconEl) iconEl.innerHTML = ' ';
+ }
+ if (userTheme.branding.faviconUrl) {
+ const link = document.querySelector('link[rel="icon"]');
+ if (link) link.href = userTheme.branding.faviconUrl;
+ }
+ }
+ }
+ } catch {}
+
+ // Watch for dark/light mode toggle and re-apply theme preview
+ new MutationObserver(function() {
+ if (state.theme) applyThemePreview();
+ }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+ });
+})();
diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js
index 20f0cf65..c91320eb 100644
--- a/test-frontend-helpers.js
+++ b/test-frontend-helpers.js
@@ -1769,6 +1769,111 @@ console.log('\n=== analytics.js: hash prefix helpers ===');
});
}
+// ===== CUSTOMIZE.JS: initState merge behavior =====
+console.log('\n=== customize.js: initState merge behavior ===');
+{
+ function loadCustomizeExports(ctx) {
+ const src = fs.readFileSync('public/customize.js', 'utf8');
+ const withExports = src.replace(
+ /\}\)\(\);\s*$/,
+ 'window.__customizeExport = { initState: initState, getState: function () { return state; }, getDefaults: function () { return deepClone(DEFAULTS); } };})();'
+ );
+ vm.runInContext(withExports, ctx);
+ for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
+ return ctx.window.__customizeExport;
+ }
+
+ test('partial local checklist does not wipe steps/footerLinks and keeps server colors', () => {
+ const ctx = makeSandbox();
+ ctx.window.SITE_CONFIG = {
+ home: {
+ heroTitle: 'Server Hero',
+ heroSubtitle: 'Server Subtitle',
+ steps: [{ emoji: 'π§ͺ', title: 'Server Step', description: 'from server' }],
+ checklist: [{ question: 'Server Q', answer: 'Server A' }],
+ footerLinks: [{ label: 'Server Link', url: '#/server' }]
+ },
+ theme: { accent: '#123456', navBg: '#222222' },
+ nodeColors: { repeater: '#aa0000' }
+ };
+ ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
+ home: { checklist: [{ question: 'Local Q', answer: 'Local A' }] }
+ }));
+ const ex = loadCustomizeExports(ctx);
+ ex.initState();
+ const state = ex.getState();
+ assert.strictEqual(state.home.checklist[0].question, 'Local Q');
+ assert.strictEqual(state.home.steps[0].title, 'Server Step');
+ assert.strictEqual(state.home.footerLinks[0].label, 'Server Link');
+ assert.strictEqual(state.home.heroTitle, 'Server Hero');
+ assert.strictEqual(state.theme.accent, '#123456');
+ assert.strictEqual(state.nodeColors.repeater, '#aa0000');
+ });
+
+ test('server values survive when localStorage has partial overrides', () => {
+ const ctx = makeSandbox();
+ ctx.window.SITE_CONFIG = {
+ home: {
+ heroTitle: 'Server Hero',
+ heroSubtitle: 'Server Subtitle',
+ steps: [{ emoji: '1οΈβ£', title: 'Server Step', description: 'server' }],
+ footerLinks: [{ label: 'Server Footer', url: '#/s' }]
+ },
+ theme: { accent: '#111111', navBg: '#222222', navText: '#333333' },
+ typeColors: { ADVERT: '#00aa00', REQUEST: '#aa00aa' }
+ };
+ ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
+ home: { heroTitle: 'Local Hero' },
+ theme: { accent: '#999999' },
+ typeColors: { ADVERT: '#ff00ff' }
+ }));
+ const ex = loadCustomizeExports(ctx);
+ ex.initState();
+ const state = ex.getState();
+ assert.strictEqual(state.home.heroTitle, 'Local Hero');
+ assert.strictEqual(state.home.heroSubtitle, 'Server Subtitle');
+ assert.strictEqual(state.home.steps[0].title, 'Server Step');
+ assert.strictEqual(state.home.footerLinks[0].label, 'Server Footer');
+ assert.strictEqual(state.theme.accent, '#999999');
+ assert.strictEqual(state.theme.navBg, '#222222');
+ assert.strictEqual(state.typeColors.ADVERT, '#ff00ff');
+ assert.strictEqual(state.typeColors.REQUEST, '#aa00aa');
+ });
+
+ test('full localStorage values override server config', () => {
+ const ctx = makeSandbox();
+ ctx.window.SITE_CONFIG = {
+ home: {
+ heroTitle: 'Server Hero',
+ heroSubtitle: 'Server Subtitle',
+ steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
+ checklist: [{ question: 'Server Q', answer: 'Server A' }],
+ footerLinks: [{ label: 'Server Link', url: '#/server' }]
+ },
+ theme: { accent: '#101010' }
+ };
+ ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
+ home: {
+ heroTitle: 'Local Hero',
+ heroSubtitle: 'Local Subtitle',
+ steps: [{ emoji: 'L', title: 'Local Step', description: 'local' }],
+ checklist: [{ question: 'Local Q', answer: 'Local A' }],
+ footerLinks: [{ label: 'Local Link', url: '#/local' }]
+ },
+ theme: { accent: '#abcdef', navBg: '#fedcba' }
+ }));
+ const ex = loadCustomizeExports(ctx);
+ ex.initState();
+ const state = ex.getState();
+ assert.strictEqual(state.home.heroTitle, 'Local Hero');
+ assert.strictEqual(state.home.heroSubtitle, 'Local Subtitle');
+ assert.strictEqual(state.home.steps[0].title, 'Local Step');
+ assert.strictEqual(state.home.checklist[0].question, 'Local Q');
+ assert.strictEqual(state.home.footerLinks[0].label, 'Local Link');
+ assert.strictEqual(state.theme.accent, '#abcdef');
+ assert.strictEqual(state.theme.navBg, '#fedcba');
+ });
+}
// ===== SUMMARY =====
console.log(`\n${'β'.repeat(40)}`);
console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);