From 977f6241236611c31749fbc1916ed9abb841c549 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 19 Aug 2026 13:29:33 -0400 Subject: [PATCH] fix(home): gate the card play button on the device having hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The play button on a home shelf's cover cards is revealed by :hover, and a touch long-press synthesises a hover state in the WebView — so on a phone it flashed into view during the 500ms hold that utils/long-press.ts is measuring for a context menu. A control appearing because the user was reaching for a different one. It is gated on `(hover: hover) and (pointer: fine)` rather than on width, so it is absent on any touch device and present on a desktop with a small window. A phone user taps the album and plays from the detail view, so nothing replaces it. The default outside the query is display:none, not opacity:0. An opacity-0 button still takes taps and is still in the accessibility tree, so leaving the reveal as the only guarded part would keep the hit area for a control the phone can never show. The test asserts the parsed stylesheet rather than rendering as a phone, and says so: CDP's Emulation.setEmulatedMedia does not reach this tier's iframe, so matchMedia still answers `hover: hover` after it is set. The regression worth catching is someone hoisting the rule back out of the query as a tidy-up — a change no desktop assertion can see. Closes #68 --- .../src/components/home-view/home-view.ts | 63 +++++++++----- .../test/components/hover-affordance.test.ts | 85 +++++++++++++++++++ 2 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 frontend/test/components/hover-affordance.test.ts diff --git a/frontend/src/components/home-view/home-view.ts b/frontend/src/components/home-view/home-view.ts index fbef07b..4d8ca74 100644 --- a/frontend/src/components/home-view/home-view.ts +++ b/frontend/src/components/home-view/home-view.ts @@ -160,29 +160,52 @@ export class HomeView extends ViewLifecycleMixin(LitElement) { user-select: none; } + /* + * The hover play button is a *hover* affordance, so it is + * gated on the device having hover rather than on width. A + * touch long-press synthesises a hover state in the WebView, + * so on a phone it flashed into view during the 500ms hold + * that utils/long-press.ts is measuring for a context menu — + * a control appearing because you were reaching for a + * different one. A phone user taps the album and plays from + * the detail view, so there is nothing to replace it with. + * + * display:none outside the query rather than opacity:0 on + * its own: an opacity-0 button still takes taps and is + * still in the accessibility tree, so the invisible control + * would keep the hit area it was never meant to have on + * touch. Everything else stays inside, so the desktop + * animation is unchanged. + */ .play { - position: absolute; - right: 8px; - bottom: 8px; - width: 38px; - height: 38px; - border: none; - border-radius: 50%; - background: var(--yj-accent, #ffd43b); - color: var(--yj-accent-fg, #000); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - opacity: 0; - transform: translateY(6px); - transition: opacity 0.12s ease, transform 0.12s ease; + display: none; } - .card:hover .play, - .card:focus-within .play { - opacity: 1; - transform: translateY(0); + @media (hover: hover) and (pointer: fine) { + .play { + position: absolute; + right: 8px; + bottom: 8px; + width: 38px; + height: 38px; + border: none; + border-radius: 50%; + background: var(--yj-accent, #ffd43b); + color: var(--yj-accent-fg, #000); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transform: translateY(6px); + transition: opacity 0.12s ease, transform 0.12s ease; + } + + .card:hover .play, + .card:focus-within .play { + opacity: 1; + transform: translateY(0); + } } .name { diff --git a/frontend/test/components/hover-affordance.test.ts b/frontend/test/components/hover-affordance.test.ts new file mode 100644 index 0000000..78d671c --- /dev/null +++ b/frontend/test/components/hover-affordance.test.ts @@ -0,0 +1,85 @@ +/** + * A hover affordance is gated on the device having hover. + * + * The home page's cover cards reveal a play button on :hover. A touch + * long-press synthesises a hover state in the WebView, so on a phone + * that button flashed into view during the 500ms hold that + * utils/long-press.ts is measuring for a context menu — a control + * appearing because the user was reaching for a different one. + * + * This is asserted against the *parsed stylesheet* rather than by + * emulating a touch device, and that is a limitation worth stating + * rather than hiding. CDP's Emulation.setEmulatedMedia does not reach + * this tier's iframe — matchMedia still answers `hover: hover` after it + * is set — so there is no way here to render the component as a phone + * would and read the computed style. What can be checked is the shape + * the browser actually built from the css`` literal: that the reveal + * lives inside a hover media query and that the default is display:none. + * + * Which is the regression worth catching anyway. The failure mode is + * someone hoisting the rule back out of the query for a one-line tidy — + * a change nothing renders differently on a desktop, so every other + * assertion in this repo passes and the phone silently regresses. + */ +import { describe, expect, it } from 'vitest'; + +import '@components/home-view/home-view'; +import { fixture } from '@test/support/render'; + +/** Every rule in the element's own adopted stylesheets, flattened. */ +function rulesOf(host: Element): { text: string; condition: string | null }[] { + const sheets = host.shadowRoot?.adoptedStyleSheets ?? []; + const out: { text: string; condition: string | null }[] = []; + + for (const sheet of sheets) { + for (const rule of Array.from(sheet.cssRules)) { + if (rule instanceof CSSMediaRule) { + for (const inner of Array.from(rule.cssRules)) { + out.push({ text: inner.cssText, condition: rule.conditionText }); + } + + continue; + } + + out.push({ text: rule.cssText, condition: null }); + } + } + + return out; +} + +describe('the home card play button', () => { + it('reveals itself only where the device has hover', async () => { + const el = await fixture('home-view', {}); + const rules = rulesOf(el); + + // The sweep is worth nothing if it read no rules at all — the same + // first assertion icon-language.test.ts makes for the same reason. + expect(rules.length).toBeGreaterThan(0); + + const reveals = rules.filter( + (r) => r.text.includes('.play') && /opacity:\s*1/.test(r.text), + ); + + expect(reveals.length).toBeGreaterThan(0); + + for (const rule of reveals) { + expect(rule.condition).toMatch(/hover:\s*hover/); + expect(rule.condition).toMatch(/pointer:\s*fine/); + } + }); + + it('is display:none rather than transparent where it is absent', async () => { + const el = await fixture('home-view', {}); + + // opacity:0 alone would leave a button that still takes taps and is + // still in the accessibility tree, so a phone would keep the hit + // area for a control it can never see. + const unconditional = rulesOf(el).filter( + (r) => r.condition === null && r.text.startsWith('.play'), + ); + + expect(unconditional.length).toBeGreaterThan(0); + expect(unconditional.some((r) => /display:\s*none/.test(r.text))).toBe(true); + }); +});