feat(a11y): touch-visible card controls + modal focus trap/restore (E4 S6)

UX-7 — hover-only card actions were `opacity-0 group-hover:opacity-100`: fine on
a mouse, but invisible AND still tappable on a touch device (no hover). New
`.reveal` primitive (index.css) gates the hide on `@media (hover: hover)`, so a
touch device keeps the control visible; its partner `.reveal-idle` is the idle
badge a control replaces (watched/in-progress pill under the watched-toggle),
faded on hover and dropped on touch so the two don't collide at the same corner.
Migrated the true controls — feed card action toolbar (VideoCard), Plex poster
card watched-toggle/info/add across all card variants + their swap badges
(plexCards), message pop-out (Messages), edit-interval pencil (Scheduler), and
saved-view star/rename/delete (SavedViewsWidget). Pure play-scrim overlays (the
whole card is the tap target) stay hover-only by design.

FE-9 — Modal.tsx had aria-modal but no focus management. Adds: a Tab/Shift+Tab
focus trap (topmost layer only, wraps at the edges and pulls escaped focus back);
initial focus into the dialog unless an autoFocus child already placed it; focus
restore to the trigger on close (captured in a useState initializer during render,
before commit runs autoFocus and moves activeElement inside); and aria-labelledby
wiring the dialog to its title.

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): FE-9 trap
both directions + pull-back + Escape-restore-to-trigger + aria-labelledby verified;
UX-7 feed toolbar opacity 0 on desktop / 1 under mobile (hover:none) emulation,
Plex reveal buttons opacity 1 + reveal-idle badges display:none on touch.
This commit is contained in:
2026-08-19 21:35:52 +02:00
parent aea99cc71a
commit 94a608053a
7 changed files with 124 additions and 21 deletions
+32
View File
@@ -536,6 +536,38 @@ body {
0 14px 30px -14px rgba(0, 0, 0, 0.5);
}
/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
.reveal-idle {
transition: opacity 150ms ease;
}
@media (hover: hover) {
.reveal {
opacity: 0;
transition: opacity 150ms ease;
}
.group:hover .reveal,
.group:focus-within .reveal,
.reveal:focus-visible {
opacity: 1;
}
.group:hover .reveal-idle {
opacity: 0;
}
}
@media (hover: none) {
.reveal-idle {
display: none;
}
}
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.