Files
meshcore-analyzer/public/customize.js
T
Kpa-clawbot cfd1903c6b fix(logo): default sage/teal brand colors, customizer mirrors accent (#1162)
## Summary

Restores sage/teal as default logo colors while preserving customizer
theming. Closes the gap from #1157 (closed) and the user's complaint
about lost two-tone.

Out-of-the-box, the navbar + hero CORE/SCOPE wordmarks now render the
brand-identity duotone — `#cfd9c9` (sage / fog) and `#2c8c8c` (teal /
water). When an operator picks a theme via the customizer (or sets a
custom accent color), the wordmark recolors to follow.

## Approach (Option C — decoupled defaults + customizer mirror)

- **`public/style.css` `:root`** — set `--logo-accent: #cfd9c9` and
`--logo-accent-hi: #2c8c8c` as literal defaults. Removes the previous
`var(--accent)` cascade so blue-by-default no longer leaks into the
brand mark.
- **`public/customize-v2.js`** — `applyTheme()`, the early-apply path,
and the live color-picker `input` handler now mirror
`themeSection.accent` → `--logo-accent` and `themeSection.accentHover` →
`--logo-accent-hi`.
- **`public/customize.js`** (legacy) — same mirroring in
`applyThemePreview()` and the early localStorage replay.
- **`.github/workflows/deploy.yml`** — adds the new e2e to the Chromium
batch.

This preserves `--accent` as the canonical app-wide accent token (no
other UI changes) while giving the logo its own brand-defaulted tokens
that the customizer still drives.

## Tests

Red → green commit pair on the branch.

- **NEW: `test-logo-default-sage-teal-e2e.js`** — gates both halves of
the contract:
1. Clean localStorage → navbar + hero CORE = `rgb(207, 217, 201)`, SCOPE
= `rgb(44, 140, 140)`.
2. Seeded `cs-theme-overrides` with red accent → navbar + hero recolor
to red.
- **UPDATED: `test-logo-theme-e2e.js`** — replaces the old "must NOT be
sage" sentinel (sage was a regression marker; it's now the brand
default) with a theme-reactivity probe that overrides `--logo-accent` /
`--logo-accent-hi` directly and asserts the wordmark fill changes.
Duotone, mobile-fit, and clip checks are unchanged.

## Verification

- Default load: sage CORE + teal SCOPE in navbar AND hero ✔ (asserted by
step 1 of the new e2e).
- Customizer override: wordmark follows `accent` / `accentHover` ✔
(asserted by step 2 of the new e2e + the theme-reactivity probe in
`test-logo-theme-e2e.js`).
- Preflight: all hard gates green (PII, branch scope, red commit,
CSS-var defined, CSS self-fallback, LIKE-on-JSON, sync migration); all
warnings green.

## Browser verified

E2E assertion added: `test-logo-default-sage-teal-e2e.js:73` (default
sage), `test-logo-default-sage-teal-e2e.js:124` (customizer override).
CI runs both via `deploy.yml:243`.

Browser verified: covered by Chromium e2e against
`http://localhost:13581` in CI; staging URL TBD on merge.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-07 08:29:02 -07:00

1511 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === CoreScope — customize.js === */
/* Tools → Customization: visual config builder with live preview & JSON export */
'use strict';
(function () {
let styleEl = null;
let originalValues = {};
let activeTab = 'branding';
// ── Brand logo swap helpers (PR #1137) ──
// Default brand logo is an inline <svg.brand-logo>; an operator override
// (branding.logoUrl) swaps it for an <img.brand-logo>. Going back to empty
// restores the inline default on next reload (intermediate state shows the
// bundled SVG via <img>). Kept in customize.js for v1 parity.
function _v1SetBrandLogoUrl(url) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (url) {
if (node.tagName.toLowerCase() === 'img') { node.setAttribute('src', url); return; }
var img = document.createElement('img');
img.className = 'brand-logo';
img.setAttribute('src', url);
img.setAttribute('alt', node.getAttribute('aria-label') || 'Brand');
img.setAttribute('width', '111');
img.setAttribute('height', '36');
node.parentNode.replaceChild(img, node);
} else if (node.tagName.toLowerCase() === 'img') {
node.setAttribute('src', 'img/corescope-logo.svg');
}
}
function _v1SetBrandAlt(alt) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (node.tagName.toLowerCase() === 'img') node.setAttribute('alt', alt);
else node.setAttribute('aria-label', alt);
var brandLink = document.querySelector('.nav-brand');
if (brandLink) brandLink.setAttribute('aria-label', alt + ' home');
}
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',
timestampTimezone: 'local',
timestampFormat: 'iso',
timestampCustomFormat: ''
}
};
// 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 = '<div style="margin-bottom:16px">' +
'<p class="cust-section-title">Theme Presets</p>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap">';
for (var id in PRESETS) {
var p = PRESETS[id];
var isActive = id === active;
var dots = '';
for (var di = 0; di < p.preview.length; di++) {
dots += '<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:' + p.preview[di] + ';border:1px solid rgba(128,128,128,0.3)"></span>';
}
html += '<button class="cust-preset-btn" data-preset="' + id + '" style="' +
'display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 10px;' +
'border:2px solid ' + (isActive ? 'var(--accent)' : 'var(--border)') + ';' +
'border-radius:8px;background:' + (isActive ? 'var(--selected-bg)' : 'var(--surface-1)') + ';' +
'cursor:pointer;min-width:72px;color:var(--text)">' +
'<div style="display:flex;gap:3px">' + dots + '</div>' +
'<span style="font-size:11px;font-weight:' + (isActive ? '700' : '500') + '">' + esc(p.name) + '</span>' +
'<span style="font-size:9px;color:var(--text-muted)">' + esc(p.desc) + '</span>' +
'</button>';
}
html += '</div></div>';
return html;
}
function 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 serverHome = window._SITE_CONFIG_ORIGINAL_HOME || cfg.home || {};
var mergedHome = Object.assign({}, DEFAULTS.home, serverHome, local.home || {});
var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
var localTsTimezone = localStorage.getItem('meshcore-timestamp-timezone');
var localTsFormat = localStorage.getItem('meshcore-timestamp-format');
var localTsCustomFormat = localStorage.getItem('meshcore-timestamp-custom-format');
var serverTsMode = (cfg.timestamps && cfg.timestamps.defaultMode === 'absolute') ? 'absolute' : 'ago';
var serverTsTimezone = (cfg.timestamps && cfg.timestamps.timezone === 'utc') ? 'utc' : 'local';
var serverTsFormat = (cfg.timestamps && (cfg.timestamps.formatPreset === 'iso' || cfg.timestamps.formatPreset === 'iso-seconds' || cfg.timestamps.formatPreset === 'locale'))
? cfg.timestamps.formatPreset
: 'iso';
var serverTsCustomFormat = (cfg.timestamps && typeof cfg.timestamps.customFormat === 'string') ? cfg.timestamps.customFormat : '';
var mergedUi = mergeSection('ui');
mergedUi.timestampMode = (localTsMode === 'ago' || localTsMode === 'absolute')
? localTsMode
: (mergedUi.timestampMode === 'absolute' || serverTsMode === 'absolute' ? 'absolute' : 'ago');
mergedUi.timestampTimezone = (localTsTimezone === 'local' || localTsTimezone === 'utc')
? localTsTimezone
: (mergedUi.timestampTimezone === 'utc' || serverTsTimezone === 'utc' ? 'utc' : 'local');
mergedUi.timestampFormat = (localTsFormat === 'iso' || localTsFormat === 'iso-seconds' || localTsFormat === 'locale')
? localTsFormat
: ((mergedUi.timestampFormat === 'iso' || mergedUi.timestampFormat === 'iso-seconds' || mergedUi.timestampFormat === 'locale') ? mergedUi.timestampFormat : serverTsFormat);
mergedUi.timestampCustomFormat = (localTsCustomFormat != null)
? localTsCustomFormat
: (typeof mergedUi.timestampCustomFormat === 'string' ? mergedUi.timestampCustomFormat : serverTsCustomFormat);
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]);
}
// Mirror accent → logo brand vars so the wordmark follows the theme.
if (t.accent) document.documentElement.style.setProperty('--logo-accent', t.accent);
if (t.accentHover) document.documentElement.style.setProperty('--logo-accent-hi', t.accentHover);
// 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;
let _initialized = false;
function autoSave() {
if (!_initialized) return;
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);
}
// 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
function injectStyles() {
if (styleEl) return;
styleEl = document.createElement('style');
styleEl.textContent = `
.cust-overlay { position: fixed; top: 56px; right: 12px; z-index: 1050; width: 480px; height: calc(100vh - 68px);
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3); display: flex; flex-direction: column;
resize: both; min-width: 320px; min-height: 300px; overflow: hidden; }
.cust-overlay.hidden { display: none; }
.cust-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px;
border-bottom: 1px solid var(--border); cursor: move; user-select: none; flex-shrink: 0; }
.cust-header h2 { margin: 0; font-size: 15px; }
.cust-close { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--text-muted); padding: 4px 8px; border-radius: 4px; }
.cust-close:hover { background: var(--surface-3); color: var(--text); }
.cust-inner { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
.cust-body { flex: 1; overflow-y: auto; min-height: 0; }
.cust-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.cust-tab { padding: 8px 10px; cursor: pointer; border: none; background: none; color: var(--text-muted);
font-size: 12px; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; flex: 1; text-align: center; }
.cust-tab-text { font-size: 10px; display: block; }
.cust-tab:hover { color: var(--text); }
.cust-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.cust-panel { display: none; padding: 12px 16px; }
.cust-panel.active { display: block; }
.cust-field { margin-bottom: 12px; }
.cust-field label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 3px; color: var(--text); }
.cust-field input[type="text"], .cust-field textarea { width: 100%; padding: 6px 8px; border: 1px solid var(--border);
border-radius: 6px; font-size: 13px; background: var(--input-bg); color: var(--text); box-sizing: border-box; }
.cust-field input[type="text"]:focus, .cust-field textarea:focus { outline: none; border-color: var(--accent); }
.cust-color-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.cust-color-row > div:first-child { min-width: 160px; flex: 1; }
.cust-color-row label { font-size: 12px; font-weight: 600; margin: 0; display: block; }
.cust-hint { font-size: 10px; color: var(--text-muted); margin-top: 1px; line-height: 1.2; }
.cust-color-row input[type="color"] { width: 40px; height: 32px; border: 1px solid var(--border);
border-radius: 6px; cursor: pointer; padding: 2px; background: var(--input-bg); }
.cust-color-row .cust-hex { font-family: var(--mono); font-size: 12px; color: var(--text-muted); min-width: 70px; }
.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 '<div class="cust-tabs">' +
tabs.map(function (t) {
return '<button class="cust-tab' + (t.id === activeTab ? ' active' : '') + '" data-tab="' + t.id + '" title="' + t.title + '">' + t.label + ' <span class="cust-tab-text">' + t.title + '</span></button>';
}).join('') + '</div>';
}
function renderBranding() {
var b = state.branding;
var logoPreview = b.logoUrl ? '<img class="cust-preview-img" src="' + escAttr(b.logoUrl) + '" alt="Logo preview" onerror="this.style.display=\'none\'">' : '';
return '<div class="cust-panel' + (activeTab === 'branding' ? ' active' : '') + '" data-panel="branding">' +
'<div class="cust-field"><label for="cust-siteName">Site Name</label><input type="text" id="cust-siteName" data-key="branding.siteName" value="' + escAttr(b.siteName) + '"></div>' +
'<div class="cust-field"><label for="cust-tagline">Tagline</label><input type="text" id="cust-tagline" data-key="branding.tagline" value="' + escAttr(b.tagline) + '"></div>' +
'<div class="cust-field"><label for="cust-logoUrl">Logo URL</label><input type="text" id="cust-logoUrl" data-key="branding.logoUrl" value="' + escAttr(b.logoUrl) + '" placeholder="https://...">' + logoPreview + '</div>' +
'<div class="cust-field"><label for="cust-faviconUrl">Favicon URL</label><input type="text" id="cust-faviconUrl" data-key="branding.faviconUrl" value="' + escAttr(b.faviconUrl) + '" placeholder="https://..."></div>' +
'</div>';
}
function renderDisplay() {
var tsMode = state.ui.timestampMode === 'absolute' ? 'absolute' : 'ago';
var tsTimezone = state.ui.timestampTimezone === 'utc' ? 'utc' : 'local';
var tsFormat = (state.ui.timestampFormat === 'iso-seconds' || state.ui.timestampFormat === 'locale') ? state.ui.timestampFormat : 'iso';
var canCustomFormat = !!(window.SITE_CONFIG && window.SITE_CONFIG.timestamps && window.SITE_CONFIG.timestamps.allowCustomFormat === true);
var customFormat = typeof state.ui.timestampCustomFormat === 'string' ? state.ui.timestampCustomFormat : '';
var showAbsoluteOnly = tsMode === 'absolute' ? '' : ' style="display:none"';
return '<div class="cust-panel' + (activeTab === 'display' ? ' active' : '') + '" data-panel="display">' +
'<p class="cust-section-title">Display Settings</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">UI preferences that affect how data is shown across pages.</p>' +
'<p class="cust-section-title" style="font-size:14px;margin-bottom:8px">Timestamps</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Global setting — applies to all pages.</p>' +
'<div class="cust-field"><label for="custTimestampMode">Timestamp Display</label>' +
'<select id="custTimestampMode" data-ui="timestampMode" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="ago"' + (tsMode === 'ago' ? ' selected' : '') + '>Relative (3m ago)</option>' +
'<option value="absolute"' + (tsMode === 'absolute' ? ' selected' : '') + '>Absolute (ISO timestamp)</option>' +
'</select>' +
'</div>' +
'<div class="cust-field"><label for="custTimestampTimezone">Timestamp Timezone</label>' +
'<select id="custTimestampTimezone" data-ui="timestampTimezone" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="local"' + (tsTimezone === 'local' ? ' selected' : '') + '>Local time</option>' +
'<option value="utc"' + (tsTimezone === 'utc' ? ' selected' : '') + '>UTC</option>' +
'</select>' +
'</div>' +
'<div class="cust-field" data-ts-absolute-only="format"' + showAbsoluteOnly + '><label for="custTimestampFormat">Timestamp Format (Absolute mode)</label>' +
'<select id="custTimestampFormat" data-ui="timestampFormat" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="iso"' + (tsFormat === 'iso' ? ' selected' : '') + '>ISO (2024-01-15 14:30:00)</option>' +
'<option value="iso-seconds"' + (tsFormat === 'iso-seconds' ? ' selected' : '') + '>ISO + milliseconds (2024-01-15 14:30:00.123)</option>' +
'<option value="locale"' + (tsFormat === 'locale' ? ' selected' : '') + '>Locale (browser format)</option>' +
'</select>' +
'</div>' +
(canCustomFormat
? ('<div class="cust-field" data-ts-absolute-only="custom"' + showAbsoluteOnly + '><label for="custTimestampCustomFormat">Custom Timestamp Format (Absolute mode)</label>' +
'<input type="text" id="custTimestampCustomFormat" data-ui-input="timestampCustomFormat" value="' + escAttr(customFormat) + '" placeholder="YYYY-MM-DD HH:mm:ss">' +
'<div class="cust-hint">If non-empty, this overrides preset formatting.</div>' +
'</div>')
: '') +
'</div>';
}
function renderColorRow(key, val, def, dataAttr) {
var isFont = key === 'font' || key === 'mono';
var inputHtml = isFont
? '<input type="text" id="cust-' + dataAttr + '-' + key + '" data-' + dataAttr + '="' + key + '" value="' + escAttr(val) + '" style="width:160px;font-size:11px;font-family:var(--mono);padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--input-bg);color:var(--text)">'
: '<input type="color" id="cust-' + dataAttr + '-' + key + '" data-' + dataAttr + '="' + key + '" value="' + val + '">' +
'<span class="cust-hex" data-hex="' + key + '">' + val + '</span>';
return '<div class="cust-color-row">' +
'<div><label for="cust-' + dataAttr + '-' + key + '">' + THEME_LABELS[key] + '</label>' +
'<div class="cust-hint">' + (THEME_HINTS[key] || '') + '</div></div>' +
inputHtml +
(val !== def ? '<button class="cust-reset-btn" data-reset-theme="' + key + '">Reset</button>' : '') +
'</div>';
}
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 '<div class="cust-panel' + (activeTab === 'theme' ? ' active' : '') + '" data-panel="theme">' +
renderPresets() +
'<p class="cust-section-title">' + modeLabel + '</p>' +
'<p style="font-size:11px;color:var(--text-muted);margin:0 0 10px">Toggle ☀️/🌙 in nav to edit the other mode.</p>' +
basicRows +
'<details class="cust-advanced"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted);margin:12px 0 8px">Advanced (' + ADVANCED_KEYS.length + ' options)</summary>' +
advancedRows +
'</details>' +
'<details class="cust-fonts" style="margin-top:12px"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted);margin:12px 0 8px">Fonts</summary>' +
fontRows +
'</details>' +
'<button class="cust-reset-preview" id="custResetPreview">↩ Reset Preview</button>' +
'</div>';
}
function renderNodes() {
var rows = '';
for (var key in NODE_LABELS) {
var val = state.nodeColors[key];
var def = DEFAULTS.nodeColors[key];
rows += '<div class="cust-color-row">' +
'<div><label for="cust-node-' + key + '">' + NODE_EMOJI[key] + ' ' + NODE_LABELS[key] + '</label>' +
'<div class="cust-hint">' + (NODE_HINTS[key] || '') + '</div></div>' +
'<input type="color" id="cust-node-' + key + '" data-node="' + key + '" value="' + val + '">' +
'<span class="cust-node-dot" style="background:' + val + '" data-dot="' + key + '"></span>' +
'<span class="cust-hex" data-nhex="' + key + '">' + val + '</span>' +
(val !== def ? '<button class="cust-reset-btn" data-reset-node="' + key + '">Reset</button>' : '') +
'</div>';
}
var typeRows = '';
for (var tkey in TYPE_LABELS) {
var tval = state.typeColors[tkey];
var tdef = DEFAULTS.typeColors[tkey];
typeRows += '<div class="cust-color-row">' +
'<div><label for="cust-type-' + tkey + '">' + (TYPE_EMOJI[tkey] || '') + ' ' + TYPE_LABELS[tkey] + '</label>' +
'<div class="cust-hint">' + (TYPE_HINTS[tkey] || '') + '</div></div>' +
'<input type="color" id="cust-type-' + tkey + '" data-type-color="' + tkey + '" value="' + tval + '">' +
'<span class="cust-node-dot" style="background:' + tval + '" data-tdot="' + tkey + '"></span>' +
'<span class="cust-hex" data-thex="' + tkey + '">' + tval + '</span>' +
(tval !== tdef ? '<button class="cust-reset-btn" data-reset-type="' + tkey + '">Reset</button>' : '') +
'</div>';
}
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 '<div class="cust-panel' + (activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
'<p class="cust-section-title">Node Role Colors</p>' + rows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Packet Type Colors</p>' + typeRows +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Heatmap Opacity</p>' +
'<div class="cust-color-row">' +
'<div><label for="custHeatOpacity">🗺️ Nodes Map</label>' +
'<div class="cust-hint">Heatmap overlay on the Nodes → Map page (0100%)</div></div>' +
'<input type="range" id="custHeatOpacity" min="0" max="100" value="' + heatPct + '" style="width:120px;cursor:pointer">' +
'<span id="custHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + heatPct + '%</span>' +
'</div>' +
'<div class="cust-color-row">' +
'<div><label for="custLiveHeatOpacity">📡 Live Map</label>' +
'<div class="cust-hint">Heatmap overlay on the Live page (0100%)</div></div>' +
'<input type="range" id="custLiveHeatOpacity" min="0" max="100" value="' + liveHeatPct + '" style="width:120px;cursor:pointer">' +
'<span id="custLiveHeatOpacityVal" style="font-family:var(--mono);font-size:12px;color:var(--text-muted);min-width:36px">' + liveHeatPct + '%</span>' +
'</div>' +
'</div>';
}
function renderHome() {
var h = state.home;
var stepsHtml = h.steps.map(function (s, i) {
return '<div class="cust-list-item" data-step="' + i + '">' +
'<div class="cust-list-row">' +
'<input class="cust-emoji-input" data-step-field="emoji" data-idx="' + i + '" value="' + escAttr(s.emoji) + '" placeholder="📡" aria-label="Step ' + (i + 1) + ' emoji">' +
'<input data-step-field="title" data-idx="' + i + '" value="' + escAttr(s.title) + '" placeholder="Title" aria-label="Step ' + (i + 1) + ' title">' +
'<button class="cust-list-btn" data-move-step="' + i + '" data-dir="up" title="Move up">↑</button>' +
'<button class="cust-list-btn" data-move-step="' + i + '" data-dir="down" title="Move down">↓</button>' +
'<button class="cust-list-btn danger" data-rm-step="' + i + '" title="Remove">✕</button>' +
'</div>' +
'<textarea data-step-field="description" data-idx="' + i + '" placeholder="Description" rows="2" aria-label="Step ' + (i + 1) + ' description">' + esc(s.description) + '</textarea>' +
'<div class="cust-md-hint">Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>[text](url)</code> <code>- list</code></div>' +
'</div>';
}).join('');
var checkHtml = h.checklist.map(function (c, i) {
return '<div class="cust-list-item" data-check="' + i + '">' +
'<div class="cust-list-row">' +
'<input data-check-field="question" data-idx="' + i + '" value="' + escAttr(c.question) + '" placeholder="Question" aria-label="Checklist item ' + (i + 1) + ' question">' +
'<button class="cust-list-btn danger" data-rm-check="' + i + '" title="Remove">✕</button>' +
'</div>' +
'<textarea data-check-field="answer" data-idx="' + i + '" placeholder="Answer" rows="2" aria-label="Checklist item ' + (i + 1) + ' answer">' + esc(c.answer) + '</textarea>' +
'<div class="cust-md-hint">Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>[text](url)</code> <code>- list</code></div>' +
'</div>';
}).join('');
var linksHtml = h.footerLinks.map(function (l, i) {
return '<div class="cust-list-item" data-link="' + i + '">' +
'<div class="cust-list-row">' +
'<input data-link-field="label" data-idx="' + i + '" value="' + escAttr(l.label) + '" placeholder="Label" aria-label="Footer link ' + (i + 1) + ' label">' +
'<button class="cust-list-btn danger" data-rm-link="' + i + '" title="Remove">✕</button>' +
'</div>' +
'<input data-link-field="url" data-idx="' + i + '" value="' + escAttr(l.url) + '" placeholder="URL" aria-label="Footer link ' + (i + 1) + ' URL">' +
'</div>';
}).join('');
return '<div class="cust-panel' + (activeTab === 'home' ? ' active' : '') + '" data-panel="home">' +
'<div class="cust-field"><label for="cust-heroTitle">Hero Title</label><input type="text" id="cust-heroTitle" data-key="home.heroTitle" value="' + escAttr(h.heroTitle) + '"></div>' +
'<div class="cust-field"><label for="cust-heroSubtitle">Hero Subtitle</label><input type="text" id="cust-heroSubtitle" data-key="home.heroSubtitle" value="' + escAttr(h.heroSubtitle) + '"></div>' +
'<p class="cust-section-title" style="margin-top:20px">Steps</p>' + stepsHtml +
'<button class="cust-add-btn" id="addStep">+ Add Step</button>' +
'<p class="cust-section-title" style="margin-top:24px">FAQ / Checklist</p>' + checkHtml +
'<button class="cust-add-btn" id="addCheck">+ Add Question</button>' +
'<p class="cust-section-title" style="margin-top:24px">Footer Links</p>' + linksHtml +
'<button class="cust-add-btn" id="addLink">+ Add Link</button>' +
'</div>';
}
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 ((state.ui.timestampTimezone || 'local') !== DEFAULTS.ui.timestampTimezone) ui.timestampTimezone = state.ui.timestampTimezone;
if ((state.ui.timestampFormat || 'iso') !== DEFAULTS.ui.timestampFormat) ui.timestampFormat = state.ui.timestampFormat;
if ((state.ui.timestampCustomFormat || '') !== DEFAULTS.ui.timestampCustomFormat) ui.timestampCustomFormat = state.ui.timestampCustomFormat;
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 '<div class="cust-panel' + (activeTab === 'export' ? ' active' : '') + '" data-panel="export">' +
'<p class="cust-section-title">My Preferences</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Save these colors just for you — stored in your browser, works on any instance.</p>' +
'<div class="cust-export-btns" style="margin-bottom:16px">' +
'<button class="cust-save-user" id="custSaveUser">💾 Save as my theme</button>' +
(hasUserTheme ? '<button class="cust-reset-user" id="custResetUser">🗑️ Reset my theme</button>' : '') +
'</div>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Admin</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Download or import a theme file. Admins place it as <code>theme.json</code> next to the server.</p>' +
'<div class="cust-export-btns" style="margin-bottom:12px">' +
'<button class="cust-dl-btn" id="custDownload">💾 Download theme.json</button>' +
'<button class="cust-dl-btn" id="custImportFile">📂 Import File</button>' +
'<input type="file" id="custImportInput" accept=".json,application/json" style="display:none" aria-label="Import theme file">' +
'<button class="cust-copy-btn" id="custCopy">📋 Copy</button>' +
'</div>' +
'<details style="margin-top:8px"><summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-muted)">Raw JSON</summary>' +
'<textarea class="cust-export-area" id="custExportJson" style="margin-top:8px" aria-label="Theme JSON data">' + esc(json) + '</textarea>' +
'</details>' +
'</div>';
}
let panelEl = null;
function render(container) {
container.innerHTML =
renderTabs() +
'<div class="cust-body">' +
renderBranding() +
renderTheme() +
renderNodes() +
renderHome() +
renderDisplay() +
renderExport() +
'</div>';
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') {
// Post-rebrand (PR #1137): the navbar brand is an inline <svg>;
// mutate aria-label (a11y label on the <svg>/<a>) + document title.
// Legacy .brand-text fallback retained for any operator who shipped
// a custom build that still uses the text node.
_v1SetBrandAlt(inp.value);
var brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = inp.value;
document.title = inp.value;
}
if (inp.dataset.key === 'branding.logoUrl') {
// Swap the navbar logo: empty → restore inline default; URL → <img>.
_v1SetBrandLogoUrl(inp.value || '');
var iconEl = document.querySelector('.brand-icon');
if (iconEl) {
if (inp.value) { iconEl.innerHTML = '<img src="' + inp.value + '" style="height:24px" onerror="this.style.display=\'none\'">'; }
else { iconEl.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' || key === 'timestampTimezone' || key === 'timestampFormat') {
if (!window.SITE_CONFIG) window.SITE_CONFIG = {};
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
if (key === 'timestampMode') {
localStorage.setItem('meshcore-timestamp-mode', sel.value);
window.SITE_CONFIG.timestamps.defaultMode = sel.value;
var formatRow = container.querySelector('[data-ts-absolute-only="format"]');
if (formatRow) formatRow.style.display = sel.value === 'absolute' ? '' : 'none';
var customRow = container.querySelector('[data-ts-absolute-only="custom"]');
if (customRow) customRow.style.display = sel.value === 'absolute' ? '' : 'none';
} else if (key === 'timestampTimezone') {
localStorage.setItem('meshcore-timestamp-timezone', sel.value);
window.SITE_CONFIG.timestamps.timezone = sel.value;
} else if (key === 'timestampFormat') {
localStorage.setItem('meshcore-timestamp-format', sel.value);
window.SITE_CONFIG.timestamps.formatPreset = sel.value;
}
window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
}
autoSave();
});
});
container.querySelectorAll('input[data-ui-input]').forEach(function (inp) {
inp.addEventListener('input', function () {
var key = inp.dataset.uiInput;
state.ui[key] = inp.value;
if (key === 'timestampCustomFormat') {
localStorage.setItem('meshcore-timestamp-custom-format', inp.value);
if (!window.SITE_CONFIG) window.SITE_CONFIG = {};
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
window.SITE_CONFIG.timestamps.customFormat = inp.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); autoSave();
});
});
container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
btn.addEventListener('click', function () {
state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
render(container); autoSave();
});
});
var addStepBtn = document.getElementById('addStep');
if (addStepBtn) addStepBtn.addEventListener('click', function () {
state.home.steps.push({ emoji: '📌', title: '', description: '' });
render(container); autoSave();
});
// 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); autoSave();
});
});
var addCheckBtn = document.getElementById('addCheck');
if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
state.home.checklist.push({ question: '', answer: '' });
render(container); autoSave();
});
// 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); autoSave();
});
});
var addLinkBtn = document.getElementById('addLink');
if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
state.home.footerLinks.push({ label: '', url: '' });
render(container); autoSave();
});
// 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();
_initialized = false;
initState();
panelEl = document.createElement('div');
panelEl.className = 'cust-overlay';
panelEl.innerHTML =
'<div class="cust-header">' +
'<h2>🎨 Customize</h2>' +
'<button class="cust-close" title="Close">✕</button>' +
'</div>' +
'<div class="cust-inner"></div>';
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();
_initialized = true;
}
// 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);
}
// Mirror accent → logo brand vars (matches applyThemePreview()).
if (themeData.accent) document.documentElement.style.setProperty('--logo-accent', themeData.accent);
if (themeData.accentHover) document.documentElement.style.setProperty('--logo-accent-hi', themeData.accentHover);
// 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) {
_v1SetBrandAlt(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) {
_v1SetBrandLogoUrl(userTheme.branding.logoUrl);
const iconEl = document.querySelector('.brand-icon');
if (iconEl) iconEl.innerHTML = '<img src="' + userTheme.branding.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
}
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'] });
});
})();