Files
iptvnator/apps/web/src/app/settings/settings.component.scss
T
4grayandClaude Opus 5.5 7abf479de9 fix(ui): one focus ring colour everywhere via the shared mixin (#1882)
* fix(ui): one focus ring colour everywhere via the shared mixin

#1866 gave the app a keyboard focus ring token (`--app-focus-ring`, at
least 3:1 on every surface), but 59 component rings still drew their own
colour: the selection blue under several names (`--app-selection-color`,
`--mat-sys-primary`, `--app-selection-border`, the EPG's `$accent-blue`,
`--embedded-mpv-accent`, `--apd-accent`, the hero's `--accent-color`),
the heading colour, or the overlay's literal text colour. The selection
blue falls to 2.6:1 on the stronger selection tint.

- Every one now includes `focus-ring.focus-ring-declarations`, keeping its
  offset; the projects that newly import `libs/ui/styles` declare
  `ui-styles`.
- The mixin reads the token alone: it is declared on `html` in both
  themes, and the fallback chain cost bytes in every ring.
- `tools/nx/check-focus-ring-colour.mjs` joins `styles:focus-visible:
  validate`: an outline in a focus rule must use the token or, over
  video, the player's `--pc-*` palette. Three deliberate exceptions are
  listed with their reasons, and a stale one is reported. On master it
  reports these 59 rings.
- The keyboard-focus E2E asserts each ring's colour equals the token
  where it is drawn.

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

* fix(ui): search fields and shadow rings take the focus colour too; guard reads them

Local review (Greptile P2): the ring-colour guard read only `outline`, so a
focus indicator drawn as a shadow or a border kept any colour. Search
fields showed focus as a `--mat-sys-primary`/selection border with a 12-16%
halo, the EPG guide's keyboard cell as an inset `$accent-blue` shadow, and
the downloads cards tinted their artwork border with the M3 primary.

- Every one now uses `--app-focus-ring` (the halos and tints keep their
  strength through `color-mix()`); over video the panel's search border
  mixes the overlay's own ring colour.
- The guard reads a focus rule's outline, its unblurred ring or line
  shadows and its border colours. Neutral boundaries (separator and
  widget-border tokens, transparent, currentColor) and blurred lift
  shadows are not indicators. On the previous commit it reports these 21
  declarations.

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

* fix(tooling): check every focus colour on its own; count exceptions only where they excuse a ring

Greptile on #1882 (2×P2):

- The guard joined a declaration's colours and accepted the lot when the
  token appeared anywhere, so `border-color: var(--app-focus-ring) red`
  or a `color-mix()` of the token and red passed. It now splits each
  declaration into plain colours, every `color-mix()` argument included,
  and checks each one: the token, the player palette or a neutral
  boundary. An outline or shadow ring without a colour is drawn in the
  text colour, which only a border may keep.
- An exception counted as used when its value appeared in any
  declaration (the diagnostic's amber is also a text colour), so a stale
  one was never reported. It now counts only where it excuses an
  off-token focus indicator.

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

* fix(settings): the row a search result reveals takes the app focus ring

Codex on #1882 (P2): choosing a settings search result with Enter focuses
its row by script, and the keypress keeps `:focus-visible`, so the row's
60%-transparent selection outline was a real keyboard focus ring under
3:1, not the passive marker its guard exception described. The row now
includes the shared mixin (keeping its 12px radius), the exception is
gone, and the keyboard-focus E2E reveals a row with Enter and asserts the
app ring on it; with the old rule it fails on the 60% colour.

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

* fix(tooling): accept focus colour tokens by exact name

Greptile (local, P2): the guard matched `--app-focus-ring` and the neutral
boundary tokens by prefix, so `var(--app-focus-ring-other, red)` or
`var(--app-separator-strong)` passed. It now reads the property a `var()`
names and accepts exactly `--app-focus-ring`, a `--pc-*` palette token or
one of the four neutral boundary tokens; their fallbacks are never drawn,
since the tokens are always declared, so they are not checked.

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

* fix(tooling): check the body of a focus mixin, the shared ring included

Greptile (local, P2): a mixin body has no selector and its includes are
not expanded, so the shared `focus-ring-declarations` itself escaped the
colour check; turning it red would change every ring and pass. The
walker now names the mixin a declaration sits in, and the colour guard
treats the body of a mixin whose name mentions focus as a focus rule.

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

* fix(tooling): read logical border sides in the focus colour guard

Greptile on #1882 (P2): only `border`, `border-color` and the physical
sides were read, so `border-inline-start: 2px solid red` in a focus rule
passed. The guard now reads every colour-carrying border property: the
shorthand and the physical and logical sides, with or without `-color`.

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

* fix(tooling): an exception excuses its own colour, not the declaration

Greptile (local, P2): an exception matched the whole declaration, so in
the player stylesheet `box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red`, or
the white mixed with red, passed. Exceptions now apply to each plain
colour, by exact value, like every other check; an exception counts as
used only where it excuses one of a focus indicator's colours.

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

* fix(tooling): tell a var() or calc() width from the ring colour

Greptile (local, P2): `outline: var(--ring-width) solid var(--app-focus-
ring)` failed the guard, since any non-length token counted as a colour.
Math functions now count as lengths, and a shorthand with one colour slot
takes its literal colour, else an accepted token (the other `var()`s are
widths), else reports every candidate it cannot prove; `border-color`
still checks a colour per side.

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

* fix(tooling): read a shadow ring whose spread is computed

Codex (local, P2): with `box-shadow: 0 0 0 calc(1px + 1px) red` or a
`var()` spread, only three literal zeros were left as lengths, so the
shadow looked flat and was skipped. A shadow is now skipped only when it
is provably not a ring: a literal non-zero blur in the third place, or
every length a literal zero with no `var()` that could be a spread. The
test with a variable-width token ring now asserts it is read, not skipped.

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

* fix(tooling): read shadow lengths by type, with the colour first or no blur

Greptile (local, P2): the guard took a shadow's first three tokens as its
lengths, so `0 2px` (a line in the text colour, no blur) was skipped as
blurred and a colour-first lift shadow was read as a ring. Lengths are
now read by type: a colour sits before or after them, never between, and
a missing blur is zero. A `var()` counts as a possible length, so a
shadow is skipped only when the first three possible lengths prove a
blur, or every length and the first four possible ones are zero.

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

* fix(tooling): a negated focus condition does not make a focus rule

Greptile (local, P2): the colour guard read `:focus-visible` inside
`:not()` as a focus rule, so a hover style such as
`.button:hover:not(:focus-visible) { border-color: red; }` failed. The
selector is now tested without its `:not()` arguments; a focus condition
elsewhere, `:has()` included, still makes a focus rule.

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

* fix(tooling): read a width in any CSS length unit

Greptile (local, P2): only px, em and rem were lengths, so `outline: 1pt
solid var(--app-focus-ring)` took `1pt` for the colour and failed. Every
CSS length unit (absolute, font-relative, viewport and container) now
counts as a length.

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

* fix(ui): the open-in-playlist chip rings in the app focus colour

Codex on #1882 (P2): the chip's inline component styles drew its focus
ring in `--app-selection-color`, out of the colour guard's reach (it reads
stylesheets). It was the only inline-style focus ring in the repository;
it now uses `--app-focus-ring` like every other ring.

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

* refactor(ui): move the open-in-playlist chip styles into a stylesheet

Codex on #1882 (P2) suggested this over scanning TS inline styles: the
chip was the only component with a focus ring in inline `styles`, which
the colour guard does not read. Its styles now live in
`open-in-playlist-chip.component.scss` unchanged, the ring through the
shared `focus-ring-declarations` mixin, so the guard covers it (a drifted
colour there is reported).

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

---------

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

609 lines
14 KiB
SCSS

@use '../../../../../libs/ui/styles/focus-ring';
// Settings page: one content column of titled cards. Every row is a label
// column plus one right-aligned control column, so selects, switches and
// segmented controls line up down the page. Tokens and the Material token
// overrides (compact switch, dense select) live in `_settings-theme.scss`.
.settings-page-host {
display: block;
}
.settings-container {
height: auto;
overflow: visible;
// Rows stack by the pane's width, not the viewport's: the sidebar keeps
// the page narrow on small desktop windows. This block is not the
// scroll owner, so inline-size containment costs it nothing.
container-type: inline-size;
}
.settings-layout {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
align-items: stretch;
box-sizing: border-box;
max-width: 880px;
margin: 0 auto;
padding: 28px 40px calc(48px + var(--settings-safe-bottom));
}
.settings-content {
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
// ─── Page header ───────────────────────────────────────────────────────────
.settings-group {
display: flex;
flex-direction: column;
gap: 28px;
}
.settings-group__header {
h2 {
margin: 0;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.3px;
line-height: 1.2;
color: var(--app-heading-color);
}
p {
margin: 6px 0 0;
color: var(--app-body-color);
font-size: 13.5px;
line-height: 1.45;
}
}
// ─── Sections and cards ────────────────────────────────────────────────────
.settings-section {
display: block;
}
.settings-section__header {
display: flex;
align-items: center;
gap: 10px;
margin: 0 2px 10px;
min-height: 32px;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--app-heading-color);
}
}
.settings-section__count {
padding: 1px 7px;
border-radius: 999px;
background: var(--settings-chip-bg);
color: var(--settings-chip-fg);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.settings-section__actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.settings-section__desc {
margin: -4px 2px 10px;
color: var(--settings-muted);
font-size: 12.5px;
line-height: 1.5;
max-width: 72ch;
}
.settings-card {
background: var(--settings-card-bg);
border: 1px solid var(--settings-card-border);
border-radius: 12px;
}
.settings-card--danger {
border-color: var(--settings-item-danger-border);
}
// ─── Rows ──────────────────────────────────────────────────────────────────
.setting-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
min-height: 60px;
padding: 14px 16px;
position: relative;
}
.setting-item + .setting-item,
.setting-item__sub + .setting-item,
.setting-item + .setting-item__sub + .setting-item {
border-top: 1px solid var(--settings-item-border);
}
// Meta stacked over a full-width control (textareas, lists). Flex children
// do not grow from `justify-content`, so the field itself takes the row.
.setting-item--full-control {
grid-template-columns: 1fr;
gap: 12px;
.setting-item__control {
justify-content: stretch;
> .mat-mdc-form-field {
width: 100%;
}
}
}
.setting-item--list {
display: block;
padding: 0;
}
// A callout under the row it explains (player runtime note, nightly warning).
.setting-item__sub {
padding: 0 16px 14px;
}
.setting-item__meta {
min-width: 0;
h4 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--app-heading-color);
}
p {
margin: 3px 0 0;
max-width: 56ch;
color: var(--settings-muted);
font-size: 12.5px;
line-height: 1.5;
text-wrap: pretty;
}
a {
color: var(--app-selection-color);
}
}
.setting-item__control {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
min-width: 0;
}
.setting-item__control--stack {
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.setting-item__toggle {
justify-content: flex-end;
}
// Row revealed from settings search or the command palette. Choosing a
// result with Enter focuses the row by script and keeps `:focus-visible`,
// so it shows the app ring like any keyboard focus.
.setting-item[data-setting-id],
.settings-card[data-setting-id],
.settings-foot[data-setting-id] {
scroll-margin: 96px 0 120px;
&:focus {
outline: none;
}
&:focus-visible {
@include focus-ring.focus-ring-declarations;
border-radius: 12px;
}
}
.setting-item--revealed {
animation: settings-reveal-fade 2.4s ease-out forwards;
}
@keyframes settings-reveal-fade {
0%,
60% {
background: color-mix(
in srgb,
var(--app-selection-color) 10%,
transparent
);
}
100% {
background: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.setting-item--revealed {
animation: none;
}
}
// ─── Controls ──────────────────────────────────────────────────────────────
// Compact select without a floating label: the row label names it.
.settings-select.mat-mdc-form-field {
width: 220px;
.mat-mdc-text-field-wrapper {
background: var(--settings-control-bg);
}
.mat-mdc-select-value,
.mat-mdc-input-element {
font-size: 13px;
}
}
.settings-text-field.mat-mdc-form-field {
width: min(100%, 340px);
}
.settings-chip {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 999px;
background: var(--settings-chip-bg);
color: var(--settings-chip-fg);
font-size: 10.5px;
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
}
.settings-chip--success {
background: color-mix(in srgb, #4ade80 14%, transparent);
color: var(--app-provider-stalker);
}
.settings-chip--accent {
background: var(--app-selection-surface);
color: var(--app-selection-color);
}
.settings-chip--warn {
background: var(--app-expiry-warn-surface);
color: var(--app-expiry-warn-text);
}
.settings-callout {
display: flex;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--settings-callout-info-border);
background: var(--settings-callout-info-bg);
color: var(--settings-muted);
font-size: 12.5px;
line-height: 1.5;
> mat-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
font-size: 16px;
margin-top: 1px;
color: var(--app-selection-color);
}
b {
display: block;
margin-bottom: 2px;
color: var(--app-heading-color);
font-weight: 600;
}
a {
color: var(--app-selection-color);
}
}
.settings-callout--warn {
border-color: var(--settings-callout-warn-border);
background: var(--settings-callout-warn-bg);
> mat-icon {
color: var(--settings-callout-warn-icon);
}
}
.settings-callout__actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
// Segmented control (theme, cover size, guide layout): a pill.
.theme-switcher {
display: inline-flex;
padding: 3px;
border: 1px solid var(--settings-control-border);
border-radius: 999px;
background: var(--settings-control-bg);
}
.theme-switcher__option {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--app-body-color);
font: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
&:hover {
color: var(--app-heading-color);
}
&:focus-visible {
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
.theme-switcher__option--selected {
background: var(--app-selection-surface);
color: var(--app-selection-color);
}
// ─── Buttons in rows ───────────────────────────────────────────────────────
.inline-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.button-spinner {
display: inline-block;
margin-right: 6px;
vertical-align: middle;
}
.inline-actions__label {
display: inline-flex;
align-items: center;
}
.inline-actions__button--busy {
background: var(--app-selection-surface);
}
// TMDB attribution / disclaimers under the last card (About, Metadata,
// Parental lock).
.settings-foot {
display: flex;
align-items: center;
gap: 10px;
color: var(--settings-muted);
font-size: 11.5px;
line-height: 1.6;
img {
height: 14px;
}
}
// ─── Restart notice ────────────────────────────────────────────────────────
.settings-restart-bar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px 8px 16px;
border: 1px solid var(--settings-card-border);
border-radius: 10px;
background: var(--settings-restart-bar-bg);
color: var(--app-body-color);
font-size: 12.5px;
mat-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
font-size: 16px;
color: var(--app-selection-color);
}
span {
flex: 1;
min-width: 0;
}
}
// ─── Save bar ──────────────────────────────────────────────────────────────
// Only present while the form is dirty. It floats over the content column
// (sticky, so it never covers the shell rail) and carries the change count.
.unsaved-bar {
position: sticky;
bottom: calc(16px + var(--settings-safe-bottom));
z-index: 100;
display: flex;
align-items: center;
gap: 12px;
width: min(100%, 720px);
margin: 0 auto;
padding: 10px 10px 10px 16px;
box-sizing: border-box;
border: 1px solid var(--settings-action-bar-border);
border-radius: 12px;
background: var(--settings-action-bar-bg);
box-shadow: var(--settings-action-bar-shadow);
animation: unsaved-bar-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1);
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@keyframes unsaved-bar-enter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.unsaved-bar__dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--app-selection-color);
}
.unsaved-bar__message {
flex: 1;
min-width: 0;
color: var(--app-heading-color);
font-size: 13px;
}
.unsaved-bar__actions {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.settings-save-button.mat-mdc-button-base {
font-weight: 600;
}
.settings-kbd {
margin-left: 8px;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10.5px;
font-weight: 500;
opacity: 0.8;
}
// ─── Narrow panes ──────────────────────────────────────────────────────────
// Below 600px of content width a row cannot hold its label beside a 220px
// control, so the control drops under the label.
@container (max-width: 600px) {
.settings-layout {
padding: 20px 16px calc(24px + var(--settings-safe-bottom));
}
.settings-group__header h2 {
font-size: 22px;
}
.setting-item {
grid-template-columns: 1fr;
gap: 12px;
}
.setting-item__control,
.setting-item__toggle {
justify-content: flex-start;
}
.setting-item__control--stack {
align-items: flex-start;
}
.settings-select.mat-mdc-form-field,
.settings-text-field.mat-mdc-form-field {
width: 100%;
}
// Translated option labels (ru, de) do not fit a one-line pill on a
// phone: the segments take the row and wrap when they must.
.theme-switcher {
width: 100%;
flex-wrap: wrap;
border-radius: 14px;
}
.theme-switcher__option {
flex: 1 1 auto;
min-width: 0;
}
.settings-section__header {
flex-wrap: wrap;
}
.settings-section__actions {
flex-basis: 100%;
margin-left: 0;
}
.unsaved-bar {
flex-wrap: wrap;
bottom: calc(8px + var(--settings-safe-bottom));
}
// Long translations ("Enregistrer les modifications") do not fit two
// buttons side by side on a phone: stack them, Save first.
.unsaved-bar__actions {
flex-basis: 100%;
flex-direction: column-reverse;
align-items: stretch;
margin-left: 0;
button {
width: 100%;
}
}
// A keyboard-only affordance has no place on a phone.
.settings-kbd {
display: none;
}
}