feat(#1034): channel UX redesign PR1 — Add Channel modal + sectioned sidebar (#1037)

## Summary

PR 1 of 3 for #1034 — channel UX redesign. Replaces the cramped inline
"type a name or 32-hex blob" form with a clear modal dialog, and
reorganizes the sidebar into three labeled sections.

**Scope of this PR:** Modal UI + sectioned sidebar. QR generation/scan
is deferred to PR #2 (placeholders are wired and ready).
`channel-decrypt.js` crypto is untouched.

## What changed

### New modal: `[+ Add Channel]`

Triggered by the new sidebar button. Three sections:

1. **Generate PSK Channel** — name + `[Generate & Show QR]` →
`crypto.getRandomValues(16)` → hex → `ChannelDecrypt.storeKey`. QR
rendering ships in PR #2; for now `#qr-output` surfaces the hex key as
text.
2. **Add Private Channel (PSK)** — 32-hex input (regex-validated),
optional display name, `[Add]`. `[📷 Scan QR]` placeholder is present but
`disabled` (PR #2 wires it).
3. **Monitor Hashtag Channel** — non-editable `#` prefix + free text +
case-sensitivity warning + `[Monitor]`. Reuses
`ChannelDecrypt.deriveKey`.

Privacy footer: _"🔒 Keys stay in your browser. CoreScope is a passive
observer..."_

Close ✕, backdrop click, and Escape all dismiss.

### Sectioned sidebar

`renderChannelList()` rewritten to render three sections:

- **My Channels** — `userAdded` channels. ✕ always visible. Last sender
+ relative time.
- **Network** — server-known cleartext channels.
- **Encrypted (N)** — collapsed by default (toggle persists in
`localStorage`). Shows hash byte + packet count.

The legacy "🔒 No key" checkbox and `#chShowEncrypted` toggle are removed
entirely. Encrypted channels are always fetched; the renderer groups
them.

## Tests

- **Unit** — `test-channel-modal-ux.js` (33 assertions): added to
`test-all.sh`. Covers sidebar button, modal markup, three sections, QR
placeholders, privacy footer, sectioned sidebar, modal handlers (incl.
`crypto.getRandomValues(16)`).
- **E2E** — `test-channel-modal-e2e.js` (Playwright, 14 steps). Covers
modal open/close, section rendering, invalid-hex error, valid-hex
storage, encrypted-section toggle. Run with:
  ```
CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:38201
node test-channel-modal-e2e.js
  ```
- `test-channel-psk-ux.js` — updated to reference `#chPskName` (was
`#chKeyLabelInput`).

### Red→green proof

- Red commit (`7ee421b`): test added with 31 expected assertion
failures, no source change.
- Green commit (`897be8f`): implementation lands, test passes 33/33.

## Browser-validated

Built `cmd/server/`, ran against `test-fixtures/e2e-fixture.db`,
exercised modal open → invalid hex → valid hex → key persisted → modal
closes → sectioned sidebar renders + Encrypted toggle expands. All 14
E2E steps pass.

## What's NOT in this PR

- QR code rendering (PR #2)
- Camera/QR scanning (PR #2)
- Migration of legacy localStorage format (PR #3, if needed — current
key format is unchanged)
- `channel-decrypt.js` changes (none — UI-only PR)

## Acceptance criteria from #1034

- [x] Modal opens on `[+ Add Channel]` click
- [x] Three sections clearly separated with labels
- [x] Add PSK: accepts 32-hex (QR scan = PR #2)
- [x] Monitor Hashtag: derives key, case-sensitivity warning shown
- [x] Privacy footer present
- [x] Sidebar: three sections (My Channels / Network / Encrypted)
- [x] ✕ button visible and functional on My Channels entries
- [x] "No key" checkbox removed
- [ ] Generate PSK QR display — text fallback only; QR is PR #2
- [ ] Old stored keys migrate seamlessly — no migration needed (storage
format unchanged)

Refs #1034

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
This commit is contained in:
Kpa-clawbot
2026-05-04 18:40:46 -07:00
committed by GitHub
parent 71f82d5d25
commit cea2c70d12
7 changed files with 596 additions and 132 deletions
+237 -117
View File
@@ -631,33 +631,68 @@
<div class="ch-sidebar" aria-label="Channel list">
<div class="ch-sidebar-header">
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
<label class="ch-encrypted-toggle" title="Show encrypted channels you don't have a key for (locked, can't decrypt)">
<input type="checkbox" id="chShowEncrypted"> <span class="ch-toggle-label">🔒 Show encrypted (no key)</span>
</label>
</div>
<div class="ch-key-input-wrap" style="padding:4px 8px">
<form id="chKeyForm" autocomplete="off" class="ch-add-form">
<div class="ch-add-row">
<input type="text" id="chKeyInput" class="ch-key-input"
placeholder="#channelname"
aria-label="Channel name or hex key" spellcheck="false">
<button type="submit" class="ch-add-btn" title="Add channel">+</button>
</div>
<div class="ch-add-row">
<input type="text" id="chKeyLabelInput" class="ch-key-label-input"
placeholder="optional name (e.g. My Crew)"
aria-label="Optional display name for this channel" spellcheck="false">
</div>
<div class="ch-add-hint">e.g. #LongFast or 32-char hex key — decrypted in your browser.</div>
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
</form>
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add Channel</button>
</div>
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
<div class="ch-loading">Loading channels…</div>
</div>
<div class="ch-sidebar-resize" aria-hidden="true"></div>
</div>
<!-- #1034 PR1: Add Channel modal -->
<div id="chAddChannelModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chModalTitle" hidden>
<div class="modal ch-modal" role="document">
<button type="button" class="modal-close ch-modal-close" id="chModalClose" data-action="ch-modal-close" aria-label="Close">✕</button>
<h3 id="chModalTitle">Add Channel</h3>
<section class="ch-modal-section" aria-labelledby="chSecGenTitle">
<h4 id="chSecGenTitle" class="ch-modal-section-title">Generate PSK Channel</h4>
<p class="ch-modal-section-hint">Create a new private channel with a random key. Share the QR code with others to add it.</p>
<div class="ch-modal-row">
<input type="text" id="chGenerateName" class="ch-modal-input" placeholder="Channel name (e.g. My Crew)" aria-label="Channel name" spellcheck="false">
<button type="button" id="chGenerateBtn" class="btn-primary">Generate &amp; Show QR</button>
</div>
<div id="qr-output" class="ch-qr-output" aria-live="polite"></div>
</section>
<section class="ch-modal-section" aria-labelledby="chSecPskTitle">
<h4 id="chSecPskTitle" class="ch-modal-section-title">Add Private Channel (PSK)</h4>
<p class="ch-modal-section-hint">Paste a 32-character hex key someone shared with you, or scan their QR code.</p>
<div class="ch-modal-row">
<input type="text" id="chPskKey" class="ch-modal-input ch-modal-input--mono"
placeholder="32-char hex key (0-9, a-f)"
pattern="[0-9a-fA-F]{32}"
maxlength="32"
aria-label="32-character hex PSK key" spellcheck="false" autocomplete="off">
<button type="button" id="scan-qr-btn" class="ch-modal-btn-secondary" disabled title="QR scanning ships in the next update">📷 Scan QR</button>
</div>
<div class="ch-modal-row">
<input type="text" id="chPskName" class="ch-modal-input" placeholder="Display name (optional)" aria-label="Optional display name" spellcheck="false">
<button type="button" id="chPskAddBtn" class="btn-primary">Add</button>
</div>
<div id="chPskError" class="ch-modal-error" style="display:none" role="alert"></div>
</section>
<section class="ch-modal-section" aria-labelledby="chSecTagTitle">
<h4 id="chSecTagTitle" class="ch-modal-section-title">Monitor Hashtag Channel</h4>
<p class="ch-modal-section-hint">Decrypt traffic on a public hashtag channel by deriving the key from its name.</p>
<div class="ch-modal-row ch-hashtag-row">
<span class="ch-hashtag-prefix" aria-hidden="true">#</span>
<input type="text" id="chHashtagName" class="ch-modal-input"
placeholder="LongFast"
aria-label="Hashtag channel name (without #)" spellcheck="false" autocomplete="off">
<button type="button" id="chHashtagBtn" class="btn-primary">Monitor</button>
</div>
<div class="ch-modal-warn">⚠ Case-sensitive — <code>#LongFast</code> ≠ <code>#longfast</code></div>
</section>
<div class="ch-modal-footer">
🔒 Keys stay in your browser. CoreScope is a passive observer — it monitors and decrypts traffic but cannot transmit over RF. Clear browser data to remove stored keys.
</div>
</div>
</div>
<div class="ch-main" role="region" aria-label="Channel messages">
<div class="ch-main-header" id="chHeader">
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" data-action="ch-back">←</button>
@@ -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 (09, af).'; 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 <button> — outer .ch-item is itself a <button>;
// nested <button> is invalid HTML5 and the parser orphans everything
// after it. Use <span role="button">; keydown handler on #chList
// (Enter/Space) keeps it keyboard-accessible.
const removeBtn = isUserAdded ? ' <span class="ch-remove-btn" role="button" tabindex="0" data-remove-channel="' + escapeHtml(ch.hash) + '" title="Remove channel and clear saved key" aria-label="Remove ' + escapeHtml(name) + '">✕</span>' : '';
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
const unreadBadge = (ch.unread && ch.unread > 0)
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
: '';
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
<div class="ch-item-body">
<div class="ch-item-top">
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '">✕</span>' : ''}
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${removeBtn}
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
</div>
</button>`;
}
// #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 = '<div class="ch-empty">No channels found</div>'; 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:<hex>
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 <button> — the outer .ch-item is itself a <button>,
// and HTML5 forbids nested <button>; the parser would implicitly
// close .ch-item and orphan everything after this node (✕, preview).
// Use a <span role="button"> instead. Click delegation keys off
// data-remove-channel so behavior is unchanged.
const removeBtn = isUserAdded ? ' <span class="ch-remove-btn" role="button" tabindex="0" data-remove-channel="' + escapeHtml(ch.hash) + '" title="Remove channel and clear saved key" aria-label="Remove ' + escapeHtml(name) + '">✕</span>' : '';
// #1020: explicit badge marker for "your key" so it's distinguishable
// from server-known encrypted rows at a glance and for screen readers.
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
// #1029 Unread badge — bumped by live PSK decrypt for channels not currently selected.
const unreadBadge = (ch.unread && ch.unread > 0)
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
: '';
const mine = channels.filter(c => c.userAdded === true).sort(sortByActivity);
const network = channels.filter(c => c.userAdded !== true && c.encrypted !== true).sort(sortByActivity);
const encrypted = channels.filter(c => c.userAdded !== true && c.encrypted === true).sort(sortByCount);
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
<div class="ch-item-body">
<div class="ch-item-top">
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '">✕</span>' : ''}
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${removeBtn}
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
// Encrypted section collapsed by default; user toggle persisted in localStorage.
const collapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
const sections = [];
sections.push(
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
<div class="ch-section-header">My Channels</div>
${mine.length ? mine.map(renderChannelRow).join('') : '<div class="ch-section-empty">No channels yet — click [+ Add Channel] to add one.</div>'}
</div>`
);
sections.push(
`<div class="ch-section ch-section-network" data-section="network">
<div class="ch-section-header">Network</div>
${network.length ? network.map(renderChannelRow).join('') : '<div class="ch-section-empty">No public channels reported by the server.</div>'}
</div>`
);
sections.push(
`<div class="ch-section ch-section-encrypted" data-section="encrypted" data-encrypted-collapsed="${collapsed ? 'true' : 'false'}">
<button type="button" class="ch-section-header ch-section-toggle" id="chEncryptedToggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="chEncryptedBody">
<span class="ch-section-caret" aria-hidden="true">${collapsed ? '▸' : '▾'}</span>
Encrypted (${encrypted.length})
</button>
<div class="ch-section-body" id="chEncryptedBody"${collapsed ? ' hidden' : ''}>
${encrypted.length ? encrypted.map(renderChannelRow).join('') : '<div class="ch-section-empty">No unkeyed encrypted channels seen.</div>'}
</div>
</button>`;
}).join('');
</div>`
);
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) {
+58
View File
@@ -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; }
+1
View File
@@ -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
+161
View File
@@ -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); });
+123
View File
@@ -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 &amp; 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</.test(chSrc),
'sidebar renders "Network" section');
assert(/ch-section-encrypted|Encrypted \(/.test(chSrc),
'sidebar renders "Encrypted (N)" section');
assert(/data-encrypted-collapsed|chEncryptedCollapsed|encrypted-collapsed/.test(chSrc),
'Encrypted section is collapsible (collapsed by default)');
console.log('\n=== #1034 PR1: Modal action wiring ===');
assert(/chGenerateBtn[\s\S]{0,400}addEventListener|onGenerate|generatePsk/.test(chSrc),
'#chGenerateBtn has a click handler wired');
assert(/chPskAddBtn[\s\S]{0,400}addEventListener|onPskAdd/.test(chSrc),
'#chPskAddBtn has a click handler wired');
assert(/chHashtagBtn[\s\S]{0,400}addEventListener|onHashtag/.test(chSrc),
'#chHashtagBtn has a click handler wired');
// Generate uses crypto.getRandomValues(16)
assert(/getRandomValues\(\s*new Uint8Array\(\s*16\s*\)|getRandomValues\([^)]*16/.test(chSrc),
'generate handler uses crypto.getRandomValues(16) for the key');
console.log('\n=== #1034 PR1: CSS for modal ===');
assert(/ch-modal|ch-add-modal|chAddChannelModal/.test(cssSrc) || /\.modal-overlay/.test(cssSrc),
'modal CSS present (ch-modal-* or reuses .modal-overlay)');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
+4 -4
View File
@@ -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
+12 -11
View File
@@ -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*</.test(chSrc),
'ambiguous "🔒 No key" label has been replaced');
assert(/Show encrypted/i.test(chSrc) || /Show locked/i.test(chSrc),
'toggle label now reads "Show encrypted ..." (self-explanatory)');
// Tooltip must explain what the toggle does.
assert(/title="[^"]*encrypted[^"]*no key[^"]*"/i.test(chSrc) ||
/title="[^"]*haven[']t added[^"]*"/i.test(chSrc) ||
/title="[^"]*don[']t have a key[^"]*"/i.test(chSrc),
'toggle has a descriptive title attribute explaining its effect');
console.log('\n=== Encrypted section: header exists and is collapsible (#1037 redesign) ===');
// #1037 replaced the binary "No key" visibility toggle with a sectioned
// sidebar — encrypted (no-key) channels live in their own collapsible
// section grouped with the rest. The old toggle is intentionally gone.
assert(/ch-section-encrypted/.test(chSrc),
'sidebar renders a dedicated Encrypted section');
assert(/id="chEncryptedToggle"/.test(chSrc),
'Encrypted section header is a toggle (button#chEncryptedToggle)');
assert(/aria-expanded=/.test(chSrc) && /aria-controls="chEncryptedBody"/.test(chSrc),
'toggle exposes ARIA collapsible state (aria-expanded + aria-controls)');
assert(/Encrypted \(\$\{encrypted\.length\}\)/.test(chSrc),
'Encrypted header shows live count');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);