feat(portals): posters-only cover wall for movie and series grids (#1604)

* feat(portals): posters-only cover wall for movie and series grids

Add `Settings.showCoverTitles` (Settings > General, default on). Turning it
off drops the title row under VOD/series covers in catalog, favorites and
recent grids and reveals the title as a bottom-gradient overlay on hover and
keyboard focus, pinned open for items whose cover is missing or failed.

`CoverTitlesService` is the single resolver: the opt-out AND a hover-capable
pointer, so touch-only devices keep their titles. Live channel grids, search
results, "recently added" rails and dashboard rails always keep labels.

Catalog and collection cards become keyboard buttons (role, tabindex,
aria-label, Enter/Space, focus ring) and poster alt text is the title. The
default-on boolean coercion moves into `settings-opt-out.util.ts` because
the settings store reached the max-lines limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): keep nested Remove key presses from activating the card

Enter/Space on the content card's nested Remove button bubbled into the
card's own key handlers: Enter opened the item before removing it and
Space opened it while cancelling the removal. Only keys pressed on the
card element itself now activate it. Regression spec added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): keep cover titles while an in-section search filters the grid

The posters-only wall exempts search results because they are identified
by the name the user typed; the category grid's own in-section filter is
the same case, so `app-grid-list` now keeps the title row while its
`searchTerm` is non-blank. Contract docs updated, regression spec added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): keep cover titles while the collection tab search is active

The unified favorites/recent tab filters by its own search term, so its
matches are identified by name like every other search result. The tab
now opts its cards out of the posters-only wall while the term is
non-blank. Contract docs updated, regression spec added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): move the card Remove control out of the button surface

An interactive control nested inside a role="button" is an invalid
accessibility structure. The content card's activation surface is now its
own inner element and the Remove button a sibling positioned over the
poster corner, labelled by its tooltip text. Spec asserts the control is
never a descendant of the button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): draw the collection card focus ring where it is not clipped

The card's overflow: hidden clipped an outline drawn on the inner
activation surface on every edge, so keyboard users saw no focus
indication. The ring now sits on the outer card via
:has(> .content-card__activation:focus-visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): detect any hover-capable pointer for the posters-only wall

`hover` describes only the primary pointer, so a touch-first tablet with
a mouse or hover-capable stylus attached lost the wall. The resolver now
reads `(any-hover: hover)`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-14 18:34:21 +02:00
1 parent 40092f6efc
commit ef3f98d026
49 files changed
+1297 -106

No files matched your search

@@ -234,6 +234,62 @@ minimum dimensions and flexible columns over fixed row widths.
- Do not render placeholder and real logo at the same time
- Keep logos contained with `object-fit: contain`
## Cover Grids
Movie and series covers render in three surfaces: the catalog grid
(`app-grid-list`, `libs/portal/shared/ui/.../grid-list/`), the favorites /
recent card (`app-content-card`, same lib) and the dashboard rails. All of
them size from the `--cover-grid-min-width` / `--cover-rail-width` /
`--cover-gap` tokens that `Settings.coverSize` writes onto `<html>` as
`data-cover-size` (`apps/web/src/_cover-size.scss`).
### Posters-only wall
`Settings.showCoverTitles` (Settings > General, default on, only an explicit
`false` opts out — coerced like `webPlayerSharedControls`) removes the title
row under VOD and series covers so the grid shows more rows per screen.
- **Resolution.** `CoverTitlesService.postersOnly` (`libs/portal/shared/ui`)
is the single source: the opt-out AND a hover-capable pointer
(`(any-hover: hover)` media query, tracked live). On touch-only devices the
preference is ignored and titles stay under the covers, because a tap
already opens the item and there is no gesture left to peek at a hidden
name.
- **Scope.** Catalog grids (Xtream/Stalker VOD and series), unified
favorites/recent grids and the portal favorites tab. Exempt, regardless of
the setting: live channel grids (`type` `live`/`itv`/`radio` or the
`logo` variant — logos are too often missing to identify a channel),
search results and "recently added" rails (they answer by name; hosts
pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list`
and `app-unified-grid-tab` drop the wall themselves while their
`searchTerm` input is non-blank, i.e. an in-section search is filtering
the list), and
the dashboard rails (their meta rows do not fit an overlay).
- **Reveal.** The title is a `.cover-title-overlay` inside the poster
wrapper: bottom gradient scrim, two clamped lines, 150 ms ease-out
opacity, shown on `:hover` and `:focus-visible` of the card, none under
`prefers-reduced-motion`. It is `aria-hidden`; the card itself carries the
accessible name.
- **Pinned caption.** When the item has no cover to identify it — no
poster URL, or the image failed and the default poster / placeholder is
showing — the overlay is pinned open (`--pinned`). Both components track
failed URLs so the fallback branch re-renders instead of swapping `src`
in place.
- **Layout hints.** The grid's `contain-intrinsic-size` drops from 270 px to
222 px (bare 2/3 poster) under `.grid-list--posters-only`, and the skeleton
hides its text lines so loading matches the cards it precedes.
- **Keyboard.** Both cards expose a `role="button"`, `tabindex="0"` surface
labelled by the title, activated by Enter and Space (Space prevents the
page scroll) and carrying a `:focus-visible` ring (`card-focus-ring`
mixin in `libs/ui/styles/_content-grid.scss`). On `app-content-card` that
surface is the inner `.content-card__activation` element, and the Remove
control (labelled by `removeTooltip`) is a SIBLING positioned over the
poster corner — an interactive control nested inside a `role="button"`
is an invalid accessibility structure. Its ring is drawn on the OUTER
`.content-card` via `:has(> .content-card__activation:focus-visible)`,
because the card's `overflow: hidden` would clip an outline on the inner
surface on every edge. Poster `alt` is the title, not a literal.
## EPG Views
The shared timeline and list still contain local dark surfaces, blue selection