diff --git a/public/channels.js b/public/channels.js index 69bdcc6e..8c7f1c26 100644 --- a/public/channels.js +++ b/public/channels.js @@ -631,33 +631,68 @@
πŸ’¬ Channels
- -
-
-
-
- - -
-
- -
-
e.g. #LongFast or 32-char hex key β€” decrypted in your browser.
- -
+
+
Loading channels…
+ +
@@ -673,15 +708,10 @@ RegionFilter.init(document.getElementById('chRegionFilter')); - // Encrypted channels toggle (#727) - var showEncryptedCb = document.getElementById('chShowEncrypted'); - var showEncrypted = localStorage.getItem('channels-show-encrypted') === 'true'; - showEncryptedCb.checked = showEncrypted; - showEncryptedCb.addEventListener('change', function () { - showEncrypted = showEncryptedCb.checked; - localStorage.setItem('channels-show-encrypted', showEncrypted ? 'true' : 'false'); - loadChannels(true); - }); + // #1034 PR1: encrypted-channels visibility now driven by sectioned sidebar. + // Always include encrypted channels in the API call; the renderer groups them. + var showEncrypted = true; + try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) { /* quota */ } regionChangeHandler = RegionFilter.onChange(function () { loadChannels(true).then(async function () { @@ -690,36 +720,93 @@ }); }); - // Channel key input handler (#725 M2, improved UX #759) - var chKeyForm = document.getElementById('chKeyForm'); - if (chKeyForm) { - var submitHandler = async function (e) { - e.preventDefault(); - var input = document.getElementById('chKeyInput'); - var labelInput = document.getElementById('chKeyLabelInput'); - var val = (input.value || '').trim(); - var label = labelInput ? (labelInput.value || '').trim() : ''; - if (!val) return; - input.value = ''; - if (labelInput) labelInput.value = ''; - await addUserChannel(val, label); - }; - chKeyForm.addEventListener('submit', submitHandler); - var chKeyInput = document.getElementById('chKeyInput'); - if (chKeyInput) { - chKeyInput.addEventListener('focus', function () { - var st = document.getElementById('chAddStatus'); - if (st) { st.style.display = 'none'; clearTimeout(statusTimer); statusTimer = null; } - }); - } + // #1034 PR1: Add Channel modal wiring (replaces inline form) + var modalEl = document.getElementById('chAddChannelModal'); + function openAddModal() { + if (!modalEl) return; + modalEl.classList.remove('hidden'); + modalEl.removeAttribute('hidden'); + var first = document.getElementById('chGenerateName'); + if (first) try { first.focus(); } catch (e) { /* noop */ } + } + function closeAddModal() { + if (!modalEl) return; + modalEl.classList.add('hidden'); + modalEl.setAttribute('hidden', ''); + var err = document.getElementById('chPskError'); + if (err) { err.style.display = 'none'; err.textContent = ''; } + } + var addBtn = document.getElementById('chAddChannelBtn'); + if (addBtn) addBtn.addEventListener('click', openAddModal); + if (modalEl) { + modalEl.addEventListener('click', function (e) { + // Close on overlay backdrop click or any [data-action=ch-modal-close] + var closeEl = e.target.closest('[data-action="ch-modal-close"]'); + if (closeEl || e.target === modalEl) { + e.preventDefault(); + closeAddModal(); + } + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && !modalEl.classList.contains('hidden')) { + closeAddModal(); + } + }); } - // Auto-enable encrypted toggle if deep-linking to an encrypted channel - if (routeParam && routeParam.startsWith('enc_') && !showEncrypted) { - showEncrypted = true; - showEncryptedCb.checked = true; - localStorage.setItem('channels-show-encrypted', 'true'); - } + // Section 1: Generate PSK + var genBtn = document.getElementById('chGenerateBtn'); + if (genBtn) genBtn.addEventListener('click', async function () { + var nameEl = document.getElementById('chGenerateName'); + var label = nameEl ? (nameEl.value || '').trim() : ''; + // 16 random bytes -> 32-char hex + var bytes = crypto.getRandomValues(new Uint8Array(16)); + var keyHex = ChannelDecrypt.bytesToHex(bytes); + var channelName = 'psk:' + keyHex.substring(0, 8); + ChannelDecrypt.storeKey(channelName, keyHex, label); + var qrOut = document.getElementById('qr-output'); + if (qrOut) { + // PR #2 will render the actual QR code here. For now, surface the key + // so the user can copy/paste it manually. + qrOut.textContent = 'Key generated: ' + keyHex + ' (QR code coming in next update)'; + } + mergeUserChannels(); + renderChannelList(); + showAddStatus('Generated channel ' + (label || channelName), 'success'); + }); + + // Section 2: Add PSK + var pskBtn = document.getElementById('chPskAddBtn'); + if (pskBtn) pskBtn.addEventListener('click', async function () { + var keyEl = document.getElementById('chPskKey'); + var nameEl = document.getElementById('chPskName'); + var errEl = document.getElementById('chPskError'); + var raw = keyEl ? (keyEl.value || '').trim() : ''; + var label = nameEl ? (nameEl.value || '').trim() : ''; + if (!isHexKey(raw)) { + if (errEl) { errEl.textContent = 'Key must be 32 hex characters (0–9, a–f).'; errEl.style.display = ''; } + return; + } + if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; } + closeAddModal(); + if (keyEl) keyEl.value = ''; + if (nameEl) nameEl.value = ''; + await addUserChannel(raw.toLowerCase(), label); + }); + + // Section 3: Monitor Hashtag + var tagBtn = document.getElementById('chHashtagBtn'); + if (tagBtn) tagBtn.addEventListener('click', async function () { + var tagEl = document.getElementById('chHashtagName'); + var raw = tagEl ? (tagEl.value || '').trim() : ''; + if (!raw) return; + // Strip a leading '#' if the user typed one β€” the prefix is implicit. + if (raw.charAt(0) === '#') raw = raw.substring(1); + if (!raw) return; + closeAddModal(); + if (tagEl) tagEl.value = ''; + await addUserChannel('#' + raw, ''); + }); loadObserverRegions(); loadChannels().then(async function () { @@ -1182,74 +1269,107 @@ } } + // #1034 PR1: render a single channel row (used by all sidebar sections). + function renderChannelRow(ch) { + const isEncrypted = ch.encrypted === true; + const isUserAdded = ch.userAdded === true; + const baseName = isEncrypted ? (ch.name || 'Unknown') : (ch.name || `Channel ${formatHashHex(ch.hash)}`); + const name = (isUserAdded && ch.userLabel) ? ch.userLabel : baseName; + const color = isEncrypted && !isUserAdded ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash); + const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : ''; + const preview = isUserAdded + ? (ch.lastSender && ch.lastMessage + ? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}` + : `${ch.messageCount || 0} messages (your key)`) + : isEncrypted + ? `0x${formatHashHex(ch.hash)} Β· ${ch.messageCount || 0} packets` + : ch.lastSender && ch.lastMessage + ? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}` + : `${ch.messageCount || 0} messages`; + const sel = selectedHash === ch.hash ? ' selected' : ''; + const encClass = isUserAdded + ? ' ch-user-added' + : (isEncrypted ? ' ch-encrypted' : ''); + const badgeIcon = isUserAdded ? 'πŸ”“' : (isEncrypted ? 'πŸ”’' : null); + const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase()); + const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null; + const dotStyle = chColor ? ` style="background:${chColor}"` : ''; + const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : ''; + // #1033: must NOT be a `; + } + + // #1034 PR1: sectioned sidebar β€” My Channels / Network / Encrypted (N). function renderChannelList() { const el = document.getElementById('chList'); if (!el) return; if (channels.length === 0) { el.innerHTML = '
No channels found
'; return; } - // Sort by message count desc - const sorted = [...channels].sort((a, b) => { - return (b.messageCount || 0) - (a.messageCount || 0); - }); + const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0); + const sortByCount = (a, b) => (b.messageCount || 0) - (a.messageCount || 0); - el.innerHTML = sorted.map(ch => { - const isEncrypted = ch.encrypted === true; - const isUserAdded = ch.userAdded === true; - // #1020: prefer user-supplied label over psk: - const baseName = isEncrypted ? (ch.name || 'Unknown') : (ch.name || `Channel ${formatHashHex(ch.hash)}`); - const name = (isUserAdded && ch.userLabel) ? ch.userLabel : baseName; - const color = isEncrypted ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash); - const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : ''; - const preview = isUserAdded - ? (ch.lastSender && ch.lastMessage - ? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}` - : `${ch.messageCount || 0} messages (your key)`) - : isEncrypted - ? `${ch.messageCount} encrypted messages (no key configured)` - : ch.lastSender && ch.lastMessage - ? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}` - : `${ch.messageCount} messages`; - const sel = selectedHash === ch.hash ? ' selected' : ''; - // #1020: distinct class so styling/tests can tell user-added apart - // from server-known encrypted channels. - const encClass = isUserAdded - ? ' ch-user-added' - : (isEncrypted ? ' ch-encrypted' : ''); - // #1020: πŸ”“ marks "I have the key" vs πŸ”’ "encrypted, no key" - const badgeIcon = isUserAdded ? 'πŸ”“' : (isEncrypted ? 'πŸ”’' : null); - const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase()); - // Channel color dot for color picker (#674) - const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null; - const dotStyle = chColor ? ` style="background:${chColor}"` : ''; - // Left border for assigned color - const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : ''; - // M4 / #1020: Remove affordance for user-added channels. - // MUST NOT be a +
+ ${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*