-
- ${escapeHtml(name)}${userBadge}${unreadBadge}
- ${chColor ? 'β' : ''}
- ${time}${removeBtn}
-
-
${escapeHtml(preview)}
+ // Encrypted section collapsed by default; user toggle persisted in localStorage.
+ const collapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
+
+ const sections = [];
+ sections.push(
+ `
+
+ ${mine.length ? mine.map(renderChannelRow).join('') : '
No channels yet β click [+ Add Channel] to add one.
'}
+
`
+ );
+ sections.push(
+ `
+
+ ${network.length ? network.map(renderChannelRow).join('') : '
No public channels reported by the server.
'}
+
`
+ );
+ sections.push(
+ `
+
+
+ ${encrypted.length ? encrypted.map(renderChannelRow).join('') : '
No unkeyed encrypted channels seen.
'}
- `;
- }).join('');
+
`
+ );
+ el.innerHTML = sections.join('');
+
+ // Toggle expand/collapse for the Encrypted section.
+ const toggle = document.getElementById('chEncryptedToggle');
+ if (toggle) {
+ toggle.addEventListener('click', function () {
+ const wasCollapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
+ const next = wasCollapsed ? 'false' : 'true';
+ try { localStorage.setItem('ch-encrypted-collapsed', next); } catch (e) { /* quota */ }
+ renderChannelList();
+ });
+ }
}
async function selectChannel(hash, decryptOpts) {
diff --git a/public/style.css b/public/style.css
index f9fac7ac..83e20db8 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2404,3 +2404,61 @@ th.sort-active { color: var(--accent, #60a5fa); }
color: #fff; text-align: center; text-shadow: none;
border: 1px solid rgba(255,255,255,0.4);
}
+
+/* === #1034 PR1: Channel Add modal + sectioned sidebar === */
+.ch-add-channel-btn {
+ background: var(--accent, #2563eb); color: #fff; border: none;
+ padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
+}
+.ch-add-channel-btn:hover { background: var(--accent-hover, #1d4ed8); }
+
+.ch-modal-overlay { z-index: 1100; }
+.ch-modal-overlay.hidden { display: none; }
+.ch-modal { width: 560px; max-width: 92vw; padding: 24px 24px 16px; position: relative; }
+.ch-modal h3 { margin: 0 0 16px; font-size: 18px; }
+.ch-modal-close {
+ position: absolute; top: 10px; right: 10px;
+ background: transparent; border: none; cursor: pointer;
+ font-size: 18px; color: var(--text-muted); padding: 4px 8px; border-radius: 6px;
+}
+.ch-modal-close:hover { background: var(--row-hover, rgba(0,0,0,0.05)); color: var(--text); }
+.ch-modal-section { padding: 12px 0; border-top: 1px solid var(--border); }
+.ch-modal-section:first-of-type { border-top: none; padding-top: 0; }
+.ch-modal-section-title { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
+.ch-modal-section-hint { margin: 0 0 10px; font-size: 12px; color: var(--text-muted); }
+.ch-modal-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
+.ch-modal-input {
+ flex: 1; min-width: 0; padding: 7px 10px; font-size: 13px;
+ border: 1px solid var(--border); border-radius: 6px;
+ background: var(--input-bg, var(--card-bg)); color: var(--text);
+}
+.ch-modal-input--mono { font-family: var(--mono, monospace); }
+.ch-modal-btn-secondary {
+ background: var(--card-bg); color: var(--text);
+ border: 1px solid var(--border); padding: 7px 12px;
+ border-radius: 6px; cursor: pointer; font-size: 13px;
+}
+.ch-modal-btn-secondary[disabled] { opacity: .5; cursor: not-allowed; }
+.ch-hashtag-row .ch-hashtag-prefix {
+ font-family: var(--mono, monospace); font-size: 14px; color: var(--text-muted); padding: 0 2px;
+}
+.ch-modal-warn { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
+.ch-modal-warn code { background: var(--row-hover, rgba(0,0,0,0.05)); padding: 1px 4px; border-radius: 3px; font-size: 11px; }
+.ch-modal-error { color: var(--status-red, #dc2626); font-size: 12px; margin-top: 4px; }
+.ch-modal-footer {
+ margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);
+ font-size: 12px; color: var(--text-muted); line-height: 1.4;
+}
+.ch-qr-output { font-size: 11px; font-family: var(--mono, monospace); color: var(--text-muted); word-break: break-all; min-height: 14px; padding: 4px 0; }
+
+.ch-section { margin-bottom: 8px; }
+.ch-section-header {
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px 10px; font-size: 11px; font-weight: 700; text-transform: uppercase;
+ letter-spacing: .5px; color: var(--text-muted);
+ background: transparent; border: none; width: 100%; text-align: left; cursor: default;
+}
+.ch-section-toggle { cursor: pointer; }
+.ch-section-toggle:hover { color: var(--text); }
+.ch-section-empty { padding: 8px 12px; font-size: 12px; color: var(--text-muted); font-style: italic; }
+.ch-section-caret { display: inline-block; width: 10px; }
diff --git a/test-all.sh b/test-all.sh
index 5131b663..4c8a70ca 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -15,6 +15,7 @@ node test-frontend-helpers.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
node test-channel-sidebar-layout.js
+node test-channel-modal-ux.js
node test-channel-decrypt-insecure-context.js
node test-channel-qr.js
diff --git a/test-channel-modal-e2e.js b/test-channel-modal-e2e.js
new file mode 100644
index 00000000..c4edf9c1
--- /dev/null
+++ b/test-channel-modal-e2e.js
@@ -0,0 +1,161 @@
+/**
+ * E2E (#1034 PR1): Channel Add modal + sectioned sidebar.
+ *
+ * Boots a headless Chromium against a locally running corescope-server and
+ * exercises:
+ * - sidebar [+ Add Channel] opens modal
+ * - modal renders three labeled sections + privacy footer + QR placeholders
+ * - close (β) hides modal
+ * - sectioned sidebar renders My Channels / Network / Encrypted sections
+ * - PSK add flow: invalid hex β error; valid hex β modal closes
+ *
+ * Usage: BASE_URL=http://localhost:38201 node test-channel-modal-e2e.js
+ */
+'use strict';
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:38201';
+
+let passed = 0, failed = 0;
+async function step(name, fn) {
+ try { await fn(); passed++; console.log(' β ' + name); }
+ catch (e) { failed++; console.error(' β ' + 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=== #1034 PR1 E2E against ${BASE} ===`);
+
+ await step('navigate to /channels', async () => {
+ await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
+ });
+
+ await step('Add Channel button is visible', async () => {
+ const text = await page.textContent('#chAddChannelBtn');
+ assert(/Add Channel/.test(text), 'button text: ' + text);
+ });
+
+ await step('modal hidden on load', async () => {
+ const isHidden = await page.evaluate(() => {
+ const m = document.getElementById('chAddChannelModal');
+ return !!m && (m.classList.contains('hidden') || m.hasAttribute('hidden'));
+ });
+ assert(isHidden, 'modal should start hidden');
+ });
+
+ await step('clicking [+ Add Channel] opens modal', async () => {
+ await page.click('#chAddChannelBtn');
+ await page.waitForSelector('#chAddChannelModal:not(.hidden)', { timeout: 3000 });
+ const visible = await page.isVisible('#chAddChannelModal');
+ assert(visible, 'modal should be visible after click');
+ });
+
+ await step('modal renders all three section titles', async () => {
+ const html = await page.innerHTML('#chAddChannelModal');
+ assert(html.includes('Generate PSK Channel'), 'section 1 missing');
+ assert(html.includes('Add Private Channel (PSK)'), 'section 2 missing');
+ assert(html.includes('Monitor Hashtag Channel'), 'section 3 missing');
+ });
+
+ await step('modal renders QR placeholders', async () => {
+ assert(await page.isVisible('#qr-output'), '#qr-output missing');
+ const scanBtn = await page.$('#scan-qr-btn');
+ assert(scanBtn, '#scan-qr-btn missing');
+ const disabled = await scanBtn.getAttribute('disabled');
+ assert(disabled !== null, '#scan-qr-btn must be disabled placeholder');
+ });
+
+ await step('modal renders privacy footer', async () => {
+ const footer = await page.textContent('#chAddChannelModal .ch-modal-footer');
+ assert(/Keys stay in your browser/.test(footer), 'footer text missing: ' + footer);
+ assert(/passive observer/.test(footer), 'passive observer text missing');
+ });
+
+ await step('modal renders case-sensitivity warning', async () => {
+ const warn = await page.textContent('#chAddChannelModal .ch-modal-warn');
+ assert(/[Cc]ase-sensitive/.test(warn), 'warning missing: ' + warn);
+ });
+
+ await step('PSK add: invalid hex shows inline error', async () => {
+ await page.fill('#chPskKey', 'not-hex');
+ await page.click('#chPskAddBtn');
+ await page.waitForFunction(() => {
+ const e = document.getElementById('chPskError');
+ return e && e.style.display !== 'none' && /hex/i.test(e.textContent);
+ }, { timeout: 3000 });
+ });
+
+ await step('close button (β) hides modal', async () => {
+ await page.click('#chModalClose');
+ await page.waitForFunction(() => {
+ const m = document.getElementById('chAddChannelModal');
+ return m && m.classList.contains('hidden');
+ }, { timeout: 3000 });
+ });
+
+ await step('sidebar renders three sections (My Channels / Network / Encrypted)', async () => {
+ // Wait for channel list to populate from API (or render empty-state).
+ await page.waitForFunction(() => {
+ const el = document.getElementById('chList');
+ if (!el) return false;
+ return el.querySelector('.ch-section-mychannels') &&
+ el.querySelector('.ch-section-network') &&
+ el.querySelector('.ch-section-encrypted');
+ }, { timeout: 8000 });
+ const headers = await page.$$eval('.ch-section-header', els => els.map(e => e.textContent.trim()));
+ const joined = headers.join(' | ');
+ assert(/My Channels/.test(joined), 'My Channels header missing: ' + joined);
+ assert(/Network/.test(joined), 'Network header missing');
+ assert(/Encrypted/.test(joined), 'Encrypted header missing');
+ });
+
+ await step('Encrypted section is collapsed by default', async () => {
+ const collapsed = await page.getAttribute('.ch-section-encrypted', 'data-encrypted-collapsed');
+ assert(collapsed === 'true', 'expected data-encrypted-collapsed=true, got ' + collapsed);
+ const bodyHidden = await page.evaluate(() => {
+ const b = document.getElementById('chEncryptedBody');
+ return b ? b.hasAttribute('hidden') : null;
+ });
+ assert(bodyHidden === true, 'encrypted body should be hidden initially');
+ });
+
+ await step('clicking Encrypted toggle expands it', async () => {
+ await page.click('#chEncryptedToggle');
+ const bodyHidden = await page.evaluate(() => {
+ const b = document.getElementById('chEncryptedBody');
+ return b ? b.hasAttribute('hidden') : null;
+ });
+ assert(bodyHidden === false, 'encrypted body should be visible after toggle');
+ });
+
+ await step('PSK add: valid hex closes modal and persists key', async () => {
+ await page.click('#chAddChannelBtn');
+ await page.waitForSelector('#chAddChannelModal:not(.hidden)');
+ const validHex = 'cafebabe' + '00112233' + '44556677' + '8899aabb';
+ await page.fill('#chPskKey', validHex);
+ await page.fill('#chPskName', 'E2E Test Channel');
+ await page.click('#chPskAddBtn');
+ await page.waitForFunction(() => {
+ const m = document.getElementById('chAddChannelModal');
+ return m && m.classList.contains('hidden');
+ }, { timeout: 5000 });
+ const stored = await page.evaluate(() => localStorage.getItem('corescope_channel_keys') || '');
+ assert(/cafebabe/i.test(stored), 'expected stored key in localStorage corescope_channel_keys, got: ' + stored);
+ });
+
+ await browser.close();
+
+ console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
+ process.exit(failed > 0 ? 1 : 0);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/test-channel-modal-ux.js b/test-channel-modal-ux.js
new file mode 100644
index 00000000..f196eaca
--- /dev/null
+++ b/test-channel-modal-ux.js
@@ -0,0 +1,123 @@
+/**
+ * Tests for #1034 β Channel UX redesign PR1: Modal + sectioned sidebar.
+ *
+ * Pattern follows test-channel-psk-ux.js: string-contract assertions over
+ * public/channels.js + DOM render harness via vm sandbox.
+ *
+ * - [+ Add Channel] button in sidebar (replaces inline form)
+ * - Modal overlay with three labeled sections:
+ * Generate PSK Channel | Add Private Channel (PSK) | Monitor Hashtag Channel
+ * - QR placeholders (#qr-output, #scan-qr-btn[disabled])
+ * - Privacy footer text
+ * - Sectioned sidebar render: My Channels / Network / Encrypted (N)
+ * - "No key" checkbox is gone
+ * - Three modal action handlers wired
+ *
+ * Runs in Node.js β no browser.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+let passed = 0;
+let failed = 0;
+function assert(cond, msg) {
+ if (cond) { passed++; console.log(' β ' + msg); }
+ else { failed++; console.error(' β ' + msg); }
+}
+
+const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
+const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
+
+console.log('\n=== #1034 PR1: [+ Add Channel] sidebar button ===');
+assert(/id="chAddChannelBtn"/.test(chSrc),
+ 'sidebar exposes #chAddChannelBtn (replaces inline form)');
+assert(/\+ Add Channel/.test(chSrc) || /Add Channel/.test(chSrc),
+ '[+ Add Channel] button label present');
+// Old "No key" toggle must be GONE.
+assert(!/No key/.test(chSrc),
+ 'old "No key" checkbox removed from sidebar');
+assert(!/id="chShowEncrypted"/.test(chSrc),
+ 'old #chShowEncrypted toggle removed');
+
+console.log('\n=== #1034 PR1: Modal markup ===');
+assert(/id="chAddChannelModal"/.test(chSrc),
+ 'modal element #chAddChannelModal exists');
+assert(/modal-overlay|ch-modal-overlay/.test(chSrc),
+ 'modal uses overlay pattern (matches existing modal-overlay class)');
+assert(/data-action="ch-modal-close"/.test(chSrc) || /id="chModalClose"/.test(chSrc),
+ 'modal has close affordance (data-action ch-modal-close or #chModalClose)');
+
+console.log('\n=== #1034 PR1: Three sections by label ===');
+assert(/Generate PSK Channel/.test(chSrc),
+ 'section 1 label: "Generate PSK Channel"');
+assert(/Add Private Channel \(PSK\)/.test(chSrc),
+ 'section 2 label: "Add Private Channel (PSK)"');
+assert(/Monitor Hashtag Channel/.test(chSrc),
+ 'section 3 label: "Monitor Hashtag Channel"');
+
+console.log('\n=== #1034 PR1: Section 1 β Generate PSK ===');
+assert(/id="chGenerateName"/.test(chSrc),
+ 'generate section has #chGenerateName input');
+assert(/id="chGenerateBtn"/.test(chSrc),
+ 'generate section has #chGenerateBtn');
+assert(/Generate & Show QR|Generate & Show QR/.test(chSrc),
+ '[Generate & Show QR] button label present');
+assert(/id="qr-output"/.test(chSrc),
+ '#qr-output placeholder div present (QR code render is PR #2)');
+
+console.log('\n=== #1034 PR1: Section 2 β Add PSK ===');
+assert(/id="chPskKey"/.test(chSrc),
+ 'PSK section has #chPskKey input (32-hex)');
+assert(/id="chPskName"/.test(chSrc),
+ 'PSK section has optional #chPskName input');
+assert(/id="chPskAddBtn"/.test(chSrc),
+ 'PSK section has #chPskAddBtn');
+assert(/id="scan-qr-btn"[^>]*disabled/.test(chSrc),
+ '#scan-qr-btn placeholder present and disabled (PR #2 wires it)');
+assert(/\[0-9a-fA-F\]\{32\}|isHexKey/.test(chSrc),
+ 'PSK section validates 32-hex format');
+
+console.log('\n=== #1034 PR1: Section 3 β Monitor Hashtag ===');
+assert(/id="chHashtagName"/.test(chSrc),
+ 'hashtag section has #chHashtagName input');
+assert(/id="chHashtagBtn"/.test(chSrc),
+ 'hashtag section has #chHashtagBtn');
+assert(/Case-sensitive|case-sensitive/.test(chSrc),
+ 'hashtag section shows case-sensitivity warning');
+
+console.log('\n=== #1034 PR1: Privacy footer ===');
+assert(/Keys stay in your browser/.test(chSrc),
+ 'privacy footer "Keys stay in your browser" present');
+assert(/passive observer/.test(chSrc),
+ 'privacy footer mentions "passive observer"');
+
+console.log('\n=== #1034 PR1: Sectioned sidebar ===');
+assert(/ch-section-mychannels|My Channels/.test(chSrc),
+ 'sidebar renders "My Channels" section');
+assert(/ch-section-network|>Network 0 ? 1 : 0);
diff --git a/test-channel-psk-ux.js b/test-channel-psk-ux.js
index 0b0d5d0f..2adb7dd2 100644
--- a/test-channel-psk-ux.js
+++ b/test-channel-psk-ux.js
@@ -80,10 +80,10 @@ async function run() {
console.log('\n=== #1020 PSK UX: channels.js DOM/contract ===');
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
- // E2E DOM: optional label input in add form
- assert(chSrc.includes('id="chKeyLabelInput"'),
- 'add form contains chKeyLabelInput element');
- assert(/placeholder="[^"]*name[^"]*"/i.test(chSrc) || chSrc.includes('chKeyLabelInput'),
+ // E2E DOM: optional label input in add form (now in #1034 modal as #chPskName)
+ assert(chSrc.includes('id="chPskName"') || chSrc.includes('id="chKeyLabelInput"'),
+ 'add form contains optional label input (#chPskName in modal, was #chKeyLabelInput)');
+ assert(/placeholder="[^"]*name[^"]*"/i.test(chSrc) || chSrc.includes('chPskName') || chSrc.includes('chKeyLabelInput'),
'label input has a name-related placeholder');
// E2E DOM: distinct badge class/marker for user-added channels
diff --git a/test-channel-sidebar-layout.js b/test-channel-sidebar-layout.js
index db2a1f05..417d7910 100644
--- a/test-channel-sidebar-layout.js
+++ b/test-channel-sidebar-layout.js
@@ -57,17 +57,18 @@ if (removeRule) {
'.ch-remove-btn must not be opacity:0 by default (was invisible on touch)');
}
-console.log('\n=== "No key" toggle: clearer copy + grouped with controls ===');
-// The ambiguous "π No key" label gets replaced with self-explanatory copy.
-assert(!/>\s*π\s*No key\s*