From 06f53ab18fc4dc33b26f6ff9d6bcbe12d604e218 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 4 Oct 2026 07:27:53 +0200 Subject: [PATCH] fix(ui): 48px navigation and channel buttons (port of upstream 2078) Port of Kpa-clawbot/CoreScope PR 2078 (410c82c0). .nav-btn and .ch-icon-btn now take the shared 48x48 house minimum from the touch-target block instead of 44px component rules, replacing the 44px choice made in #85. .nav-btn is dropped from the coarse-pointer 44px list. User-added channel rows let their controls wrap so share and remove stay inside the sidebar at 768-1330px. Fork adaptations: #85 had removed the 48px group declarations, so they are restored in the touch-target block. Tests live in the repo root. test-touch-targets.js takes upstream's harness fixes and is registered in the Playwright step; test-issue-2052-touch-target-{css,e2e}.js are tightened to 48 and the 768px characterization step becomes a gate at 768/1024/1180/1330px. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 2 + public/style.css | 29 ++++++----- test-channel-fluid-e2e.js | 58 +++++++++++++++++++++ test-channel-ux-followup.js | 15 +++--- test-issue-2052-touch-target-css.js | 20 +++++--- test-issue-2052-touch-target-e2e.js | 80 +++++++++++++++++------------ test-test-all.js | 1 - test-touch-targets.js | 36 ++++++++----- 8 files changed, 169 insertions(+), 72 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 495d06c5f..218a0f882 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -469,6 +469,8 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-2052-touch-target-e2e.js 2>&1 | tee -a e2e-output.txt + # Standalone harness (no server): loads public/style.css into Chromium. + CHROMIUM_REQUIRE=1 node test-touch-targets.js 2>&1 | tee -a e2e-output.txt # Starts its own ingestor + server on a temp copy of the fixture. CHROMIUM_REQUIRE=1 CORESCOPE_SERVER_BIN=./corescope-server CORESCOPE_INGESTOR_BIN=./corescope-ingestor FIXTURE_DB=test-fixtures/e2e-fixture.db node test-channel-proposals-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/style.css b/public/style.css index 5b02ae965..164efe439 100644 --- a/public/style.css +++ b/public/style.css @@ -518,10 +518,8 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible } /* === Touch Targets === */ -/* WCAG 2.5.5 and Apple HIG use a 44x44 CSS px target; Material uses 48x48. - Most controls in this section use the 48x48 house preference. Compact - .nav-btn and .ch-icon-btn controls retain 44x44 in their component rules. - Targets are achieved with min-height/min-width +/* House preference: 48x48 CSS px minimum for the controls below (#2052), + above WCAG 2.5.5's 44x44 minimum. Targets use min-height/min-width plus inline-flex centering so existing visual styling (font-size, padding, icon size) is preserved on desktop while the *hit area* grows for touch. Issue #1060. @@ -560,6 +558,13 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible touch-action: manipulation; } +.nav-btn { + min-height: 48px; + min-width: 48px; + touch-action: manipulation; +} + +.ch-icon-btn, .ch-remove-btn, .ch-share-btn { min-height: 48px; @@ -603,8 +608,7 @@ button.ch-item { } /* Additional button-like controls discovered during PR #1067 review (Issue - #1060 follow-up). Same 48x48 minimums + touch-action so all interactive - surfaces meet WCAG 2.5.5. */ + #1060 follow-up). Same 48x48 house minimums + touch-action. */ .btn-link, .col-toggle-btn, .filter-toggle-btn, @@ -885,8 +889,7 @@ img.brand-logo { .nav-btn { background: none; border: 1px solid var(--border); color: var(--nav-text-muted); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s; - min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; - touch-action: manipulation; + display: inline-flex; align-items: center; justify-content: center; } .nav-btn:hover { background: var(--nav-bg2); color: var(--nav-text); } @@ -1844,6 +1847,8 @@ button.ch-item.selected { background: var(--selected-bg); } } .ch-item-body { flex: 1; min-width: 0; } .ch-item-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; } +/* Keep both 48px actions inside narrow user-channel rows (#2052). */ +.ch-item.ch-user-added .ch-item-top { flex-wrap: wrap; } .ch-item-name { font-weight: 600; font-size: 14px; } .ch-item-time { font-size: 12px; font-weight: var(--fw-medium); color: var(--text-muted); white-space: nowrap; } /* #1668 M3 — was 11px/400 */ .ch-unread-badge { @@ -1860,13 +1865,12 @@ button.ch-item.selected { background: var(--selected-bg); } line-height: 1.4; } /* Shared icon button base for sidebar row controls (remove ✕, share ⤴). EMOJI-OK-COMMENT - WCAG 2.5.5 / Apple HIG: 44x44 CSS px minimum touch target. */ + Inherits the shared 48x48 house minimum above. */ .ch-icon-btn { display: inline-flex; align-items: center; justify-content: center; background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px; margin-left: 2px; - min-width: 44px; min-height: 44px; box-sizing: border-box; - touch-action: manipulation; + box-sizing: border-box; opacity: 0.55; transition: opacity 0.15s, color 0.15s; line-height: 1; user-select: none; } @@ -2476,7 +2480,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } /* Channel list row: name must dominate. Below 768px channels.js renders the flat .ch-row list (#1367), which has no inline share/remove actions, so there is no size override for .ch-icon-btn here: wherever - those actions render they keep the 44x44 target from the base rule. */ + those actions render they keep the shared 48x48 house minimum. */ #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; } @@ -4457,7 +4461,6 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); } .tab-btn, .filter-bar input, .filter-bar select, - .nav-btn, .region-pill, .region-dropdown-trigger, .multi-select-trigger, diff --git a/test-channel-fluid-e2e.js b/test-channel-fluid-e2e.js index 3d58667c5..3d70b6404 100644 --- a/test-channel-fluid-e2e.js +++ b/test-channel-fluid-e2e.js @@ -105,6 +105,64 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } } }); + // #2052: exercise the actual controls in both pointer modes, not just + // standalone CSS samples. Mobile channels use rows without inline icons; + // the packets navbar supplies the visible .nav-btn mirrors on phones. + for (const width of [375, 390, 768, 1280]) { + await step(`viewport ${width}: 48px controls fit and respond to clicks`, async () => { + const mobile = width < 768; + const touchContext = await browser.newContext({ + viewport: { width, height: 900 }, hasTouch: mobile, isMobile: mobile, + }); + const target = await touchContext.newPage(); + try { + await target.addInitScript(() => localStorage.setItem('corescope_channel_keys', + JSON.stringify({ Touch2052: '00112233445566778899aabbccddeeff' }))); + await target.goto(BASE + (mobile ? '/#/packets' : '/#/channels'), + { waitUntil: 'domcontentloaded' }); + const action = mobile ? '.filter-toggle-btn-mirror' : '[data-share-channel]'; + await target.waitForSelector(action); + await target.evaluate(() => document.fonts.ready); + // Mobile page-actions rebuilds the mirror while packets initializes. + // Query and measure in one browser turn so a detached selector snapshot + // cannot look like missing controls. Bad dimensions still fail below. + const controlsHandle = await target.waitForFunction(({ mobile, action }) => { + if (mobile && !document.querySelector('#pktLeft[data-loaded="true"] #pktPauseBtn')) return false; + const els = Array.from(document.querySelectorAll('.top-nav .nav-btn, #chList .ch-icon-btn')) + .filter(el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden'); + if (!els.length || !els.some(el => el.matches(action))) return false; + return els.map(el => { + const r = el.getBoundingClientRect(); + const container = el.closest('.top-nav, .ch-item').getBoundingClientRect(); + return { name: el.id || el.getAttribute('aria-label'), w: r.width, h: r.height, + fits: r.left >= container.left - 1 && r.right <= container.right + 1 + && r.top >= container.top - 1 && r.bottom <= container.bottom + 1 }; + }); + }, { mobile, action }, { timeout: 8000 }); + const controls = await controlsHandle.jsonValue(); + await controlsHandle.dispose(); + assert(controls.length > 0, 'expected visible navbar or channel controls'); + for (const control of controls) { + assert(control.w >= 48 && control.h >= 48, + `${control.name}: expected >=48x48, got ${control.w}x${control.h}`); + assert(control.fits, `${control.name}: clipped outside its navbar/channel row`); + } + assert(await target.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + 'controls caused horizontal overflow'); + await target.locator(action).click(); + if (mobile) { + await target.waitForSelector('.filter-bar.filters-expanded'); + } else { + await target.waitForSelector('#chShareModal:not(.hidden)'); + assert((await target.inputValue('#chShareKey')) === '00112233445566778899aabbccddeeff', + 'Share must open the selected channel key'); + } + } finally { + await touchContext.close(); + } + }); + } + console.log(`\n${passed} passed, ${failed} failed`); await browser.close(); process.exit(failed ? 1 : 0); diff --git a/test-channel-ux-followup.js b/test-channel-ux-followup.js index 6e2f284a2..458fafde3 100644 --- a/test-channel-ux-followup.js +++ b/test-channel-ux-followup.js @@ -1,7 +1,7 @@ /** * Follow-up UX fixes to #1037 channel modal/sidebar redesign: * - * 1. ✕ remove button must hit a 44×44px touch target (WCAG 2.5.5). + * 1. ✕ remove button must hit a 48×48px house touch target (#2052). * 2. Channel rows must NOT display "0 messages" — when no messages * have been decrypted yet, omit the count entirely. * 3. Modal footer wording: keys removed via ✕ button, not by @@ -25,12 +25,13 @@ function assert(cond, msg) { else { failed++; console.error(' ✗ ' + msg); } } -console.log('\n=== Fix 1: ✕ touch target ≥ 44×44px (on shared .ch-icon-btn base) ==='); -const iconBtnRule = (cssSrc.match(/\.ch-icon-btn\s*\{[^}]*\}/) || [''])[0]; -assert(/min-width:\s*44px/.test(iconBtnRule), - '.ch-icon-btn declares min-width: 44px'); -assert(/min-height:\s*44px/.test(iconBtnRule), - '.ch-icon-btn declares min-height: 44px'); +console.log('\n=== Fix 1: shared touch-target sizing owns channel icon minimums ==='); +// Computed 48x48 hit areas are asserted by test-touch-targets.js. Keep the +// component rules from silently overriding that shared policy again (#2052). +const iconBtnRules = cssSrc.match(/(?:^|\n)[^{}]*\.ch-icon-btn\s*\{[^}]*\}/g) || []; +assert(iconBtnRules.length > 0, '.ch-icon-btn component rules exist'); +assert(iconBtnRules.every(rule => !/min-(?:width|height)\s*:/.test(rule)), + '.ch-icon-btn component rules inherit minimum sizes from the shared touch-target group'); console.log('\n=== Fix 2: no "0 messages" in default row ==='); // renderChannelRow must not emit a literal "0 messages" preview when diff --git a/test-issue-2052-touch-target-css.js b/test-issue-2052-touch-target-css.js index 0f2a677ea..c72a72524 100644 --- a/test-issue-2052-touch-target-css.js +++ b/test-issue-2052-touch-target-css.js @@ -10,9 +10,12 @@ * specific selectors such as `#chList .ch-icon-btn` count too; an earlier * version matched only the exact selector text and missed a 32px override. * Every min-width/min-height declared for these controls must be at least - * 44px. Pseudo-class rules (:hover, :active, :focus, ...) are skipped because - * they do not set target dimensions. The base .nav-btn and .ch-icon-btn rules - * must also keep touch-action: manipulation. + * MIN (48px, the house preference; upstream PR 2078 settled #2052 on 48), and + * each control must declare both somewhere, so dropping the 48px group rule + * cannot pass silently. Pseudo-class rules (:hover, :active, :focus, ...) are + * skipped because they do not set target dimensions. Every rule naming + * .nav-btn or .ch-icon-btn that sets touch-action must use manipulation, and + * at least one must set it. */ 'use strict'; @@ -24,6 +27,7 @@ const css = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, ''); const CONTROLS = ['.nav-btn', '.ch-icon-btn', '.ch-share-btn', '.ch-remove-btn']; +const MIN = 48; // The innermost { } blocks are the style rules, also inside @media. function rules() { @@ -54,6 +58,7 @@ function declared(body, property) { const violations = []; const seen = new Set(); +const sized = new Set(); let checked = 0; for (const rule of rules()) { for (const selector of rule.selectors) { @@ -63,8 +68,9 @@ for (const rule of rules()) { for (const property of ['min-width', 'min-height']) { for (const value of declared(rule.body, property)) { checked++; + sized.add(control + ' ' + property); const px = /^(\d+(?:\.\d+)?)px$/.exec(value); - if (!px || Number(px[1]) < 44) violations.push(`${selector} { ${property}: ${value} }`); + if (!px || Number(px[1]) < MIN) violations.push(`${selector} { ${property}: ${value} }`); } } } @@ -72,8 +78,10 @@ for (const rule of rules()) { assert.deepStrictEqual(CONTROLS.filter((c) => !seen.has(c)), [], 'every compact control must have at least one rule'); assert(checked > 0, 'no min-width/min-height declarations found for the compact controls'); -assert.deepStrictEqual(violations, [], 'compact controls must not declare a touch target below 44px:\n ' + violations.join('\n ')); -console.log(`PASS ${checked} min-width/min-height declarations for ${CONTROLS.join(', ')} are all >= 44px`); +const unsized = CONTROLS.flatMap((c) => ['min-width', 'min-height'].map((p) => c + ' ' + p)).filter((k) => !sized.has(k)); +assert.deepStrictEqual(unsized, [], 'every compact control must declare min-width and min-height'); +assert.deepStrictEqual(violations, [], `compact controls must not declare a touch target below ${MIN}px:\n ` + violations.join('\n ')); +console.log(`PASS ${checked} min-width/min-height declarations for ${CONTROLS.join(', ')} are all >= ${MIN}px`); for (const base of ['.nav-btn', '.ch-icon-btn']) { const values = rules() diff --git a/test-issue-2052-touch-target-e2e.js b/test-issue-2052-touch-target-e2e.js index 15799e51f..3f22c2d2d 100644 --- a/test-issue-2052-touch-target-e2e.js +++ b/test-issue-2052-touch-target-e2e.js @@ -5,18 +5,23 @@ * measures what the browser actually renders, in the real responsive DOM: * * - Desktop 1440x900 (sectioned .ch-item list): the share and remove actions - * of a user-added channel are at least 44x44 CSS px, visible, not clipped + * of a user-added channel are at least 48x48 CSS px, visible, not clipped * by any overflow container, hit at their centre, not overlapping each * other or the row's other parts, reachable by keyboard with focus-visible, * keep their ARIA/title contract, and do their job without side effects * (share opens the modal, remove asks for confirmation, which is dismissed). * - Mobile 390x844 touch (flat .ch-row list, #1367): the list renders no * share/remove actions at all, every visible tap target in the channel - * sidebar is at least 44x44, and tapping a row opens the channel. - * - Tablet 768x1024 touch: characterization only. The sidebar is narrower - * than a channel row there, so the actions are clipped. That is a known, - * separate layout issue (also on master); this step logs the measurement - * and does not gate. + * sidebar is at least 44x44 (WCAG_MIN: other sidebar controls, e.g. the + * coarse-pointer .region-pill, are outside #2052 and still 44), and + * tapping a row opens the channel. + * - 768-1330 px (the narrow sectioned sidebar; 768 with touch): share and + * remove are at least 48x48, visible, unclipped, hit at their centre and + * do not overlap. Before upstream PR 2078 the remove action was clipped + * here; user-added rows now let their controls wrap. + * + * 48 is the house preference for these controls (upstream PR 2078 settled + * #2052 on 48; WCAG 2.5.5 asks for 44). * * The user-added channel comes from a saved key in localStorage, the same * state a returning user has; no DOM is injected. No sleeps or retries: each @@ -34,7 +39,8 @@ const CHANNEL = '#tt2052probe'; const HASH = 'user:' + CHANNEL; const SHARE = `#chList [data-share-channel="${HASH}"]`; const REMOVE = `#chList [data-remove-channel="${HASH}"]`; -const MIN = 44; +const MIN = 48; +const WCAG_MIN = 44; let passed = 0, failed = 0; async function step(name, fn) { @@ -115,6 +121,22 @@ function overlaps(a, b) { return !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top); } +// Share and remove: at least MIN square, visible, unclipped, hit at their +// centre, and not on top of each other. +async function assertActionTargets(page) { + const found = {}; + for (const [name, sel] of [['share', SHARE], ['remove', REMOVE]]) { + const m = await measure(page, sel); + assert(m, name + ' action not rendered'); + assert(m.w >= MIN && m.h >= MIN, `${name} renders ${m.w.toFixed(1)}x${m.h.toFixed(1)}, below ${MIN}x${MIN}`); + assert(m.visible, name + ' is not visible'); + assert(m.clippedBy.length === 0, name + ' is clipped by ' + m.clippedBy.join(', ')); + assert(m.hit, `${name} centre hits "${m.hitOn}", not the control`); + found[name] = m; + } + assert(!overlaps(found.share, found.remove), 'share and remove overlap'); +} + async function main() { let browser; try { @@ -140,15 +162,8 @@ async function main() { try { await dp.waitForSelector(REMOVE, { state: 'visible' }); - await step('desktop 1440x900: share and remove are at least 44x44, visible, unclipped and hit at their centre', async () => { - for (const [name, sel] of [['share', SHARE], ['remove', REMOVE]]) { - const m = await measure(dp, sel); - assert(m, name + ' action not rendered'); - assert(m.w >= MIN && m.h >= MIN, `${name} renders ${m.w.toFixed(1)}x${m.h.toFixed(1)}, below ${MIN}x${MIN}`); - assert(m.visible, name + ' is not visible'); - assert(m.clippedBy.length === 0, name + ' is clipped by ' + m.clippedBy.join(', ')); - assert(m.hit, `${name} centre hits "${m.hitOn}", not the control`); - } + await step(`desktop 1440x900: share and remove are at least ${MIN}x${MIN}, visible, unclipped and hit at their centre`, async () => { + await assertActionTargets(dp); }); await step('desktop: actions do not overlap each other or the rest of the row, and the page has no horizontal overflow', async () => { @@ -214,20 +229,20 @@ async function main() { // asserts which layout it is. await mp.waitForSelector(`#chList [data-hash="${HASH}"]`, { state: 'visible' }); - await step('mobile 390x844: the list renders no share/remove actions, and every visible tap target is at least 44x44', async () => { - const r = await mp.evaluate((hash) => ({ + await step(`mobile 390x844: the list renders no share/remove actions, and every visible tap target is at least ${WCAG_MIN}x${WCAG_MIN}`, async () => { + const r = await mp.evaluate(([hash, min]) => ({ mobileRow: !!document.querySelector(`#chList .ch-row[data-hash="${hash}"]`), actions: document.querySelectorAll('#chList .ch-icon-btn, #chList [data-share-channel], #chList [data-remove-channel]').length, desktopRows: document.querySelectorAll('#chList .ch-item').length, small: [...document.querySelectorAll('.ch-sidebar button, .ch-sidebar a[href], .ch-sidebar [role="button"], .ch-sidebar [tabindex="0"]')] .filter((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0 && getComputedStyle(e).visibility === 'visible'; }) .map((e) => { const b = e.getBoundingClientRect(); return { name: e.id || String(e.className).split(' ')[0] || e.tagName, w: +b.width.toFixed(1), h: +b.height.toFixed(1) }; }) - .filter((t) => t.w < 44 || t.h < 44), + .filter((t) => t.w < min || t.h < min), overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, - }), HASH); + }), [HASH, WCAG_MIN]); assert(r.mobileRow, 'the channel is not rendered as a mobile .ch-row'); assert(r.actions === 0 && r.desktopRows === 0, `mobile list renders desktop actions (actions=${r.actions}, .ch-item rows=${r.desktopRows})`); - assert(r.small.length === 0, 'mobile tap targets below 44x44: ' + JSON.stringify(r.small)); + assert(r.small.length === 0, `mobile tap targets below ${WCAG_MIN}x${WCAG_MIN}: ` + JSON.stringify(r.small)); assert(r.overflow <= 0, 'mobile page overflows horizontally by ' + r.overflow + 'px'); }); @@ -240,18 +255,17 @@ async function main() { await mob.ctx.close(); } - // ── Tablet: characterization of the known clipping (not gating) ────────── - const tab = await openChannels(browser, errors, { viewport: { width: 768, height: 1024 }, hasTouch: true }); - try { - await tab.page.waitForSelector(REMOVE, { state: 'attached' }); - await step('tablet 768x1024: characterization only (known, separate layout issue)', async () => { - const share = await measure(tab.page, SHARE), remove = await measure(tab.page, REMOVE); - assert(share && remove, 'tablet actions not rendered'); - const fmt = (n, m) => `${n} ${m.w.toFixed(0)}x${m.h.toFixed(0)} hit=${m.hit ? 'yes' : 'no (' + m.hitOn + ')'} clipped=${m.clippedBy.join('; ') || 'no'}`; - console.log(' KNOWN LIMITATION (tracked separately, not fixed here): ' + fmt('share', share) + ' | ' + fmt('remove', remove)); - }); - } finally { - await tab.ctx.close(); + // ── Narrow sectioned sidebar: 768 (touch) to 1330 px ───────────────────── + for (const width of [768, 1024, 1180, 1330]) { + const narrow = await openChannels(browser, errors, { viewport: { width, height: 1024 }, hasTouch: width === 768 }); + try { + await narrow.page.waitForSelector(REMOVE, { state: 'visible' }); + await step(`${width}x1024: share and remove are at least ${MIN}x${MIN}, unclipped, hit at their centre and do not overlap`, async () => { + await assertActionTargets(narrow.page); + }); + } finally { + await narrow.ctx.close(); + } } await step('no page errors, console errors or unhandled rejections', async () => { diff --git a/test-test-all.js b/test-test-all.js index f613e4961..4ef814dc8 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -143,7 +143,6 @@ const KNOWN_UNREGISTERED = { // need something CI's unit job does not have 'test-marker-outline-weight.js': 'needs @playwright/test (not a dependency) and a server', 'test-table-sort.js': 'needs jsdom (not a dependency)', - 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', // E2E 'test-channel-modal-e2e.js': 'red: Add button text and sidebar sections changed', 'test-issue-1522-trace-url-sync-e2e.js': 'needs @playwright/test (not a dependency)', diff --git a/test-touch-targets.js b/test-touch-targets.js index c509c1f37..fede79316 100644 --- a/test-touch-targets.js +++ b/test-touch-targets.js @@ -26,9 +26,23 @@ const { chromium, devices } = require('playwright'); const REPO = __dirname; const CSS = fs.readFileSync(path.join(REPO, 'public/style.css'), 'utf8'); -// Selectors we claim to make 48x48. Each entry: [selector, tag, classes, -// optional inner-html]. Tag matters because some rules are scoped to -// `button.ch-item` and some only apply to specific input[type=...]. +// All listed button surfaces use the shared 48px house minimum (#2052). +const DEFAULT_MIN = 48; + +// Each entry: [selector, tag, classes, optional inner-html]. +// Tag matters because some rules are scoped to `button.ch-item` and some only +// apply to specific input[type=...]. +// +// Not listed, and why: +// .compare-btn the Compare CTA was removed in #1646 (see style.css) +// .ch-back-btn display:none outside the mobile channels layout, so a +// standalone element in this harness measures 0x0 +// .filter-toggle-btn hidden on mobile since #1461 (`.filter-bar > +// .filter-toggle-btn`, display:none !important); the +// control actually shown is the navbar mirror, which +// mobile-page-actions.js builds with class "nav-btn +// filter-toggle-btn-mirror mpa-btn-pill", so the .nav-btn +// entry below already measures it const BUTTON_SELECTORS = [ ['.btn', 'button', 'btn'], ['.btn-icon', 'button', 'btn-icon'], @@ -42,14 +56,11 @@ const BUTTON_SELECTORS = [ ['button.ch-item', 'button', 'ch-item'], ['.btn-link', 'button', 'btn-link'], ['.col-toggle-btn', 'button', 'col-toggle-btn'], - ['.filter-toggle-btn', 'button', 'filter-toggle-btn'], ['.ch-add-channel-btn', 'button', 'ch-add-channel-btn'], - ['.ch-back-btn', 'button', 'ch-back-btn'], ['.ch-modal-btn-secondary','button', 'ch-modal-btn-secondary'], ['.ch-scroll-btn', 'button', 'ch-scroll-btn'], ['.chooser-btn', 'button', 'chooser-btn'], ['.clock-filter-btn', 'button', 'clock-filter-btn'], - ['.compare-btn', 'button', 'compare-btn'], ['.copy-link-btn', 'button', 'copy-link-btn'], ['.alab-btn', 'button', 'alab-btn'], ]; @@ -111,9 +122,9 @@ async function run() { } catch (err) { // Allow the test to be skipped on hosts where Chromium cannot launch // (e.g. some musl-libc dev boxes). CI uses standard glibc Ubuntu runners - // where this path is never taken. Set TOUCH_TARGETS_REQUIRE=1 to force + // where this path is never taken. Set TOUCH_TARGETS_REQUIRE=1 or CHROMIUM_REQUIRE=1 to force // a hard failure even when Chromium is unavailable. - if (process.env.TOUCH_TARGETS_REQUIRE === '1') throw err; + if (process.env.TOUCH_TARGETS_REQUIRE === '1' || process.env.CHROMIUM_REQUIRE === '1') throw err; console.log(`test-touch-targets.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`); process.exit(0); } @@ -149,11 +160,12 @@ async function run() { const cs = getComputedStyle(el); return { w: r.width, h: r.height, mh: cs.minHeight, mw: cs.minWidth }; }); - const okH = dim.h >= 48; - const okW = dim.w >= 48; - record(`${selector}: rendered ${dim.w.toFixed(1)}x${dim.h.toFixed(1)} (min ${dim.mw}/${dim.mh})`, + const min = DEFAULT_MIN; + const okH = dim.h >= min; + const okW = dim.w >= min; + record(`${selector}: rendered ${dim.w.toFixed(1)}x${dim.h.toFixed(1)} (min ${dim.mw}/${dim.mh}, required ${min})`, okH && okW, - `expected >=48x48, got ${dim.w}x${dim.h}`); + `expected >=${min}x${min}, got ${dim.w}x${dim.h}`); } // --- Form controls: rendered height must be at least 48 CSS px.