diff --git a/public/app.js b/public/app.js index b280439..d7b1540 100644 --- a/public/app.js +++ b/public/app.js @@ -134,6 +134,13 @@ function getTimestampCustomFormat() { function pad2(v) { return String(v).padStart(2, '0'); } function pad3(v) { return String(v).padStart(3, '0'); } +function mergeUserHomeConfig(siteConfig, userTheme) { + if (!siteConfig || !userTheme || !userTheme.home || typeof userTheme.home !== 'object') return siteConfig; + const serverHome = (siteConfig.home && typeof siteConfig.home === 'object') ? siteConfig.home : {}; + siteConfig.home = Object.assign({}, serverHome, userTheme.home); + return siteConfig; +} + function formatIsoLike(d, timezone, includeMs) { const useUtc = timezone === 'utc'; const year = useUtc ? d.getUTCFullYear() : d.getFullYear(); @@ -727,6 +734,7 @@ window.addEventListener('DOMContentLoaded', () => { // User's localStorage preferences take priority over server config const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })(); + mergeUserHomeConfig(window.SITE_CONFIG, userTheme); // Apply CSS variable overrides from theme config (skipped if user has local overrides) if (!userTheme.theme && !userTheme.themeDark) { diff --git a/public/customize.js b/public/customize.js index ac2805d..c879ebe 100644 --- a/public/customize.js +++ b/public/customize.js @@ -536,7 +536,9 @@ // 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; @@ -546,7 +548,6 @@ // 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')); @@ -1358,6 +1359,7 @@ // First open — create the panel injectStyles(); saveOriginalCSS(); + _initialized = false; initState(); panelEl = document.createElement('div'); @@ -1390,7 +1392,8 @@ }); render(panelEl.querySelector('.cust-inner')); - applyThemePreview(); autoSave(); + applyThemePreview(); + _initialized = true; } // Restore saved user theme IMMEDIATELY (before DOMContentLoaded, before map/app init) diff --git a/public/index.html b/public/index.html index 2ede25e..069d511 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,31 +81,33 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index ee2412b..334c9c9 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -80,6 +80,68 @@ async function run() { assert(hasCustomizer, 'Customizer panel not found after clicking'); }); + await test('Customizer open does not overwrite server home config without edits', async () => { + // TODO: requires running server with full customize/home wiring + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]'); + await page.evaluate(() => { + localStorage.removeItem('meshcore-user-theme'); + window.SITE_CONFIG = window.SITE_CONFIG || {}; + window.SITE_CONFIG.home = { + heroTitle: 'Server Hero (E2E)', + heroSubtitle: 'Server Subtitle (E2E)', + steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }], + checklist: [{ question: 'Server Q', answer: 'Server A' }], + footerLinks: [{ label: 'Server Link', url: '#/server' }] + }; + }); + const before = await page.evaluate(() => JSON.stringify(window.SITE_CONFIG && window.SITE_CONFIG.home)); + const btn = await page.$('#customizeToggle, button[title*="ustom" i], [class*="customize"]'); + if (!btn) { + console.log(' ⏭️ Customizer toggle not found — TODO: requires running server'); + return; + } + await btn.click(); + await page.waitForTimeout(200); + const after = await page.evaluate(() => JSON.stringify(window.SITE_CONFIG && window.SITE_CONFIG.home)); + assert(after === before, 'Opening customizer should not mutate server home config'); + }); + + await test('Home customization persists through page refresh', async () => { + // TODO: requires running server with full customize/home wiring + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]'); + const toggleSelector = '#customizeToggle, button[title*="ustom" i], button[aria-label*="theme" i], [class*="customize"]'; + const btn = await page.$(toggleSelector); + if (!btn) { + console.log(' ⏭️ Customizer toggle not found — TODO: requires running server'); + return; + } + const editedHero = 'Persisted Hero From Playwright'; + await page.click(toggleSelector); + const homeTab = page.locator('.cust-tab[data-tab="home"]'); + await homeTab.waitFor({ state: 'visible', timeout: 10000 }); + await homeTab.click(); + const heroInput = page.locator('#cust-heroTitle'); + if (await heroInput.count() === 0) { + console.log(' ⏭️ #cust-heroTitle not found — TODO: requires running server'); + return; + } + await heroInput.waitFor({ state: 'visible', timeout: 10000 }); + await heroInput.fill(editedHero); + await page.waitForTimeout(700); // autoSave debounce is 500ms + await page.reload({ waitUntil: 'domcontentloaded' }); + const persistedHero = await page.evaluate(() => { + try { + const saved = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); + return saved && saved.home ? saved.home.heroTitle : ''; + } catch { + return ''; + } + }); + assert(persistedHero === editedHero, `Expected persisted hero "${editedHero}" but got "${persistedHero}"`); + }); + // Test 7: Dark mode toggle (fresh navigation \u2014 customizer panel may be open) await test('Dark mode toggle', async () => { await page.goto(BASE, { waitUntil: 'domcontentloaded' }); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index cbafae1..31d02f7 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -1857,13 +1857,76 @@ console.log('\n=== customize.js: initState merge behavior ==='); 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); } };})();' + 'window.__customizeExport = { initState: initState, autoSave: autoSave, getState: function () { return state; }, getDefaults: function () { return deepClone(DEFAULTS); }, setInitialized: function (v) { _initialized = !!v; } };})();' ); vm.runInContext(withExports, ctx); for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; return ctx.window.__customizeExport; } + test('autoSave no-ops before initialization on panel open path', () => { + const ctx = makeSandbox(); + let saveTimerCalls = 0; + ctx.setTimeout = function () { saveTimerCalls++; return 1; }; + ctx.clearTimeout = function () {}; + ctx.window.SITE_CONFIG = { home: { heroTitle: 'Server Hero' } }; + const ex = loadCustomizeExports(ctx); + ex.initState(); + ex.setInitialized(false); + ex.autoSave(); + assert.strictEqual(saveTimerCalls, 0); + assert.strictEqual(ctx.localStorage.getItem('meshcore-user-theme'), null); + }); + + test('server home config survives customizer open without modification', () => { + const ctx = makeSandbox(); + let saveTimerCalls = 0; + ctx.setTimeout = function () { saveTimerCalls++; return 1; }; + ctx.clearTimeout = function () {}; + 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' }] + } + }; + const before = JSON.stringify(ctx.window.SITE_CONFIG.home); + const ex = loadCustomizeExports(ctx); + ex.initState(); + ex.setInitialized(false); + ex.autoSave(); + assert.strictEqual(saveTimerCalls, 0); + assert.strictEqual(JSON.stringify(ctx.window.SITE_CONFIG.home), before); + }); + + test('post-init autoSave exports user theme without mutating SITE_CONFIG.home', () => { + const ctx = makeSandbox(); + let saveTimerCalls = 0; + ctx.setTimeout = function (fn) { saveTimerCalls++; fn(); return 1; }; + ctx.clearTimeout = function () {}; + ctx.HashChangeEvent = function HashChangeEvent(type) { this.type = type; }; + 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' }] + } + }; + const before = JSON.stringify(ctx.window.SITE_CONFIG.home); + const ex = loadCustomizeExports(ctx); + ex.initState(); + ex.setInitialized(true); + ex.autoSave(); + const saved = ctx.localStorage.getItem('meshcore-user-theme'); + assert.strictEqual(saveTimerCalls, 1); + assert(saved && saved.length > 0, 'Expected autoSave to persist user theme'); + assert.strictEqual(JSON.stringify(ctx.window.SITE_CONFIG.home), before); + }); + test('partial local checklist does not wipe steps/footerLinks and keeps server colors', () => { const ctx = makeSandbox(); ctx.window.SITE_CONFIG = { @@ -1956,6 +2019,58 @@ console.log('\n=== customize.js: initState merge behavior ==='); }); } +// ===== APP.JS: home rehydration merge ===== +console.log('\n=== app.js: home rehydration merge ==='); +{ + test('mergeUserHomeConfig layers local home overrides on server home', () => { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + const merged = ctx.mergeUserHomeConfig( + { + home: { + heroTitle: 'Server Hero', + heroSubtitle: 'Server Subtitle', + steps: [{ title: 'Server Step' }], + footerLinks: [{ label: 'Server Link' }] + } + }, + { + home: { + heroSubtitle: 'Local Subtitle', + checklist: [{ question: 'Local Q', answer: 'Local A' }] + } + } + ); + assert.strictEqual(merged.home.heroTitle, 'Server Hero'); + assert.strictEqual(merged.home.heroSubtitle, 'Local Subtitle'); + assert.strictEqual(merged.home.steps[0].title, 'Server Step'); + assert.strictEqual(merged.home.footerLinks[0].label, 'Server Link'); + assert.strictEqual(merged.home.checklist[0].question, 'Local Q'); + }); + + test('mergeUserHomeConfig handles refresh-style localStorage payload', () => { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({ + home: { heroTitle: 'Local Hero' } + })); + const cfg = { + home: { + heroTitle: 'Server Hero', + heroSubtitle: 'Server Subtitle', + steps: [{ title: 'Server Step' }] + } + }; + const userTheme = JSON.parse(ctx.localStorage.getItem('meshcore-user-theme') || '{}'); + const merged = ctx.mergeUserHomeConfig(cfg, userTheme); + assert.strictEqual(merged.home.heroTitle, 'Local Hero'); + assert.strictEqual(merged.home.heroSubtitle, 'Server Subtitle'); + assert.strictEqual(merged.home.steps[0].title, 'Server Step'); + }); +} + // ===== CHANNELS.JS: WS Region Filter helper ===== console.log('\n=== channels.js: shouldProcessWSMessageForRegion ==='); {