From 7d7d691ce401f7ba7bacba22b4746dc3137754d2 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:16:38 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- .changes/ui-skeleton-geometry.md | 9 + .../src/ipc-channel-hold.ts | 108 ++++++ .../src/skeleton-geometry.e2e-support.ts | 159 ++++++++ .../src/skeleton-geometry.e2e.ts | 366 ++++++++++++++++++ docs/architecture/iptvnator-ui-guidelines.md | 32 +- docs/architecture/m3u-playlist-module.md | 3 + docs/architecture/workspace-dashboard.md | 5 +- .../all-channels-view.component.scss | 18 +- .../channel-list-container.component.html | 1 + .../channel-list-container.component.scss | 19 +- .../groups-view/groups-view.component.scss | 29 +- .../channel-list-loading-state.component.html | 84 ++-- .../channel-list-loading-state.component.scss | 186 +++++---- ...annel-list-loading-state.component.spec.ts | 58 +++ .../channel-list-loading-state.component.ts | 10 + .../src/lib/styles/_channel-list-layout.scss | 99 +++++ .../src/lib/styles/_skeleton-shimmer.scss | 52 +-- libs/ui/styles/_index.scss | 1 + libs/ui/styles/_skeleton-shimmer.scss | 59 +++ .../lib/rails/_dashboard-rail-geometry.scss | 190 +++++++++ .../lib/rails/dashboard-hero.component.scss | 26 +- .../dashboard-rail-skeleton.component.html | 63 +++ .../dashboard-rail-skeleton.component.scss | 126 ++++++ .../dashboard-rail-skeleton.component.spec.ts | 91 +++++ .../dashboard-rail-skeleton.component.ts | 23 ++ .../lib/rails/dashboard-rail.component.html | 3 +- .../lib/rails/dashboard-rail.component.scss | 117 ++---- .../rails/dashboard-rail.component.spec.ts | 21 +- .../src/lib/rails/dashboard-rail.component.ts | 7 + .../src/lib/rails/dashboard-rail.utils.ts | 17 + .../workspace-dashboard-rails.component.html | 98 ++--- .../workspace-dashboard-rails.component.scss | 70 ---- ...orkspace-dashboard-rails.component.spec.ts | 33 ++ .../workspace-dashboard-rails.component.ts | 29 +- 34 files changed, 1747 insertions(+), 465 deletions(-) create mode 100644 .changes/ui-skeleton-geometry.md create mode 100644 apps/electron-backend-e2e/src/ipc-channel-hold.ts create mode 100644 apps/electron-backend-e2e/src/skeleton-geometry.e2e-support.ts create mode 100644 apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts create mode 100644 libs/ui/components/src/lib/styles/_channel-list-layout.scss create mode 100644 libs/ui/styles/_skeleton-shimmer.scss create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/_dashboard-rail-geometry.scss create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeleton.component.html create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeleton.component.scss create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeleton.component.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeleton.component.ts diff --git a/.changes/ui-skeleton-geometry.md b/.changes/ui-skeleton-geometry.md new file mode 100644 index 000000000..1fcd7f6e4 --- /dev/null +++ b/.changes/ui-skeleton-geometry.md @@ -0,0 +1,9 @@ +--- +type: fix +area: ui +--- + +Loading placeholders now take the size of what replaces them: an M3U +playlist's channel list no longer jumps down when it finishes loading, and +the dashboard's rail placeholders match the cards that arrive, so the rails +below stay put. Their shimmer is also easier to see in the light theme. diff --git a/apps/electron-backend-e2e/src/ipc-channel-hold.ts b/apps/electron-backend-e2e/src/ipc-channel-hold.ts new file mode 100644 index 000000000..4e5fccf20 --- /dev/null +++ b/apps/electron-backend-e2e/src/ipc-channel-hold.ts @@ -0,0 +1,108 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { LaunchedElectronApp } from './electron-test-fixtures'; +import { + electronMainPath, + expect, + launchElectronApp, +} from './electron-test-fixtures'; + +interface ChannelHold { + /** Invocations parked behind the hold, waiting for `release`. */ + waiting: number; + gate: Promise; + release: () => void; +} + +type HoldGlobal = typeof globalThis & { + __ipcChannelHolds?: Record; + __ipcChannelHoldInstalled?: string[]; +}; + +// Serialized into the test bootstrap; all runtime dependencies are arguments. +function installChannelHolds( + { ipcMain }: Pick, + channels: readonly string[] +): void { + const register = ipcMain.handle; + const scope = globalThis as HoldGlobal; + scope.__ipcChannelHolds = {}; + scope.__ipcChannelHoldInstalled = []; + // Wraps the listener at registration through the public API; no + // Electron-private handler registry is read or mutated. + ipcMain.handle = function (channel, listener) { + if (!channels.includes(channel)) { + return register.call(this, channel, listener); + } + scope.__ipcChannelHoldInstalled?.push(channel); + return register.call(this, channel, async (...args) => { + const hold = scope.__ipcChannelHolds?.[channel]; + if (hold) { + hold.waiting += 1; + await hold.gate; + } + return listener(...args); + }); + }; +} + +/** + * Launches the app with `channels` wrapped so a test can park their + * invocations (`holdIpcChannel`) and keep a loading state on screen for as + * long as it measures it. + */ +export function launchElectronWithChannelHolds( + dataDir: string, + channels: readonly string[] +): Promise { + const entryPoint = join(dataDir, 'ipc-channel-holds.cjs'); + writeFileSync( + entryPoint, + [ + `(${installChannelHolds.toString()})(require('electron'), ${JSON.stringify(channels)});`, + `require(${JSON.stringify(electronMainPath)});`, + ].join('\n') + ); + return launchElectronApp(dataDir, { entryPoint }); +} + +/** Parks every later invocation of `channel` until the returned release. */ +export async function holdIpcChannel( + app: LaunchedElectronApp, + channel: string +) { + await app.electronApp.evaluate((_electron, name) => { + const scope = globalThis as HoldGlobal; + if (!scope.__ipcChannelHoldInstalled?.includes(name)) { + throw new Error(`IPC channel ${name} has no hold installed`); + } + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const holds = (scope.__ipcChannelHolds ??= {}); + holds[name] = { waiting: 0, gate, release }; + }, channel); + + return { + waitUntilHeld: () => + expect + .poll(() => + app.electronApp.evaluate( + (_electron, name) => + (globalThis as HoldGlobal).__ipcChannelHolds?.[name] + ?.waiting ?? 0, + channel + ) + ) + .toBeGreaterThan(0), + release: () => + app.electronApp.evaluate((_electron, name) => { + const holds = (globalThis as HoldGlobal).__ipcChannelHolds; + const hold = holds?.[name]; + if (!hold) throw new Error(`IPC channel ${name} is not held`); + delete holds[name]; + hold.release(); + }, channel), + }; +} diff --git a/apps/electron-backend-e2e/src/skeleton-geometry.e2e-support.ts b/apps/electron-backend-e2e/src/skeleton-geometry.e2e-support.ts new file mode 100644 index 000000000..594889a89 --- /dev/null +++ b/apps/electron-backend-e2e/src/skeleton-geometry.e2e-support.ts @@ -0,0 +1,159 @@ +import type { Page } from '@playwright/test'; +import { expect } from './electron-test-fixtures'; + +export interface Box { + top: number; + left: number; + width: number; + height: number; + bottom: number; +} + +export interface LayoutShiftReport { + score: number; + shifts: { + value: number; + sources: { node: string; deltaY: number; deltaHeight: number }[]; + }[]; +} + +/** Header and row positions must match to this many pixels. */ +export const GEOMETRY_TOLERANCE_PX = 0.5; + +type ShiftWindow = typeof window & { __skeletonShifts?: LayoutShiftReport }; + +/** Border boxes of the first match of each selector, in viewport pixels. */ +export async function measureBoxes( + page: Page, + selectors: Record +): Promise> { + const boxes = await page.evaluate( + (map) => { + const result: Record = {}; + for (const [key, selector] of Object.entries(map)) { + const rect = document + .querySelector(selector) + ?.getBoundingClientRect(); + result[key] = rect + ? { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + bottom: rect.bottom, + } + : null; + } + return result; + }, + selectors as Record + ); + + for (const [key, box] of Object.entries(boxes)) { + expect( + box, + `${key} (${selectors[key as Key]}) is rendered` + ).not.toBeNull(); + } + return boxes as Record; +} + +/** + * Starts summing `layout-shift` entries from now on, with the nodes each + * shift moved, until `readLayoutShifts`. + */ +export async function observeLayoutShifts(page: Page): Promise { + await page.evaluate(() => { + const report: LayoutShiftReport = { score: 0, shifts: [] }; + (window as ShiftWindow).__skeletonShifts = report; + const nodeLabel = (node: Node | null): string => { + if (!(node instanceof Element)) return String(node?.nodeName); + const testId = node.getAttribute('data-test-id'); + return [ + node.tagName.toLowerCase(), + ...[...node.classList].map((name) => `.${name}`), + testId ? `[data-test-id="${testId}"]` : '', + ].join(''); + }; + new PerformanceObserver((list) => { + for (const entry of list.getEntries() as (PerformanceEntry & { + value: number; + sources?: { + node: Node | null; + previousRect: DOMRectReadOnly; + currentRect: DOMRectReadOnly; + }[]; + })[]) { + report.score += entry.value; + report.shifts.push({ + value: entry.value, + sources: (entry.sources ?? []).map((source) => ({ + node: nodeLabel(source.node), + deltaY: source.currentRect.y - source.previousRect.y, + deltaHeight: + source.currentRect.height - + source.previousRect.height, + })), + }); + } + }).observe({ type: 'layout-shift', buffered: false }); + }); +} + +export async function readLayoutShifts(page: Page): Promise { + const report = await page.evaluate( + () => (window as ShiftWindow).__skeletonShifts + ); + if (!report) throw new Error('observeLayoutShifts was not called'); + return report; +} + +/** + * Resolves once the workspace content has not mutated for 300 ms (at most + * 3 s), then after one more frame, so pending layout shifts are delivered. + */ +export async function waitForContentQuiet(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => { + const target = + document.querySelector('main.workspace-content') ?? + document.documentElement; + let quiet = 0; + const finish = () => { + observer.disconnect(); + clearTimeout(cap); + clearTimeout(quiet); + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()) + ); + }; + const observer = new MutationObserver(() => { + clearTimeout(quiet); + quiet = window.setTimeout(finish, 300); + }); + observer.observe(target, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + quiet = window.setTimeout(finish, 300); + const cap = window.setTimeout(finish, 3000); + }) + ); +} + +/** `actual` within the geometry tolerance of `expected`, or a message + * naming both boxes. */ +export function expectSameEdge( + label: string, + actual: number, + expected: number, + evidence: unknown +): void { + expect( + Math.abs(actual - expected), + `${label}: ${actual.toFixed(2)} vs skeleton ${expected.toFixed(2)}\n${JSON.stringify(evidence, null, 1)}` + ).toBeLessThanOrEqual(GEOMETRY_TOLERANCE_PX); +} diff --git a/apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts b/apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts new file mode 100644 index 000000000..73579b10a --- /dev/null +++ b/apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts @@ -0,0 +1,366 @@ +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + clickCategoryByNameExact, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + goToDashboard, + importM3uPlaylistFromNativeDialog, + m3uFixturePath, + openSettings, + openSettingsSection, + openWorkspaceSection, + resetMockServers, + saveSettings, + setSwitch, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { toggleFavoriteForChannel } from './dashboard-e2e-flows'; +import { + holdIpcChannel, + launchElectronWithChannelHolds, +} from './ipc-channel-hold'; +import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, +} from './renderer-reload.support'; +import { + expectSameEdge, + measureBoxes, + observeLayoutShifts, + readLayoutShifts, + waitForContentQuiet, +} from './skeleton-geometry.e2e-support'; + +// --------------------------------------------------------------------------- +// A skeleton must hold the place of what it precedes: the same header +// heights, the same first-row position and the same rail heights, so content +// arrives without moving anything. Each loading state is kept on screen by +// holding the IPC channel that answers it, measured, then released and +// compared with what replaced it. The M3U skeleton swaps the whole channel +// list subtree, which the browser does not count as a layout shift at all, +// so its positions are compared directly; on the dashboard a rail that +// changes height moves the rails below it, which the layout-shift score +// catches as well. +// --------------------------------------------------------------------------- + +const viewport = { width: 1280, height: 700 }; +const playlistChannel = 'DB_GET_APP_PLAYLIST'; +const favoritesChannel = 'DB_GET_ALL_GLOBAL_FAVORITES'; +const recentlyAddedChannel = 'DB_GET_GLOBAL_RECENTLY_ADDED'; +const maxDashboardShiftScore = 0.01; + +const loadingState = 'app-channel-list-loading-state'; +const firstSkeletonRow = `${loadingState} app-channel-list-item-skeleton`; +const firstChannelRow = 'app-channel-list-item'; + +async function openM3uFromDashboard(page: Page): Promise { + await page + .getByTestId('dashboard-recent-sources-rail-card-link') + .first() + .click(); +} + +test.describe('Skeleton geometry', () => { + test('M3U channel list skeletons put the headers and the first row where the list lands', async ({ + dataDir, + }) => { + test.setTimeout(120_000); + const app = await launchElectronWithChannelHolds(dataDir, [ + playlistChannel, + ]); + const page = app.mainWindow; + + try { + await page.setViewportSize(viewport); + await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); + await goToDashboard(page); + + // All channels, the default view. + let hold = await holdIpcChannel(app, playlistChannel); + await openM3uFromDashboard(page); + await hold.waitUntilHeld(); + await expect( + page.locator(`${loadingState} .channels-loading-header`) + ).toBeVisible(); + const allSkeleton = await measureBoxes(page, { + header: `${loadingState} .channels-loading-header`, + divider: `${loadingState} .channels-loading-divider`, + row: firstSkeletonRow, + }); + await observeLayoutShifts(page); + await hold.release(); + await expect(page.locator(firstChannelRow).first()).toBeVisible(); + await waitForContentQuiet(page); + const all = await measureBoxes(page, { + header: '.all-channels-header', + divider: '.all-channels-divider', + row: firstChannelRow, + }); + const allEvidence = { skeleton: allSkeleton, loaded: all }; + expectSameEdge( + 'All channels header top', + all.header.top, + allSkeleton.header.top, + allEvidence + ); + expectSameEdge( + 'All channels header height', + all.header.height, + allSkeleton.header.height, + allEvidence + ); + expectSameEdge( + 'All channels divider', + all.divider.bottom, + allSkeleton.divider.bottom, + allEvidence + ); + expectSameEdge( + 'All channels first row', + all.row.top, + allSkeleton.row.top, + allEvidence + ); + expect((await readLayoutShifts(page)).score).toBe(0); + + // Groups: entering the section again from the dashboard reloads + // the playlist, so the skeleton shows once more. + await openWorkspaceSection(page, 'Groups'); + await expect(page.locator('.groups-content-header')).toBeVisible(); + await goToDashboard(page); + hold = await holdIpcChannel(app, playlistChannel); + await page.goBack(); + await hold.waitUntilHeld(); + await expect( + page.locator(`${loadingState} .groups-loading-content__header`) + ).toBeVisible(); + const groupsSkeleton = await measureBoxes(page, { + navHeader: `${loadingState} .groups-loading-nav__header`, + // The second item: the selected first one is lifted by 1px. + navItem: `${loadingState} .groups-loading-nav__item:nth-child(2)`, + header: `${loadingState} .groups-loading-content__header`, + row: firstSkeletonRow, + }); + await observeLayoutShifts(page); + await hold.release(); + await expect(page.locator(firstChannelRow).first()).toBeVisible(); + await waitForContentQuiet(page); + const groups = await measureBoxes(page, { + navHeader: '.groups-nav-header', + navItem: '.group-nav-item:nth-child(2)', + header: '.groups-content-header', + row: firstChannelRow, + }); + const groupsEvidence = { skeleton: groupsSkeleton, loaded: groups }; + expectSameEdge( + 'Groups rail header height', + groups.navHeader.height, + groupsSkeleton.navHeader.height, + groupsEvidence + ); + expectSameEdge( + 'Groups rail second item', + groups.navItem.top, + groupsSkeleton.navItem.top, + groupsEvidence + ); + expectSameEdge( + 'Groups rail item height', + groups.navItem.height, + groupsSkeleton.navItem.height, + groupsEvidence + ); + expectSameEdge( + 'Group header top', + groups.header.top, + groupsSkeleton.header.top, + groupsEvidence + ); + expectSameEdge( + 'Group header height', + groups.header.height, + groupsSkeleton.header.height, + groupsEvidence + ); + expectSameEdge( + 'Group first row', + groups.row.top, + groupsSkeleton.row.top, + groupsEvidence + ); + expect((await readLayoutShifts(page)).score).toBe(0); + } finally { + await closeElectronApp(app); + } + }); + + test('dashboard rail skeletons have the height of the rails that replace them', async ({ + dataDir, + request, + }) => { + test.setTimeout(120_000); + await resetMockServers(request, ['xtream']); + const live = await fetchXtreamLiveFixture(request, { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }); + const app = await launchElectronWithChannelHolds(dataDir, [ + favoritesChannel, + recentlyAddedChannel, + ]); + const page = app.mainWindow; + + try { + await page.setViewportSize(viewport); + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await openWorkspaceSection(page, 'Live TV'); + await clickCategoryByNameExact(page, live.categoryName); + await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0])); + + // A skeleton never shows above a rail that already has cards, and + // the sources rail is ready first: without it, the live + // favorites skeleton (channel cards) sits right above the + // recently-added one (posters), both in the viewport. + await openSettings(page); + await openSettingsSection(page, 'dashboard'); + await setSwitch( + page + .getByTestId('toggle-dashboard-rail-recent-sources') + .locator('[role="switch"]'), + false + ); + await saveSettings(page); + await goToDashboard(page); + await expect( + page.getByTestId('dashboard-xtream-recently-added-rail') + ).toBeVisible({ timeout: 20_000 }); + + // Recently added is asked for once the favorites have loaded, so + // it is held only after the favorites are. + const favorites = await holdIpcChannel(app, favoritesChannel); + await reloadFromMainProcess(app); + await expectRendererReloadedOnRoute( + page, + /\/workspace\/dashboard$/ + ); + await page.setViewportSize(viewport); + await favorites.waitUntilHeld(); + const recentlyAdded = await holdIpcChannel( + app, + recentlyAddedChannel + ); + const liveSkeletonRail = + '[data-test-id="dashboard-live-favorites-rail-skeleton"]'; + const addedSkeletonRail = + '[data-test-id="dashboard-xtream-recently-added-rail-skeleton"]'; + await expect(page.locator(liveSkeletonRail)).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator(addedSkeletonRail)).toBeVisible(); + await waitForContentQuiet(page); + const skeleton = await measureBoxes(page, { + liveRail: liveSkeletonRail, + liveHeader: `${liveSkeletonRail} .rail-skeleton__header`, + liveCard: `${liveSkeletonRail} .rail-skeleton__card`, + addedRail: addedSkeletonRail, + addedHeader: `${addedSkeletonRail} .rail-skeleton__header`, + addedCard: `${addedSkeletonRail} .rail-skeleton__card`, + }); + + await observeLayoutShifts(page); + await favorites.release(); + const liveRail = '[data-test-id="dashboard-live-favorites-rail"]'; + await expect(page.locator(liveRail)).toBeVisible(); + await recentlyAdded.waitUntilHeld(); + await waitForContentQuiet(page); + const afterLive = await measureBoxes(page, { + liveRail, + liveHeader: `${liveRail} .rail__header`, + liveCard: `${liveRail} .rail__card`, + addedRail: addedSkeletonRail, + }); + + await recentlyAdded.release(); + const addedRail = + '[data-test-id="dashboard-xtream-recently-added-rail"]'; + await expect(page.locator(addedRail)).toBeVisible(); + await waitForContentQuiet(page); + const loaded = await measureBoxes(page, { + addedRail, + addedHeader: `${addedRail} .rail__header`, + addedCard: `${addedRail} .rail__card`, + }); + const shifts = await readLayoutShifts(page); + const evidence = { skeleton, afterLive, loaded, shifts }; + + expectSameEdge( + 'live favorites rail height', + afterLive.liveRail.height, + skeleton.liveRail.height, + evidence + ); + expectSameEdge( + 'live favorites header height', + afterLive.liveHeader.height, + skeleton.liveHeader.height, + evidence + ); + expectSameEdge( + 'channel card width', + afterLive.liveCard.width, + skeleton.liveCard.width, + evidence + ); + expectSameEdge( + 'channel card height', + afterLive.liveCard.height, + skeleton.liveCard.height, + evidence + ); + // Relative to the rail above it: the hero may still grow past its + // floor when its slides arrive, which moves both rails alike. + expectSameEdge( + 'recently added skeleton stays below the live rail', + afterLive.addedRail.top - afterLive.liveRail.bottom, + skeleton.addedRail.top - skeleton.liveRail.bottom, + evidence + ); + expectSameEdge( + 'recently added rail height', + loaded.addedRail.height, + skeleton.addedRail.height, + evidence + ); + expectSameEdge( + 'recently added header height', + loaded.addedHeader.height, + skeleton.addedHeader.height, + evidence + ); + expectSameEdge( + 'poster card width', + loaded.addedCard.width, + skeleton.addedCard.width, + evidence + ); + expectSameEdge( + 'poster card height', + loaded.addedCard.height, + skeleton.addedCard.height, + evidence + ); + expect( + shifts.score, + JSON.stringify(evidence, null, 1) + ).toBeLessThanOrEqual(maxDashboardShiftScore); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 8c79228c9..7a74e77f9 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 011fd5760..dc9991bb4 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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. diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index fcf39a749..f09271da9 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 `` full-bleed. All rails and the hero are skipped. 3. The hero (`lib-dashboard-hero`) renders when it has at least one slide; diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss index 58bcf0f21..823c96530 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss @@ -1,4 +1,5 @@ @use '@angular/material' as mat; +@use '../../styles/channel-list-layout' as layout; :host { display: flex; @@ -33,23 +34,18 @@ } .all-channels-header { - padding: 12px 16px 8px; - flex-shrink: 0; + @include layout.list-header-box; } .all-channels-header__top { - display: flex; - align-items: center; - gap: 8px; + @include layout.list-header-row($actions: true); h2 { + @include layout.list-header-title-type; flex: 1; min-width: 0; overflow: hidden; - margin: 0; color: var(--mat-sys-on-surface); - font-size: 0.94rem; - font-weight: 600; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; @@ -66,11 +62,7 @@ } .all-channels-divider { - height: 1px; - background: var(--mat-sys-outline-variant); - margin: 0 0 6px; - opacity: 0.6; - flex-shrink: 0; + @include layout.list-divider; } #all-channels { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index ccd0f7ac7..53dba5596 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -2,6 +2,7 @@ } @else if (showChannelViews()) { @if ( diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss index 99b7873a5..133285fd7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss @@ -1,3 +1,5 @@ +@use '../styles/channel-list-layout' as layout; + :host { display: flex; flex-direction: column; @@ -74,20 +76,15 @@ // ─── Header ─────────────────────────────────────────────────────────────────── .context-header { - padding: 12px 16px 8px; - flex-shrink: 0; + @include layout.list-header-box; } .context-header__top { - display: flex; - align-items: center; + @include layout.list-header-row; justify-content: space-between; - gap: 8px; h2 { - margin: 0; - font-size: 0.94rem; - font-weight: 600; + @include layout.list-header-title-type; letter-spacing: -0.01em; color: var(--mat-sys-on-surface); } @@ -95,9 +92,5 @@ // ─── Divider ────────────────────────────────────────────────────────────────── .context-divider { - height: 1px; - background: var(--mat-sys-outline-variant); - margin: 0 0 6px; - opacity: 0.6; - flex-shrink: 0; + @include layout.list-divider; } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss index a9f735244..e6d9892fe 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss @@ -2,6 +2,7 @@ @use '../../../../../styles/portal-sidebar'; @use '../../../../../styles/panel-header' as panel; @use '../../../../../styles/nav-list'; +@use '../../styles/channel-list-layout' as layout; :host { display: flex; @@ -56,25 +57,15 @@ } .groups-nav-header { - padding: 4px 6px 12px; - flex-shrink: 0; - display: flex; - gap: 10px; - flex-direction: column; + @include layout.groups-nav-header-box; } .groups-nav-header__content { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; + @include layout.groups-nav-header-row; } .groups-nav-title { - margin: 0; - font-size: 0.94rem; - font-weight: 600; - letter-spacing: -0.01em; + @include layout.groups-nav-title-type; color: var(--mat-sys-on-surface); } @@ -168,11 +159,6 @@ } } -.groups-content-header { - @include panel.standard-panel-header(); - border-top-left-radius: inherit; -} - .groups-content-header button { @include mat.form-field-density(-5); } @@ -181,9 +167,7 @@ @include panel.standard-panel-meta(); &--stacked { - flex-direction: column; - align-items: flex-start; - gap: 4px; + @include layout.groups-content-meta-stacked; } } @@ -261,7 +245,10 @@ border-top-left-radius: 16px; } +// Outranks `.sidebar .sidebar-header` (portal-sidebar), so the header's box +// comes from the mixin the loading skeleton shares. .groups-content-panel > .groups-content-header { + @include layout.groups-content-header-box; border-top-left-radius: inherit; } diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html index 676655433..89f29c166 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html @@ -7,37 +7,52 @@