diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 423437af..ef08da70 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -293,6 +293,11 @@ jobs:
BASE_URL=http://localhost:13581 node test-channel-decrypt-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-qr-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-color-picker-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node test-customize-theme-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node test-customize-branding-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node test-customize-display-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node test-customize-export-e2e.js 2>&1 | tee -a e2e-output.txt
+ CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
diff --git a/test-customize-branding-e2e.js b/test-customize-branding-e2e.js
new file mode 100644
index 00000000..cbf94954
--- /dev/null
+++ b/test-customize-branding-e2e.js
@@ -0,0 +1,112 @@
+/**
+ * E2E (#1297 B4): Customizer V2 — Branding (siteName, tagline, logo, favicon)
+ *
+ * Verifies the branding subsystem in public/customize-v2.js:
+ * - Site name input → updates .brand-text + document.title live
+ * - Logo URL input → swaps inline SVG for
(PR #1137 helper)
+ * - Override persisted to cs-theme-overrides.branding
+ * - Survives reload
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-customize-branding-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+const BASE = process.env.BASE_URL || 'http://localhost:3000';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' \u2713 ' + name); }
+ catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(8000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ console.log(`\n=== #1297 B4 customize-branding E2E against ${BASE} ===`);
+
+ await step('setup: clear overrides + load', async () => {
+ await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ });
+
+ await step('open customizer + switch to branding tab', async () => {
+ await page.click('#customizeToggle');
+ await page.waitForSelector('.cust-overlay:not(.hidden)');
+ const brandingTab = await page.$('.cust-tab[data-tab="branding"]');
+ if (brandingTab) await brandingTab.click();
+ await page.waitForSelector('input[data-cv2-field="branding.siteName"]', { timeout: 4000 });
+ });
+
+ await step('siteName input updates document.title live', async () => {
+ const inp = await page.$('input[data-cv2-field="branding.siteName"]');
+ assert(inp, 'branding.siteName input missing');
+ await page.evaluate((el) => {
+ el.value = 'MyMeshTest';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ }, inp);
+ await page.waitForTimeout(400);
+ const title = await page.title();
+ assert(title === 'MyMeshTest', 'document.title should update live, got: ' + title);
+ });
+
+ await step('siteName persists to cs-theme-overrides.branding.siteName', async () => {
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ assert(raw, 'overrides not written');
+ const parsed = JSON.parse(raw);
+ assert(parsed.branding && parsed.branding.siteName === 'MyMeshTest',
+ 'branding.siteName missing in overrides: ' + raw);
+ });
+
+ await step('logoUrl input triggers _setBrandLogoUrl helper (swaps SVG → img)', async () => {
+ const inp = await page.$('input[data-cv2-field="branding.logoUrl"]');
+ assert(inp, 'branding.logoUrl input missing');
+ const testUrl = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=';
+ await page.evaluate((args) => {
+ args.el.value = args.url;
+ args.el.dispatchEvent(new Event('input', { bubbles: true }));
+ }, { el: inp, url: testUrl });
+ await page.waitForTimeout(300);
+ // Brand logo node should now be
+ const tag = await page.evaluate(() => {
+ const n = document.querySelector('.nav-brand .brand-logo');
+ return n ? n.tagName.toLowerCase() : null;
+ });
+ assert(tag === 'img', 'expected brand-logo to be
after logoUrl set, got: ' + tag);
+ const src = await page.evaluate(() => {
+ const n = document.querySelector('.nav-brand .brand-logo');
+ return n ? n.getAttribute('src') : null;
+ });
+ assert(src === testUrl, 'brand-logo src should match URL, got: ' + (src || '').slice(0, 40));
+ });
+
+ await step('branding overrides persist across reload', async () => {
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ const title = await page.title();
+ // app.js applies branding.siteName to document.title on init (or customizer pipeline does)
+ // At minimum, the override is still in localStorage:
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw);
+ assert(parsed.branding && parsed.branding.siteName === 'MyMeshTest',
+ 'siteName override should persist, got: ' + raw);
+ });
+
+ await step('cleanup: clear overrides', async () => {
+ await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
+ });
+
+ await browser.close();
+ console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
+ process.exit(failed > 0 ? 1 : 0);
+})();
diff --git a/test-customize-display-e2e.js b/test-customize-display-e2e.js
new file mode 100644
index 00000000..f379d441
--- /dev/null
+++ b/test-customize-display-e2e.js
@@ -0,0 +1,121 @@
+/**
+ * E2E (#1297 B4): Customizer V2 — Display settings (timestamps, distance, heatmap opacity sliders)
+ *
+ * Exercises:
+ * - Display tab renders timestamp/distance/heatmap controls
+ * - Select change for distanceUnit persists to overrides
+ * - Heatmap opacity slider writes scalar override (cs-theme-overrides.heatmapOpacity)
+ * - Reload preserves the values
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-customize-display-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+const BASE = process.env.BASE_URL || 'http://localhost:3000';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' \u2713 ' + name); }
+ catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(8000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ console.log(`\n=== #1297 B4 customize-display E2E against ${BASE} ===`);
+
+ await step('setup', async () => {
+ await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ });
+
+ await step('open customizer + switch to display tab', async () => {
+ await page.click('#customizeToggle');
+ await page.waitForSelector('.cust-overlay:not(.hidden)');
+ const tab = await page.$('.cust-tab[data-tab="display"]');
+ if (tab) await tab.click();
+ await page.waitForSelector('[data-cv2-select="distanceUnit"]', { timeout: 4000 });
+ });
+
+ await step('distanceUnit select writes scalar override', async () => {
+ await page.selectOption('[data-cv2-select="distanceUnit"]', 'mi');
+ await page.waitForTimeout(400);
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw || '{}');
+ assert(parsed.distanceUnit === 'mi', 'distanceUnit should be "mi", got: ' + raw);
+ });
+
+ await step('timestamps.defaultMode select writes nested override', async () => {
+ await page.selectOption('[data-cv2-select="timestamps.defaultMode"]', 'absolute');
+ await page.waitForTimeout(400);
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw);
+ assert(parsed.timestamps && parsed.timestamps.defaultMode === 'absolute',
+ 'timestamps.defaultMode missing, got: ' + raw);
+ });
+
+ await step('heatmap opacity slider — switch to nodes tab + drag slider', async () => {
+ const nodesTab = await page.$('.cust-tab[data-tab="nodes"]');
+ if (nodesTab) await nodesTab.click();
+ await page.waitForSelector('[data-cv2-slider="heatmapOpacity"]', { timeout: 4000 });
+ const slider = await page.$('[data-cv2-slider="heatmapOpacity"]');
+ // Fire input + change events with a specific value
+ await page.evaluate((el) => {
+ el.value = '75';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ }, slider);
+ await page.waitForTimeout(400);
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw);
+ assert(typeof parsed.heatmapOpacity === 'number',
+ 'heatmapOpacity should be numeric, got: ' + JSON.stringify(parsed.heatmapOpacity));
+ assert(Math.abs(parsed.heatmapOpacity - 0.75) < 0.001,
+ 'heatmapOpacity should be 0.75, got: ' + parsed.heatmapOpacity);
+ });
+
+ await step('node role color picker writes typed override', async () => {
+ const picker = await page.$('input[data-cv2-field="nodeColors.repeater"]');
+ assert(picker, 'nodeColors.repeater picker missing');
+ await page.evaluate((el) => {
+ el.value = '#aabbcc';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ }, picker);
+ await page.waitForTimeout(400);
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw);
+ assert(parsed.nodeColors && parsed.nodeColors.repeater === '#aabbcc',
+ 'nodeColors.repeater missing/wrong, got: ' + raw);
+ });
+
+ await step('all display overrides survive reload', async () => {
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw);
+ assert(parsed.distanceUnit === 'mi', 'distanceUnit lost on reload');
+ assert(parsed.timestamps.defaultMode === 'absolute', 'timestamps.defaultMode lost on reload');
+ assert(Math.abs(parsed.heatmapOpacity - 0.75) < 0.001, 'heatmapOpacity lost on reload');
+ assert(parsed.nodeColors.repeater === '#aabbcc', 'nodeColors.repeater lost on reload');
+ });
+
+ await step('cleanup', async () => {
+ await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
+ });
+
+ await browser.close();
+ console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
+ process.exit(failed > 0 ? 1 : 0);
+})();
diff --git a/test-customize-export-e2e.js b/test-customize-export-e2e.js
new file mode 100644
index 00000000..2c3967eb
--- /dev/null
+++ b/test-customize-export-e2e.js
@@ -0,0 +1,102 @@
+/**
+ * E2E (#1297 B4): Customizer V2 — Export / Import / Reset
+ *
+ * Exercises export tab + raw JSON area + reset-all button in public/customize-v2.js:
+ * - Write an override → switch to export tab → raw JSON textarea reflects it
+ * - Copy button populates the clipboard via document.execCommand or navigator.clipboard
+ * - Reset All clears cs-theme-overrides and CSS variables revert
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-customize-export-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+const BASE = process.env.BASE_URL || 'http://localhost:3000';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' \u2713 ' + name); }
+ catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(8000);
+ page.on('dialog', async (d) => { await d.accept(); }); // confirm() for reset
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ console.log(`\n=== #1297 B4 customize-export E2E against ${BASE} ===`);
+
+ await step('setup: seed an override before loading', async () => {
+ await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => {
+ localStorage.setItem('cs-theme-overrides',
+ JSON.stringify({ theme: { accent: '#ff00aa' }, distanceUnit: 'mi' }));
+ });
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ });
+
+ await step('open customizer + switch to export tab', async () => {
+ await page.click('#customizeToggle');
+ await page.waitForSelector('.cust-overlay:not(.hidden)');
+ const tab = await page.$('.cust-tab[data-tab="export"]');
+ assert(tab, 'export tab missing');
+ await tab.click();
+ await page.waitForSelector('#cv2ExportJson', { state: 'attached', timeout: 4000 });
+ });
+
+ await step('raw JSON textarea reflects current overrides', async () => {
+ // Open to reveal textarea
+ await page.evaluate(() => {
+ document.querySelectorAll('.cust-overlay details').forEach(d => d.open = true);
+ });
+ const json = await page.$eval('#cv2ExportJson', el => el.value);
+ const parsed = JSON.parse(json);
+ assert(parsed.distanceUnit === 'mi', 'export JSON should include distanceUnit, got: ' + json);
+ assert(parsed.theme && parsed.theme.accent === '#ff00aa',
+ 'export JSON should include theme.accent, got: ' + json);
+ });
+
+ await step('Download button exists and is wired', async () => {
+ const btn = await page.$('#cv2Download');
+ assert(btn, '#cv2Download missing');
+ });
+
+ await step('Reset All clears overrides + reverts CSS variable', async () => {
+ const resetBtn = await page.$('#cv2ResetAll');
+ assert(resetBtn, '#cv2ResetAll missing (should appear when there are overrides)');
+ await resetBtn.click();
+ await page.waitForTimeout(500);
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ assert(raw === null || raw === '{}' || (raw && Object.keys(JSON.parse(raw)).length === 0),
+ 'cs-theme-overrides should be cleared after Reset All, got: ' + raw);
+
+ // After reset, the inline --accent override on documentElement.style should be gone
+ // (server default may still set computed style; we check the inline override)
+ const inlineAccent = await page.evaluate(() =>
+ document.documentElement.style.getPropertyValue('--accent').trim()
+ );
+ // Either empty (truly reset) or replaced by server default (not the user's pink #ff00aa)
+ assert(inlineAccent !== '#ff00aa',
+ 'inline --accent should no longer be #ff00aa after reset, got: ' + inlineAccent);
+ });
+
+ await step('reset persists across reload', async () => {
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ assert(raw === null || raw === '{}' || (raw && Object.keys(JSON.parse(raw)).length === 0),
+ 'overrides should remain cleared after reload, got: ' + raw);
+ });
+
+ await browser.close();
+ console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
+ process.exit(failed > 0 ? 1 : 0);
+})();
diff --git a/test-customize-theme-e2e.js b/test-customize-theme-e2e.js
new file mode 100644
index 00000000..59f3f354
--- /dev/null
+++ b/test-customize-theme-e2e.js
@@ -0,0 +1,148 @@
+/**
+ * E2E (#1297 B4): Customizer V2 — Theme tokens + presets + dark/light mode
+ *
+ * Exercises the theme-tab subsystem of public/customize-v2.js:
+ * - Open customizer panel via #customizeToggle
+ * - Click a preset button → assert CSS variable on documentElement updates
+ * AND localStorage 'cs-theme-overrides' is written
+ * - Change a basic color picker → assert THEME_CSS_MAP[key] CSS var updates
+ * (verifies "all colors via CSS variables" invariant — NOT inline styles)
+ * - Reload page → assert override persists and CSS var still applied
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-customize-theme-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:3000';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' \u2713 ' + name); }
+ catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(8000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ console.log(`\n=== #1297 B4 customize-theme E2E against ${BASE} ===`);
+
+ await step('navigate home and wait for customizer API', async () => {
+ await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ });
+
+ await step('customizer toggle button is wired', async () => {
+ const btn = await page.$('#customizeToggle');
+ assert(btn, '#customizeToggle button missing in nav');
+ });
+
+ await step('click toggle opens .cust-overlay panel', async () => {
+ await page.click('#customizeToggle');
+ await page.waitForSelector('.cust-overlay:not(.hidden)', { timeout: 4000 });
+ const visible = await page.isVisible('.cust-overlay');
+ assert(visible, 'customizer overlay should be visible');
+ });
+
+ await step('theme tab renders preset buttons', async () => {
+ // Switch to theme tab if needed
+ const tabBtn = await page.$('.cust-tab[data-tab="theme"]');
+ if (tabBtn) await tabBtn.click();
+ const presets = await page.$$('.cust-preset-btn[data-preset]');
+ assert(presets.length >= 2, 'expected multiple presets, got ' + presets.length);
+ });
+
+ await step('clicking preset writes localStorage AND updates CSS vars (not inline styles)', async () => {
+ // Find a non-active preset
+ const presetIds = await page.$$eval('.cust-preset-btn[data-preset]', els =>
+ els.filter(e => !e.classList.contains('active')).map(e => e.getAttribute('data-preset'))
+ );
+ assert(presetIds.length > 0, 'need at least one non-active preset');
+ const presetId = presetIds[0];
+
+ await page.click('.cust-preset-btn[data-preset="' + presetId + '"]');
+ await page.waitForTimeout(400); // debounced write
+
+ // Assert localStorage
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ assert(raw, 'cs-theme-overrides not written after preset click');
+ const parsed = JSON.parse(raw);
+ assert(parsed.theme || parsed.themeDark, 'preset write should include theme section, got ' + raw);
+
+ // Assert CSS var on :root reflects new accent (vs hardcoded inline style)
+ const accentVar = await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()
+ );
+ assert(accentVar.length > 0, '--accent CSS variable should be set on documentElement');
+ assert(accentVar.startsWith('#') || accentVar.startsWith('rgb'),
+ '--accent should be a color value, got: ' + accentVar);
+ });
+
+ await step('color picker change updates CSS variable (THEME_CSS_MAP invariant)', async () => {
+ // Find the accent color picker — determine which section is active (theme vs themeDark)
+ const pickerInfo = await page.evaluate(() => {
+ const el = document.querySelector('input[data-cv2-field="theme.accent"], input[data-cv2-field="themeDark.accent"]');
+ if (!el) return null;
+ return { section: el.dataset.cv2Field.split('.')[0] };
+ });
+ assert(pickerInfo, 'accent color picker not found');
+ const themeKey = pickerInfo.section; // 'theme' or 'themeDark'
+
+ // Set a known color via the input event
+ await page.evaluate((sel) => {
+ const el = document.querySelector(sel);
+ el.value = '#ff00aa';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ }, 'input[data-cv2-field="' + themeKey + '.accent"]');
+ await page.waitForTimeout(400);
+
+ // CSS variable should be updated
+ const accentVal = await page.evaluate(() =>
+ document.documentElement.style.getPropertyValue('--accent').trim()
+ );
+ assert(accentVal === '#ff00aa', 'expected inline --accent=#ff00aa on documentElement, got ' + accentVal);
+
+ // localStorage should reflect the override in the *active* section
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw);
+ assert(parsed[themeKey] && parsed[themeKey].accent === '#ff00aa',
+ 'localStorage[' + themeKey + '].accent should be #ff00aa, got ' + raw);
+ // Save the key for the persist-across-reload assertion
+ page._themeKey = themeKey;
+ });
+
+ await step('override persists across reload', async () => {
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForFunction(() => window._customizerV2 && window._customizerV2.initDone, null, { timeout: 8000 });
+ const accentVal = await page.evaluate(() =>
+ document.documentElement.style.getPropertyValue('--accent').trim()
+ );
+ assert(accentVal === '#ff00aa', 'expected --accent to persist as #ff00aa after reload, got: ' + accentVal);
+ const raw = await page.evaluate(() => localStorage.getItem('cs-theme-overrides'));
+ const parsed = JSON.parse(raw);
+ // After reload, the override may be in theme or themeDark depending on active mode
+ const themeKey = (parsed.themeDark && parsed.themeDark.accent === '#ff00aa') ? 'themeDark' :
+ (parsed.theme && parsed.theme.accent === '#ff00aa') ? 'theme' : null;
+ assert(themeKey, 'persisted override missing — neither theme.accent nor themeDark.accent is #ff00aa, got: ' + raw);
+ });
+
+ await step('cleanup: clear overrides', async () => {
+ await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
+ });
+
+ await browser.close();
+ console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
+ process.exit(failed > 0 ? 1 : 0);
+})();
diff --git a/test-drag-manager-e2e.js b/test-drag-manager-e2e.js
new file mode 100644
index 00000000..6a49ff7c
--- /dev/null
+++ b/test-drag-manager-e2e.js
@@ -0,0 +1,146 @@
+/**
+ * E2E (#1297 B4): drag-manager.js — Real Playwright pointer drag on a live panel
+ *
+ * Exercises public/drag-manager.js via the /live route which registers
+ * #liveFeed, #liveLegend, #liveNodeDetail panels. We use Playwright's pointer
+ * API (mouse.move/down/up) to simulate a drag, and assert:
+ * - data-position attribute is removed (free-form mode engaged)
+ * - data-dragged='true' is set
+ * - panel-drag-{id} localStorage entry contains xPct/yPct
+ * - Restoring on reload re-applies the position
+ *
+ * Usage: BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+const BASE = process.env.BASE_URL || 'http://localhost:3000';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' \u2713 ' + name); }
+ catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
+}
+function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
+
+(async () => {
+ const browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ // Desktop viewport: drag is disabled below 768px or on coarse pointer
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(10000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ console.log(`\n=== #1297 B4 drag-manager E2E against ${BASE} ===`);
+
+ await step('navigate to /live', async () => {
+ await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
+ // Clear any prior drag positions
+ await page.evaluate(() => {
+ ['liveFeed', 'liveLegend', 'liveNodeDetail'].forEach(id => {
+ try { localStorage.removeItem('panel-drag-' + id); } catch (_) {}
+ });
+ });
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForSelector('#liveFeed .panel-header', { timeout: 8000 });
+ });
+
+ await step('DragManager is initialized', async () => {
+ const hasDM = await page.evaluate(() => typeof window.DragManager === 'function');
+ assert(hasDM, 'window.DragManager should be defined');
+ });
+
+ await step('pointer drag on #liveFeed transitions panel into free-form mode', async () => {
+ // Locate the panel-header rect
+ const headerBox = await page.$eval('#liveFeed .panel-header', el => {
+ const r = el.getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ });
+
+ // Real mouse drag — outside the dead zone (5px)
+ await page.mouse.move(headerBox.x, headerBox.y);
+ await page.mouse.down();
+ await page.mouse.move(headerBox.x + 120, headerBox.y + 80, { steps: 8 });
+ await page.mouse.up();
+ await page.waitForTimeout(200);
+
+ const state = await page.$eval('#liveFeed', el => ({
+ dragged: el.dataset.dragged,
+ position: el.getAttribute('data-position'),
+ left: el.style.left,
+ top: el.style.top,
+ }));
+ assert(state.dragged === 'true', 'data-dragged should be "true", got: ' + JSON.stringify(state));
+ assert(state.position === null, 'data-position should be removed, got: ' + state.position);
+ assert(state.left && state.left.endsWith('px'), 'inline left should be px value, got: ' + state.left);
+ });
+
+ await step('panel-drag-liveFeed localStorage entry written with xPct/yPct', async () => {
+ const raw = await page.evaluate(() => localStorage.getItem('panel-drag-liveFeed'));
+ assert(raw, 'panel-drag-liveFeed should be written');
+ const parsed = JSON.parse(raw);
+ assert(typeof parsed.xPct === 'number' && parsed.xPct >= 0 && parsed.xPct <= 1,
+ 'xPct should be in [0,1], got: ' + raw);
+ assert(typeof parsed.yPct === 'number' && parsed.yPct >= 0 && parsed.yPct <= 1,
+ 'yPct should be in [0,1], got: ' + raw);
+ });
+
+ await step('reload restores panel to dragged position via restorePositions()', async () => {
+ const savedRaw = await page.evaluate(() => localStorage.getItem('panel-drag-liveFeed'));
+ const saved = JSON.parse(savedRaw);
+
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForSelector('#liveFeed .panel-header', { timeout: 8000 });
+ await page.waitForTimeout(500); // restorePositions runs after init
+
+ const restored = await page.$eval('#liveFeed', el => ({
+ dragged: el.dataset.dragged,
+ left: el.style.left,
+ top: el.style.top,
+ }));
+ assert(restored.dragged === 'true', 'data-dragged should persist as "true" after reload, got: ' + JSON.stringify(restored));
+
+ // Expected px from saved xPct/yPct × viewport
+ const expectedLeft = Math.round(saved.xPct * 1280);
+ const actualLeft = parseInt(restored.left, 10);
+ assert(Math.abs(actualLeft - expectedLeft) <= 5,
+ 'restored left ~' + expectedLeft + 'px, got ' + restored.left);
+ });
+
+ await step('click without movement (within dead zone) does NOT engage drag', async () => {
+ // Use a fresh panel to avoid prior state
+ await page.evaluate(() => localStorage.removeItem('panel-drag-liveLegend'));
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForSelector('#liveLegend .panel-header', { timeout: 8000 });
+
+ const headerBox = await page.$eval('#liveLegend .panel-header', el => {
+ const r = el.getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ });
+ await page.mouse.move(headerBox.x, headerBox.y);
+ await page.mouse.down();
+ await page.mouse.move(headerBox.x + 2, headerBox.y + 2); // inside DEAD_ZONE (5px)
+ await page.mouse.up();
+ await page.waitForTimeout(200);
+
+ const raw = await page.evaluate(() => localStorage.getItem('panel-drag-liveLegend'));
+ assert(raw === null, 'dead-zone click should NOT persist a position, got: ' + raw);
+ const dragged = await page.$eval('#liveLegend', el => el.dataset.dragged);
+ assert(dragged !== 'true', 'data-dragged should not be set for dead-zone click, got: ' + dragged);
+ });
+
+ await step('cleanup', async () => {
+ await page.evaluate(() => {
+ ['liveFeed', 'liveLegend', 'liveNodeDetail'].forEach(id => {
+ try { localStorage.removeItem('panel-drag-' + id); } catch (_) {}
+ });
+ });
+ });
+
+ await browser.close();
+ console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
+ process.exit(failed > 0 ? 1 : 0);
+})();