diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 91345758..231bc2d5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -258,6 +258,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/channels.js b/public/channels.js index 28d49776..2930923b 100644 --- a/public/channels.js +++ b/public/channels.js @@ -673,13 +673,13 @@
๐Ÿ’ฌ Channels
+
+ aria-label="Add channel" title="Add a channel โ€” generate, paste a key, or monitor a hashtag">+ Add + ๐Ÿ“Š
- ๐Ÿ“Š Channel Analytics โ†’ -
Loading channelsโ€ฆ
diff --git a/public/style.css b/public/style.css index 968dfe0a..a68544a2 100644 --- a/public/style.css +++ b/public/style.css @@ -1111,11 +1111,17 @@ fieldset.mc-section legend.mc-label { padding: 0; } border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; } .ch-sidebar-header { - padding: 14px 16px; border-bottom: 1px solid var(--border); + padding: 8px 12px; border-bottom: 1px solid var(--border); + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .ch-sidebar-title { - display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 700; margin-bottom: 8px; + display: flex; align-items: center; gap: 6px; + font-size: 15px; font-weight: 700; + flex: 1 1 auto; min-width: 0; + /* Name dominates; no longer push other controls onto a new row */ + margin: 0; } +.ch-header-region { margin: 0; padding: 0; flex: 0 0 auto; } .ch-encrypted-toggle { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); cursor: pointer; user-select: none; margin-bottom: 4px; @@ -1727,6 +1733,39 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .ch-back-btn { display: flex; } .ch-main-header { display: flex; align-items: center; gap: 8px; } + /* #1224 Channels mobile UX: compact header, dominant channel name, + icon-only row actions, smaller empty state. */ + .ch-sidebar-header { + padding: 6px 10px; gap: 6px; + max-height: 56px; + } + .ch-sidebar-title { font-size: 14px; } + .ch-sidebar-title .ch-icon { font-size: 16px; } + .ch-add-channel-btn { padding: 4px 8px; font-size: 12px; } + .ch-analytics-link { padding: 3px 7px; font-size: 13px; } + /* Region filter shows just its compact pills/dropdown trigger in the header. */ + .ch-header-region .region-filter-bar { padding: 0; gap: 4px; } + .ch-header-region .region-pill { padding: 2px 6px; font-size: 11px; } + .ch-header-region .region-dropdown-trigger { padding: 3px 6px; font-size: 11px; } + + /* Channel list row: name must dominate. The inline share/remove icons + were eating >88px because of their 44x44 touch target โ€” push them + onto a per-row reveal pattern: show a single โ‹ฎ trigger that toggles + the action set. Until then, shrink them to icon-only and let the + name grow via flex:1. */ + #chList .ch-item { padding: 10px 12px; gap: 10px; } + #chList .ch-item-name { flex: 1 1 auto; min-width: 0; } + #chList .ch-item-top { gap: 6px; } + #chList .ch-icon-btn { min-width: 32px; min-height: 32px; padding: 2px 4px; } + #chList .ch-share-btn { font-size: 0; } + #chList .ch-share-btn::before { content: '๐Ÿ“ค'; font-size: 13px; } + #chList .ch-remove-btn { font-size: 12px; padding: 2px 6px; } + /* Hide the analytics-style time on mobile rows to free width for the name. */ + #chList .ch-item-time { font-size: 10px; } + + /* Empty state must not dominate. */ + .ch-empty { padding: 12px; font-size: 13px; max-height: 30vh; } + /* Tables: smaller text for mobile */ .data-table { font-size: 11px; min-width: 0; } .data-table td { padding: 5px 4px; max-width: 100px; } @@ -1919,12 +1958,14 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .ch-add-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; line-height: 1.3; } .ch-add-status { font-size: 12px; margin-top: 4px; padding: 4px 6px; border-radius: 4px; } .ch-analytics-link { - display: block; - padding: 6px 8px; - font-size: 12px; + display: inline-flex; align-items: center; justify-content: center; + padding: 4px 8px; + font-size: 14px; text-decoration: none; color: var(--text-muted); - border-bottom: 1px solid var(--border); + border: 1px solid var(--border); + border-radius: 6px; + flex: 0 0 auto; } .ch-analytics-link:hover { color: var(--accent); } .ch-add-status--loading { color: var(--text-muted); } diff --git a/test-issue-1224-channels-mobile-ux-e2e.js b/test-issue-1224-channels-mobile-ux-e2e.js new file mode 100644 index 00000000..b1becae5 --- /dev/null +++ b/test-issue-1224-channels-mobile-ux-e2e.js @@ -0,0 +1,118 @@ +/** + * E2E (#1224): Channels page mobile UX overhaul. + * + * At 375x800 viewport the channels page must: + * - Render a header strip above the channel list โ‰ค60px tall (page title + + * Add chip + region filter chip + analytics overflow) in ONE row. + * - Render "+ Add Channel" as a compact chip โ€” NOT a full-width hero (the + * add control must be narrower than 65% of the sidebar width). + * - Render channel rows where the channel name has computed-width > 150px + * (the row must not be clipped by oversized inline action buttons). + * - Render the "Select a channel" empty state container occupying < 40% of + * the viewport height (no desktop-thinking empty state on mobile). + * + * Run: BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +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 function run() { + const launchOpts = { args: ['--no-sandbox'] }; + if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH; + const browser = await chromium.launch(launchOpts); + const ctx = await browser.newContext({ viewport: { width: 375, height: 800 } }); + const page = await ctx.newPage(); + + await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#chList', { timeout: 10000 }); + await page.waitForFunction(() => { + const l = document.getElementById('chList'); + return l && l.querySelectorAll('.ch-item').length > 0; + }, { timeout: 15000 }); + await page.waitForTimeout(300); + + await step('header strip above channel list is \u226460px tall on mobile', async () => { + const headerH = await page.evaluate(() => { + const sidebar = document.querySelector('.ch-sidebar'); + const header = sidebar && sidebar.querySelector('.ch-sidebar-header'); + if (!header) return null; + return Math.round(header.getBoundingClientRect().height); + }); + assert(headerH !== null, 'sidebar header not found'); + assert(headerH <= 60, 'sidebar header must be \u226460px on mobile, got ' + headerH + 'px'); + }); + + await step('"+ Add Channel" is a compact chip, not full-width hero', async () => { + const ratio = await page.evaluate(() => { + const sidebar = document.querySelector('.ch-sidebar'); + const btn = document.getElementById('chAddChannelBtn'); + if (!sidebar || !btn) return null; + const sw = sidebar.getBoundingClientRect().width; + const bw = btn.getBoundingClientRect().width; + return bw / sw; + }); + assert(ratio !== null, 'add channel button not found'); + assert(ratio < 0.65, 'add button width must be <65% of sidebar, got ratio=' + ratio.toFixed(2)); + }); + + await step('first channel row name has computed-width >150px', async () => { + const nameW = await page.evaluate(() => { + const name = document.querySelector('#chList .ch-item .ch-item-name'); + if (!name) return null; + return Math.round(name.getBoundingClientRect().width); + }); + assert(nameW !== null, 'first channel name element not found'); + assert(nameW > 150, 'channel name width must be >150px on mobile, got ' + nameW + 'px'); + }); + + await step('empty-state container is < 40% of viewport height', async () => { + const data = await page.evaluate(() => { + const empty = document.querySelector('.ch-empty'); + if (!empty) return null; + return { + h: Math.round(empty.getBoundingClientRect().height), + vh: window.innerHeight, + }; + }); + assert(data !== null, 'empty state element not found'); + const pct = data.h / data.vh; + assert(pct < 0.40, + 'empty-state height ' + data.h + 'px is ' + Math.round(pct * 100) + + '% of viewport (' + data.vh + 'px) \u2014 must be <40%'); + }); + + // Desktop guard: at 1024x800 the sidebar must remain side-by-side with main + // (layout flex-direction stays row), not stacked. This protects the desktop + // experience from a regression introduced by the mobile fix. + await ctx.close(); + const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } }); + const p2 = await ctx2.newPage(); + await p2.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' }); + await p2.waitForSelector('.ch-layout', { timeout: 10000 }); + await p2.waitForTimeout(200); + + await step('desktop (1024px): ch-layout stays row (side-by-side)', async () => { + const dir = await p2.evaluate(() => { + const l = document.querySelector('.ch-layout'); + return l ? getComputedStyle(l).flexDirection : null; + }); + assert(dir === 'row', 'desktop ch-layout flex-direction must be "row", got ' + dir); + }); + + await browser.close(); + + console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' + + (failed ? ', ' + failed + ' failed' : '')); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch(err => { console.error('Fatal:', err); process.exit(1); });