* fix(ui): skeletons that match the M3U header and dashboard rails
Loading skeletons now take their boxes from the code that draws the
content, so the content lands where the skeleton was.
- M3U channel list: the loading state's headers, divider, groups rail
and first row share `styles/_channel-list-layout.scss` with the All
channels, context and groups views. The All channels list moved down
by 19px when a playlist loaded; the group header was 79px against the
real 69px (the panel header's 52px minimum plus the padding that
`.sidebar .sidebar-header` gives it, now one mixin both include).
Group rows start at the list's top-left like the virtual-scroll rows.
- Dashboard rails: `lib-dashboard-rail-skeleton` replaces six copies of
the skeleton markup and takes the rail's layout and aspect ratio
(channel cards for live favorites, 16:9 for sources, posters with a
title and a meta line otherwise). Its header, track, cards and text
line boxes come from `_dashboard-rail-geometry.scss`, which the rail
itself now uses. The skeleton was 328px against the 337.8px poster
rail and a 2:3 poster in front of the 83.5px channel cards.
- One shimmer in `libs/ui/styles/_skeleton-shimmer.scss`, mixed from
`--app-on-surface`: the rail sweep between the widget surfaces was
1.04:1 on the light theme. The dashboard hero and the detail
skeletons (through a forward) use it too.
- Electron E2E `skeleton-geometry.e2e.ts` holds the IPC channel behind
each loading state, compares skeleton and content boxes to 0.5px at
1280x700 and caps the dashboard layout-shift score at 0.01.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): keep rail heights when a skeleton stands for a label-less or live rail
Review follow-up (Greptile, local):
- Rails that replace a skeleton (Continue Watching, sources, Xtream
recently added, TMDB) keep an empty meta line under cards without
labels (`reserveMetaLine`), as the skeleton always draws one; a
Continue Watching rail of movies with no known duration came in
shorter.
- The recent-content skeleton follows the recent rail it waits for:
posters while Continue Watching is still loading with no cards,
channel cards otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
* fix(build): include shared UI stylesheets in Nx cache inputs
`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.
Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.
Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.
`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.
Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(build): spawn git without a shell in the stylesheet check
`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.
Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.
Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.
Reported by Codex review on #1360.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(styles): move nav-list partial into ui-styles (#1361)
* fix(build): count every target of a comma-separated Sass @import
`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.
Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.
The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>