Files
iptvnator/libs/ui/styles/_content-grid.scss
4grayandClaude Opus 5.5 17a2b1b3b0 fix(ui): visible keyboard focus everywhere via a global focus-visible fallback (#1866)
* fix(ui): visible keyboard focus everywhere via a global focus-visible fallback

The global stylesheet removed the outline from every input, button,
textarea and `:focus`, so Tab users saw no focus on about 110 raw buttons:
detail actions, season tabs, chips, title results, list rows.

- Remove the outline only under `:focus:not(:focus-visible)` and draw a
  fallback ring on every other `:focus-visible` element. Both rules sit
  in `:where()`, so any component that styles its own focus still wins.
- One recipe: `focus-ring-declarations` moves to
  `libs/ui/styles/_focus-ring.scss` and reads `--app-focus-ring`, declared
  per theme (light #1d63e0, dark #8cbaff) with at least 3:1 on every app
  surface and selection tint; `m3-theme.spec.ts` measures it. The card
  ring, the detail actions, the portal sidebar and both context panels
  use the mixin (the panels' selection-blue ring fell to 2.97:1 on the
  active item).
- Material Tab stops (buttons, switches, button toggles, checkboxes) take
  the ring over their 12% focus state layer; menu items and options keep
  Material's highlight.
- Surfaces over video (player controls, vendor chrome, fullscreen panels,
  Up Next rail) point the ring at the player's text colour.
- The selected season tab drew its border with `outline`, which outranks
  the fallback; it is a spread shadow now.
- Guard: `pnpm run styles:focus-visible:validate` (CI) rejects a global
  `outline: none` on an unscoped selector and a missing fallback.
- Electron E2E `keyboard-focus-ring.e2e.ts` tabs through the detail
  actions and season tabs, a catalog grid with its refinement chips, the
  Sources list and Settings in both themes: one ring per stop, 3:1 where
  measurable, none after a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the player ring in the fullscreen panel; tighten the focus guard and E2E

Local review round 1 (Codex P2, Greptile 3×P2):

- The fullscreen channel panel carries `dark-theme`, whose context declares
  the theme ring again, so its own controls ringed in #8cbaff. Point the
  token back at the player's text colour on `.fullscreen-channel-panel
  .dark-theme`; the web series-playback E2E checks the close button's ring.
- The guard took a container-scoped rule (`.panel :focus-visible`), a mixin
  body or a media query as the global fallback. The fallback is now the
  whole selector, outside any at-rule.
- The keyboard-focus E2E now fails a ring that an `overflow: hidden`
  ancestor (up to the scroll container) cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): show focus where a component removes its outline; catch invisible rings in the guard

GitHub review round on #1866 (Codex P2, Greptile P2):

- A component rule that sets `outline: none` outranks the zero-specificity
  fallback. Re-audited every one: two hid a Tab stop with nothing else to
  show. The EPG list row (`role="button"`) now draws an inset ring, and the
  command palette underlines its search row while the field has focus.
  The others already show focus on the field's wrapper, on another
  element, or as a highlight inside an arrow-key composite (the "…" menu,
  the guide's search listbox); the guidelines now state the rule.
- The guard read only a bare zero or `none` as a removal. It now reads a
  zero width, a `none`/`hidden` style or a transparent colour anywhere in
  the shorthand or longhands, so `outline: 0 solid transparent` is
  reported and `outline: 2px solid transparent` is no fallback.
- The keyboard-focus E2E walks the live EPG list and the command palette
  too, and reads an inset or offset shadow line as a ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): keep --pc-text a literal palette colour

Master's palette spec (#1854) reads `--pc-text` from the controls host as
a literal; this branch had made it `#{palette.$text}`, failing "sets the
timeline label on the dense glass" on the merge ref. The host declares the
literal again, and a spec keeps the palette's `$text` (the focus ring
token for surfaces beside the host) equal to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(epg): declare the ui-styles dependency of the list row's focus ring

The EPG list row now `@use`s `libs/ui/styles/_focus-ring.scss`; Nx cannot
infer a Sass import, so `ui-epg` declares `ui-styles` like the other
consumers of the shared partials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): a styleless outline shorthand hides focus too

`outline: 2px` or `outline: red` resets the style to `none`, yet the guard
counted either as a visible fallback. A shorthand without a drawn style
(or a `var()` that may carry one) now counts as removing the outline, as
do `initial` and `unset`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:10:24 +02:00

192 lines
4.5 KiB
SCSS

// Shared content grid and card styles
//
// Choose a relative @use path from each consuming stylesheet. A current
// portal-shared component uses its local forwarding module as shown below;
// direct consumers of this partial may need a different depth.
// @use '../../styles/content-grid' as grid;
// @include grid.content-grid;
// @include grid.content-card;
@use 'focus-ring';
@mixin content-grid(
$min-width: var(--cover-grid-min-width, 148px),
$gap: var(--cover-gap, 16px)
) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(#{$min-width}, 1fr));
gap: $gap;
}
@mixin content-card {
cursor: pointer;
border-radius: 10px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
}
@mixin thumbnail-wrapper {
position: relative;
border-radius: 10px;
overflow: hidden;
.poster {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
display: block;
}
.poster-placeholder {
width: 100%;
aspect-ratio: 2/3;
display: flex;
align-items: center;
justify-content: center;
background: rgba(128, 128, 128, 0.1);
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
opacity: 0.25;
}
}
}
@mixin type-badge {
position: absolute;
top: 6px;
left: 6px;
padding: 3px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
&.live {
color: #ef5350;
}
&.radio {
color: #ffa726;
}
&.movie {
color: #42a5f5;
}
&.series {
color: #66bb6a;
}
}
@mixin remove-button {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
background: rgba(0, 0, 0, 0.5);
width: 28px;
height: 28px;
padding: 0;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
mat-icon {
color: white;
font-size: 18px;
width: 18px;
height: 18px;
}
}
// Hover/focus caption for the posters-only wall (Settings > General >
// "Show titles under covers" off). Placed inside the poster wrapper, which
// must be `position: relative` with `overflow: hidden`; the consumer reveals
// it on `:hover`/`:focus-visible` and pins it with `--pinned` whenever the
// cover cannot identify the item (default poster, placeholder).
@mixin cover-title-overlay {
position: absolute;
inset: auto 0 0 0;
box-sizing: border-box;
padding: 28px 8px 8px;
color: #fff;
font-size: 0.78rem;
font-weight: 500;
line-height: 1.3;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.72) 50%,
rgba(0, 0, 0, 0.88) 100%
);
opacity: 0;
pointer-events: none;
// Entrance under 300ms, ease-out: the caption answers a hover, it is
// not a state change worth choreographing.
transition: opacity 150ms ease-out;
// The clamp lives on a child: `overflow: hidden` clips at the padding
// edge, so a clamped element with its own padding paints the third
// line into that padding.
.cover-title-overlay__text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
&.cover-title-overlay--pinned {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
// The app focus ring at the cards' 3px offset. Draw it on the element that
// owns `overflow: hidden` (or on one of its ancestors): an outline on a
// clipped descendant is cut on every edge.
@mixin card-focus-ring-declarations {
@include focus-ring.focus-ring-declarations($offset: 3px);
}
@mixin card-focus-ring {
&:focus-visible {
@include card-focus-ring-declarations;
}
}
@mixin card-info {
padding: 8px 10px;
h3 {
margin: 0;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.date-info {
margin: 4px 0 0;
font-size: 0.7rem;
opacity: 0.6;
}
}