fix(bots-widgets): default data-input to mouse and drop dead danger:hover rose override so hover works from frame zero on hybrid devices

This commit is contained in:
v.lagerev 2026-05-06 17:29:11 +03:00
parent 1209654347
commit 061608558a
6 changed files with 59 additions and 50 deletions

View file

@ -6,16 +6,15 @@ import { WidgetApi, buildCapabilities } from './widget-api';
import './styles.css'; import './styles.css';
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the // Input-mode detector — see apps/widget-telegram/src/main.tsx for the
// full rationale. Capacitor Android WebView mis-reports `hover: hover` // full rationale. Default to 'mouse'; the capture-phase pointerdown
// on touch devices, so we drive `:hover` styling off the actual // listener flips to 'touch' on the first non-mouse pointerType.
// `pointerdown.pointerType` instead of media queries. The initial guess // matchMedia guessing was dropped — every variant
// based on `(any-pointer: coarse)` covers the pre-first-pointerdown // (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`)
// frame so a touch device doesn't briefly show mouse-mode hover // is mis-reported on at least one shipping device.
// affordances if the user immediately taps a card.
const setInputMode = (mode: 'touch' | 'mouse'): void => { const setInputMode = (mode: 'touch' | 'mouse'): void => {
document.documentElement.dataset.input = mode; document.documentElement.dataset.input = mode;
}; };
setInputMode(window.matchMedia('(any-pointer: coarse)').matches ? 'touch' : 'mouse'); setInputMode('mouse');
window.addEventListener( window.addEventListener(
'pointerdown', 'pointerdown',
(event) => { (event) => {

View file

@ -376,13 +376,16 @@ body {
} }
/* Destructive card red name marks logout as destructive vs the primary /* Destructive card red name marks logout as destructive vs the primary
* login card. */ * login card. The hover border stays on the generic
* `:root[data-input='mouse'] .command-card:hover` rule (hairline) so the
* accent is consistent across touch and mouse modes a previous
* `.command-card.danger:hover { border-color: var(--rose) }` override
* was dead in mouse mode (lower specificity than the input-gated rule)
* and only fired in the pre-first-pointerdown touch stub, leaking a
* rose tint that looked amber on the dark surface. */
.command-card.danger .command-card-name { .command-card.danger .command-card-name {
color: var(--rose); color: var(--rose);
} }
.command-card.danger:hover:not(:disabled) {
border-color: var(--rose);
}
/* Inline confirm-in-place body for the destructive logout card. */ /* Inline confirm-in-place body for the destructive logout card. */
.command-card-confirm { .command-card-confirm {

View file

@ -5,25 +5,37 @@ import { createT } from './i18n';
import { WidgetApi, buildCapabilities } from './widget-api'; import { WidgetApi, buildCapabilities } from './widget-api';
import './styles.css'; import './styles.css';
// Input-mode detector. Capacitor's Android Chromium WebView reports // Input-mode detector for hover styling. CSS gates `:hover` and
// `(hover: hover)` and `(any-pointer: fine)` as TRUE on a pure-touch // `:focus-visible` rules on `:root[data-input="mouse"]` because Capacitor's
// device — verified via on-device console.log of `matchMedia(...).matches`. // Android Chromium WebView synthesises `:hover` on the focused element
// That makes media-query gating of `:hover` styles unreliable: the rule // after a tap and never clears it until the next interaction elsewhere —
// fires on touch and then sticks (Chromium WebView synthesises `:hover` on // without the gate, every tap leaves a sticky hover state on the tapped
// the focused element after a tap and never clears it until the next // card («card greys out after tap and only un-greys when you tap a
// interaction elsewhere). The visible symptom is a card that «greys out // different button»).
// after tap and only un-greys when you tap a different button».
// //
// Real input is determined from the actual `pointerdown.pointerType` at // Truth comes from `pointerdown.pointerType`. The capture-phase listener
// runtime. The first pointerdown after load is authoritative; CSS gates // runs in the same task as any post-tap `:hover` synthesis, so a touch
// hover styling via `:root[data-input="mouse"]`. The initial guess based // tap on Android lands in 'touch' mode in the same render frame as the
// on `(any-pointer: coarse)` covers the pre-first-pointerdown frame so // synthesised hover would paint.
// the first paint on a touch device doesn't briefly show the mouse-mode //
// hover affordances if the user immediately taps a card. // Initial mode is plain 'mouse'. matchMedia-based guessing was tried
// here and dropped — every interaction-media query is mis-reported on at
// least one shipping device: Capacitor Android WebView falsely matches
// `hover: hover` and `any-pointer: fine` on pure-touch phones;
// Samsung / OnePlus / Moto Androids expose a virtual-mouse HID and
// falsely match `pointer: fine`; older Firefox-on-Windows desktops
// reported `pointer: coarse` despite a real mouse. Defaulting to 'mouse'
// is strictly no worse than any of those queries on any device: a
// desktop / hybrid user gets hover affordances from frame zero, and a
// touch user cannot trigger `:hover` before tapping because there is no
// pointer hovering anything — by the time the first tap fires
// `:hover` (synthesised), our listener has already moved the attribute
// to 'touch'. Pen / stylus also lands in 'touch' (pointerType is `pen`,
// matched by the `!== 'mouse'` branch).
const setInputMode = (mode: 'touch' | 'mouse'): void => { const setInputMode = (mode: 'touch' | 'mouse'): void => {
document.documentElement.dataset.input = mode; document.documentElement.dataset.input = mode;
}; };
setInputMode(window.matchMedia('(any-pointer: coarse)').matches ? 'touch' : 'mouse'); setInputMode('mouse');
window.addEventListener( window.addEventListener(
'pointerdown', 'pointerdown',
(event) => { (event) => {

View file

@ -438,13 +438,15 @@ body {
} }
/* Destructive card keeps the red name to mark «Выйти из Telegram» as a /* Destructive card keeps the red name to mark «Выйти из Telegram» as a
* destructive action, distinguishing it from the primary login card. */ * destructive action, distinguishing it from the primary login card.
* Hover border stays on the generic input-gated rule (hairline) so the
* accent is consistent across touch and mouse modes. A previous
* `.command-card.danger:hover { border-color: var(--rose) }` override
* was dead in mouse mode (lower specificity than the input-gated rule)
* and only fired in the pre-first-pointerdown touch stub. */
.command-card.danger .command-card-name { .command-card.danger .command-card-name {
color: var(--rose); color: var(--rose);
} }
.command-card.danger:hover:not(:disabled) {
border-color: var(--rose);
}
/* Inline confirm-in-place body for the destructive logout card. The button /* Inline confirm-in-place body for the destructive logout card. The button
* group lives inside the same card frame no modal, no layout shift. */ * group lives inside the same card frame no modal, no layout shift. */

View file

@ -5,25 +5,16 @@ import { createT } from './i18n';
import { WidgetApi, buildCapabilities } from './widget-api'; import { WidgetApi, buildCapabilities } from './widget-api';
import './styles.css'; import './styles.css';
// Input-mode detector. Capacitor's Android Chromium WebView reports // Input-mode detector — see apps/widget-telegram/src/main.tsx for the
// `(hover: hover)` and `(any-pointer: fine)` as TRUE on a pure-touch // full rationale. Default to 'mouse'; the capture-phase pointerdown
// device — verified via on-device console.log of `matchMedia(...).matches`. // listener flips to 'touch' on the first non-mouse pointerType.
// That makes media-query gating of `:hover` styles unreliable: the rule // matchMedia guessing was dropped — every variant
// fires on touch and then sticks (Chromium WebView synthesises `:hover` on // (`any-pointer: coarse|fine`, `hover: hover`, `pointer: fine|coarse`)
// the focused element after a tap and never clears it until the next // is mis-reported on at least one shipping device.
// interaction elsewhere). The visible symptom is a card that «greys out
// after tap and only un-greys when you tap a different button».
//
// Real input is determined from the actual `pointerdown.pointerType` at
// runtime. The first pointerdown after load is authoritative; CSS gates
// hover styling via `:root[data-input="mouse"]`. The initial guess based
// on `(any-pointer: coarse)` covers the pre-first-pointerdown frame so
// the first paint on a touch device doesn't briefly show the mouse-mode
// hover affordances if the user immediately taps a card.
const setInputMode = (mode: 'touch' | 'mouse'): void => { const setInputMode = (mode: 'touch' | 'mouse'): void => {
document.documentElement.dataset.input = mode; document.documentElement.dataset.input = mode;
}; };
setInputMode(window.matchMedia('(any-pointer: coarse)').matches ? 'touch' : 'mouse'); setInputMode('mouse');
window.addEventListener( window.addEventListener(
'pointerdown', 'pointerdown',
(event) => { (event) => {

View file

@ -419,13 +419,15 @@ body {
} }
/* Destructive card keeps the red name to mark «Выйти из WhatsApp» as a /* Destructive card keeps the red name to mark «Выйти из WhatsApp» as a
* destructive action, distinguishing it from the primary login card. */ * destructive action, distinguishing it from the primary login card.
* Hover border stays on the generic input-gated rule (hairline) so the
* accent is consistent across touch and mouse modes. A previous
* `.command-card.danger:hover { border-color: var(--rose) }` override
* was dead in mouse mode (lower specificity than the input-gated rule)
* and only fired in the pre-first-pointerdown touch stub. */
.command-card.danger .command-card-name { .command-card.danger .command-card-name {
color: var(--rose); color: var(--rose);
} }
.command-card.danger:hover:not(:disabled) {
border-color: var(--rose);
}
/* Generic leading-icon slot every command-card carries one as a /* Generic leading-icon slot every command-card carries one as a
* left-side semantic glyph (mirror of the right-side chevron). The * left-side semantic glyph (mirror of the right-side chevron). The