Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/channel-proposals.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
.ch-proposals-builtin { font-size: 12px; color: var(--warning); }
.ch-proposals-actions { display: flex; gap: 6px; align-items: center; }
.ch-proposals-actions button,
.ch-proposals-toolbar button { min-height: 44px; }
.ch-proposals-toolbar button { min-height: 48px; }
.ch-proposals-state {
font-size: 12px;
text-transform: capitalize;
Expand Down
13 changes: 8 additions & 5 deletions public/live.css
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,8 @@
position: absolute;
top: 50%;
left: 50%;
width: max(100%, 44px);
height: 44px;
width: max(100%, 48px);
height: 48px;
transform: translate(-50%, -50%);
}

Expand Down Expand Up @@ -762,9 +762,9 @@ body.live-fullscreen:not(.nav-pinned) .live-header {
/* No absolute positioning needed; they stack normally in leaflet-right */
}
.live-leaflet-toggle a {
width: 44px !important;
height: 44px !important;
line-height: 44px !important;
width: 48px !important;
height: 48px !important;
line-height: 48px !important;
font-size: 20px !important;
background-color: var(--surface-1) !important;
color: var(--text) !important;
Expand Down Expand Up @@ -994,6 +994,9 @@ body.live-fullscreen #liveFullscreenToggle:hover,

/* #1110 Live page node filter — match toolbar control sizing & theme */
.live-node-filter-wrap { position: relative; display: inline-flex; align-items: center; }
/* The label around the compact input is its 48px hit area (#235). Scoped
to .live-toggles so it wins over .live-toggles label (cursor: pointer). */
.live-toggles .live-node-filter-hitarea { display: inline-flex; align-items: center; min-height: 48px; cursor: text; }
input.live-node-filter-input {
box-sizing: border-box;
height: 30px;
Expand Down
2 changes: 1 addition & 1 deletion public/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -1285,7 +1285,7 @@
</div>
<div class="live-toggles">
<div class="live-node-filter-wrap" style="position:relative">
<label class="live-node-filter-hitarea" style="display:inline-flex; align-items:center; min-height:44px; cursor:text;">
<label class="live-node-filter-hitarea">
<input type="text" id="liveNodeFilterInput" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input" role="combobox" aria-expanded="false" aria-owns="liveNodeFilterDropdown" aria-autocomplete="list" aria-activedescendant="">
</label>
<div id="liveNodeFilterDropdown" class="live-node-filter-dropdown hidden" role="listbox"></div>
Expand Down
47 changes: 27 additions & 20 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ img.brand-logo {
.theme-toggle {
display: inline-flex; align-items: center; cursor: pointer;
padding: 0; margin: 0; border: none; background: none;
min-width: 44px; min-height: 44px; justify-content: center;
min-width: 48px; min-height: 48px; justify-content: center;
}
.theme-toggle input[type="checkbox"] {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
Expand Down Expand Up @@ -1725,13 +1725,13 @@ body.scroll-locked { overflow: hidden; }
.modal > .modal-close:not(.ch-modal-close):hover { background: var(--row-hover, rgba(0,0,0,0.05)); }

/* #1648 M6 (M5 CDP carry-forward): mobile touch-target. The 4px/10px
padding above produces an ~24px hit area, below WCAG's 44×44 minimum.
Bump on ≤640px so dismiss is reliably tappable on phones. Applies to
both the generic .modal-close and the channel-modal close. */
padding above produces an ~24px hit area, below the 48×48 house minimum
(#2052, #235). Bump on ≤640px so dismiss is reliably tappable on phones.
Applies to both the generic .modal-close and the channel-modal close. */
@media (max-width: 640px) {
.modal > .modal-close:not(.ch-modal-close),
.ch-modal-close {
min-width: 44px; min-height: 44px;
min-width: 48px; min-height: 48px;
display: inline-flex; align-items: center; justify-content: center;
padding: 8px 12px;
}
Expand Down Expand Up @@ -1919,7 +1919,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
display: none; background: none; border: none; font-size: 22px; cursor: pointer;
color: var(--text); padding: 8px 12px; border-radius: 4px;
align-items: center; justify-content: center;
min-width: 44px; min-height: 44px;
min-width: 48px; min-height: 48px;
-webkit-tap-highlight-color: rgba(0,0,0,.08);
touch-action: manipulation;
}
Expand Down Expand Up @@ -2476,8 +2476,12 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
icon-only row actions, smaller empty state. */
.ch-sidebar-header {
padding: 6px 10px; gap: 6px;
max-height: 56px;
}
/* #235: with 48px touch targets the region pills cannot share a row with
the title and + Add at phone width. Keep title and + Add on the first
row and give the pills the second. The old max-height: 56px cap only
hid the overflow: the list was drawn over the lower half of the pills. */
.ch-sidebar-header .ch-header-region { order: 1; }
.ch-sidebar-title { font-size: 14px; }
.ch-sidebar-title .ch-icon { font-size: 16px; }
.ch-add-channel-btn { padding: 4px 8px; font-size: 12px; }
Expand All @@ -2486,6 +2490,9 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.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; }
/* #239: order: 1 puts the dropdown trigger (more than 4 regions) at the
right edge of the strip, so its menu opens leftwards from there. */
.ch-header-region .region-dropdown-menu { left: auto; right: 0; }

/* Channel list row: name must dominate. Below 768px channels.js renders
the flat .ch-row list (#1367), which has no inline share/remove
Expand Down Expand Up @@ -2734,9 +2741,9 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
backdrop-filter: blur(12px);
color: var(--text) !important;
border-color: var(--border) !important;
width: 44px !important;
height: 44px !important;
line-height: 44px !important;
width: 48px !important;
height: 48px !important;
line-height: 48px !important;
font-size: 20px !important;
}
.leaflet-control-zoom a:hover {
Expand Down Expand Up @@ -2838,7 +2845,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
min-height: 36px;
font-size: 14px;
}
.ch-avatar.ch-tappable { min-width: 44px; min-height: 44px; width: 44px; height: 44px; }
.ch-avatar.ch-tappable { min-width: 48px; min-height: 48px; width: 48px; height: 48px; }
/* #1253: trim .badge-iata h-padding + margin-left on narrow viewports so
* the observer cell + IATA pill fits within a 375px viewport. Without this
* the badge right edge overflows by ~1.25px at 375px (same class of fix
Expand Down Expand Up @@ -3334,8 +3341,8 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.home-footer,
.home-favorites { max-width: 1200px; }

/* #40 — Increase suggest-claim touch target to ≥44px */
.suggest-claim { min-height: 44px; min-width: 44px; padding: 10px 14px; display: inline-flex; align-items: center; justify-content: center; }
/* #40, #235 — suggest-claim touch target at the 48px house minimum */
.suggest-claim { min-height: 48px; min-width: 48px; padding: 10px 14px; display: inline-flex; align-items: center; justify-content: center; }

/* #41 — Lower My Nodes grid minimum to prevent overflow on 375-640px */
.my-nodes-grid { max-width: 1200px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
Expand Down Expand Up @@ -3579,7 +3586,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.area-dropdown-item.area-item-active { color: var(--accent); font-weight: 600; }
.region-filter-container { margin: 0; padding: 0; display: inline-flex; align-items: center; }
.region-pill {
display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 16px;
display: inline-flex; align-items: center; justify-content: center; padding: 4px 12px; border-radius: 16px;
font-size: 12px; font-weight: 500; cursor: pointer; border: 1.5px solid var(--border);
background: transparent; color: var(--text-muted); transition: all 0.15s; white-space: nowrap;
flex-shrink: 0;
Expand Down Expand Up @@ -4464,7 +4471,7 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); }

/* === #630 — Mobile Accessibility Fixes === */

/* #630-1: Touch targets — minimum 44px on touch devices */
/* #630-1: Touch targets — 48px house minimum on touch devices (#2052, #235) */
@media (pointer: coarse) {
.filter-bar .btn,
.filter-group .btn,
Expand All @@ -4478,16 +4485,16 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); }
.analytics-time-range button,
.detail-back-btn,
.filter-toggle-btn {
min-height: 44px;
min-width: 44px;
min-height: 48px;
min-width: 48px;
}
.filter-bar input,
.filter-bar select {
height: 44px;
height: 48px;
}
.region-dropdown-trigger,
.multi-select-trigger {
height: 44px;
height: 48px;
}
}

Expand Down Expand Up @@ -5374,7 +5381,7 @@ body { touch-action: pan-y; }
.ch-back {
display: none; background: none; border: 0; cursor: pointer;
color: var(--text); font-size: 24px; line-height: 1;
min-width: 40px; min-height: 40px; padding: 0 8px;
min-width: 48px; min-height: 48px; padding: 0 8px;
border-radius: 6px;
align-items: center; justify-content: center;
-webkit-tap-highlight-color: rgba(0,0,0,.08);
Expand Down
92 changes: 80 additions & 12 deletions test-issue-1224-channels-mobile-ux-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@
* 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 a compact header strip above the channel list: at most two rows
* (title + Add chip, then the region filter; ≤120px since #235 made the
* controls 48px), with every control inside the strip and the list
* starting below it. The strip used to be capped at 56px, which kept it
* under the old ≤60px bound while the list was drawn over the pills.
* - 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).
* - With more than 4 regions (mocked; the fixture has 4), open the region
* dropdown's menu inside the viewport at 320-640px touch, every option
* hit at its centre (#239).
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js
*/
Expand All @@ -25,6 +31,27 @@ async function step(name, fn) {
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }

// The sidebar header strip: at most two rows (title + Add, then the region
// filter), every control inside it, and the channel list starting below it.
async function assertHeaderStrip(page) {
const r = await page.evaluate(() => {
const sidebar = document.querySelector('.ch-sidebar');
const header = sidebar && sidebar.querySelector('.ch-sidebar-header');
if (!header) return null;
const h = header.getBoundingClientRect();
const listTop = document.getElementById('chList').getBoundingClientRect().top;
const outside = [...header.querySelectorAll('.ch-sidebar-title, button')]
.map((e) => ({ name: e.id || String(e.className).split(' ')[0] || e.tagName, b: e.getBoundingClientRect() }))
.filter((c) => c.b.width > 0 && (c.b.top < h.top - 0.5 || c.b.bottom > h.bottom + 0.5 || c.b.left < h.left - 0.5 || c.b.right > h.right + 0.5))
.map((c) => c.name + ' ' + Math.round(c.b.top) + '-' + Math.round(c.b.bottom));
return { height: Math.round(h.height), top: Math.round(h.top), bottom: Math.round(h.bottom), listTop: Math.round(listTop), outside };
});
assert(r !== null, 'sidebar header not found');
assert(r.height <= 120, 'sidebar header must be \u2264120px on mobile, got ' + r.height + 'px');
assert(r.outside.length === 0, 'controls stick out of the header (' + r.top + '-' + r.bottom + '): ' + r.outside.join(', '));
assert(r.listTop >= r.bottom - 0.5, 'channel list starts at ' + r.listTop + 'px, above the header bottom ' + r.bottom + 'px');
}

async function run() {
const launchOpts = { args: ['--no-sandbox'] };
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
Expand All @@ -44,16 +71,7 @@ async function run() {
}, { 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('header strip is \u2264120px, holds all its controls, and the list starts below it', () => assertHeaderStrip(page));

await step('"+ Add Channel" is a compact chip, not full-width hero', async () => {
const ratio = await page.evaluate(() => {
Expand Down Expand Up @@ -98,6 +116,56 @@ async function run() {
'% of viewport (' + data.vh + 'px) \u2014 must be <40%');
});

// #235: on a touch phone the region pills and + Add are 48px tall, so the
// strip is two 48px rows; it must still hold them without the list
// covering any.
const touchCtx = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
const tp = await touchCtx.newPage();
await tp.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await tp.waitForSelector('#chList .ch-row', { timeout: 15000 });
await tp.waitForSelector('#chRegionFilter .region-pill, #chRegionFilter .region-dropdown-trigger', { timeout: 15000 });
await step('touch 390x844: header strip is \u2264120px, holds all its controls, and the list starts below it', () => assertHeaderStrip(tp));
await touchCtx.close();

// #239 F1: with more than 4 regions RegionFilter renders a dropdown, whose
// trigger order: 1 puts at the right edge of the strip. Its menu must open
// inside the viewport, with every option hit at its centre. The fixture
// has 4 regions (pills), so the regions are mocked.
const SIX_REGIONS = {
SJC: 'San Jose', SFO: 'San Francisco', OAK: 'Oakland',
MRY: 'Monterey', SMF: 'Sacramento', LAX: 'Los Angeles',
};
for (const width of [320, 360, 390, 430, 640]) {
const dctx = await browser.newContext({ viewport: { width, height: 844 }, hasTouch: true, isMobile: true });
const dp = await dctx.newPage();
await dp.route('**/api/config/regions', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(SIX_REGIONS) }));
await dp.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('#chList .ch-row', { timeout: 15000 });
await dp.waitForSelector('#chRegionFilter .region-dropdown-trigger', { timeout: 15000 });
await step('touch ' + width + 'x844, 6 regions: header strip holds its controls', () => assertHeaderStrip(dp));
await step('touch ' + width + 'x844, 6 regions: the region menu opens on-screen, every option hit at its centre', async () => {
await dp.tap('#chRegionFilter .region-dropdown-trigger');
await dp.waitForSelector('#chRegionFilter .region-dropdown-menu:not([hidden])', { timeout: 5000 });
const r = await dp.evaluate(() => {
const vw = document.documentElement.clientWidth;
const menu = document.querySelector('#chRegionFilter .region-dropdown-menu');
const m = menu.getBoundingClientRect();
const items = [...menu.querySelectorAll('.region-dropdown-item')].map((el) => {
const b = el.getBoundingClientRect();
const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
return { text: el.textContent.trim(), left: Math.round(b.left), right: Math.round(b.right), hit: !!hit && el.contains(hit) };
});
return { vw, left: Math.round(m.left), right: Math.round(m.right), items, overflow: document.documentElement.scrollWidth - vw };
});
assert(r.items.length === 7, 'expected All + 6 options, got ' + r.items.length);
assert(r.left >= 0 && r.right <= r.vw, 'menu ' + r.left + '-' + r.right + 'px is outside the ' + r.vw + 'px viewport');
const missed = r.items.filter((i) => !i.hit).map((i) => i.text + ' (' + i.left + '-' + i.right + ')');
assert(missed.length === 0, missed.length + ' of ' + r.items.length + ' options not hit at their centre: ' + missed.join(', '));
assert(r.overflow <= 0, 'horizontal page overflow ' + r.overflow + 'px with the menu open');
});
await dctx.close();
}

// 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.
Expand Down
Loading
Loading