fix(ui): skeletons that match the M3U header and dashboard rails (#1887)

* 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>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 08:16:38 +02:00
1 parent ea7efdbd9b
commit 7d7d691ce4
34 files changed
+1747 -465

No files matched your search

+30 -2
View File
@@ -745,6 +745,30 @@ mirrors the row/card geometry it precedes (see Channel List Item and Cover
Grids). The unified Favorites/Recent page gates this on `isLoading`, set only
while its item list is empty.
A skeleton takes its boxes from the code that draws the content, not from
copied values: the same Sass mixins for every box that sets a height (header
padding and minimum height, row and card size, track bleed, gaps), and for
text a line box of the real font size and line height with a shorter shimmer
bar inside it. Copied values drift: the M3U channel list moved down by 19 px
when its playlist loaded, and the dashboard rail skeletons were 2:3 posters
in front of 84 px channel cards.
- The channel list views and `app-channel-list-loading-state` share
`libs/ui/components/src/lib/styles/_channel-list-layout.scss`. The loading
state takes the view and hides its headers where the loaded view does
(`showHeader`, compact lists).
- The dashboard rails and `lib-dashboard-rail-skeleton` share
`libs/workspace/dashboard/feature/src/lib/rails/_dashboard-rail-geometry.scss`.
A skeleton takes the `layout` and `aspectRatio` of the rail it precedes:
channel cards for live favorites, 16:9 cards for sources, posters with a
title and a meta line otherwise.
- `apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts` holds the IPC
channel that answers each loading state, compares the skeleton's header,
first-row and rail boxes with the content's to 0.5 px at 1280×700, and keeps
the dashboard's layout-shift score from skeleton to content at or under
0.01. A whole subtree swapped for new nodes does not count as a layout
shift, so the positions are compared directly.
### Pages of independently loading blocks: delayed skeletons
The dashboard renders each rail as soon as its own data arrives, and several
@@ -770,9 +794,13 @@ page down. For the same reason it stays until every source that can fill it
has loaded, not only the first one. Use the same rules for any page that
stacks independently loading blocks.
Skeleton blocks use one shimmer, `libs/ui/styles/_skeleton-shimmer.scss`: a
fill mixed from `--app-on-surface` (8%, with a 16% highlight), so it reads on
both themes. The dashboard rails used to sweep between `--app-widget-bg` and
`--app-widget-header-bg`, 1.04:1 apart on the light theme.
The movie and series detail hero has an immediate skeleton too, built from
the shared shimmer (`libs/ui/components/src/lib/styles/_skeleton-shimmer.scss`)
at the loaded hero's geometry: stage height, bottom-aligned eyebrow, title,
the shared shimmer at the loaded hero's geometry: stage height, bottom-aligned eyebrow, title,
one row of chips, two description lines, the action row and two credit lines.
The Xtream series page adds an episodes section skeleton under it, and the
Stalker series hero holds the Play button's place
+3
View File
@@ -605,6 +605,9 @@ channel-list-container/
`setFavorites` action: doing so reads and rewrites the complete M3U payload
again just to store favorites that already came from SQLite.
- `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true.
Its headers and first row share the views' boxes
(`libs/ui/components/src/lib/styles/_channel-list-layout.scss`), so the list
does not move when the channels land.
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
- M3U favorites and recent collection rows preserve their full `Channel` payload on unified live items so the shared live list can open the read-only channel details context menu without reconstructing partial channel data.
+4 -1
View File
@@ -70,7 +70,10 @@ Render rules:
1. Dashboard rails render independently as their data sources resolve. The
page no longer uses `dashboardReady()` as a page-wide skeleton gate.
Initial hero/recent/favorites loading states render scoped skeletons so one
slow rail does not hide already available content.
slow rail does not hide already available content. A rail skeleton is a
`lib-dashboard-rail-skeleton` with the layout of the rail it precedes,
sized from the rail's own geometry partial, so the rail replaces it at the
same height (UI guidelines, Loading States).
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
full-bleed. All rails and the hero are skipped.
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;