mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
* 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>
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
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<Key extends string>(
|
|
page: Page,
|
|
selectors: Record<Key, string>
|
|
): Promise<Record<Key, Box>> {
|
|
const boxes = await page.evaluate(
|
|
(map) => {
|
|
const result: Record<string, Box | null> = {};
|
|
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<string, string>
|
|
);
|
|
|
|
for (const [key, box] of Object.entries(boxes)) {
|
|
expect(
|
|
box,
|
|
`${key} (${selectors[key as Key]}) is rendered`
|
|
).not.toBeNull();
|
|
}
|
|
return boxes as Record<Key, Box>;
|
|
}
|
|
|
|
/**
|
|
* Starts summing `layout-shift` entries from now on, with the nodes each
|
|
* shift moved, until `readLayoutShifts`.
|
|
*/
|
|
export async function observeLayoutShifts(page: Page): Promise<void> {
|
|
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<LayoutShiftReport> {
|
|
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<void> {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((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);
|
|
}
|