Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950

# Conflicts:
#	apps/electron-backend-e2e/src/detail-back-lane.e2e.ts
#	apps/web-e2e/src/m3u-movie-details.e2e.ts
#	docs/architecture/iptvnator-ui-guidelines.md
#	libs/ui/components/src/lib/content-hero/content-hero.component.scss
#	libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss
This commit is contained in:
4gray committed 2026-10-03 11:25:57 +02:00
commit 87d7e1d46c
122 files changed
+2080 -1040

No files matched your search

+13 -15
View File
@@ -345,17 +345,15 @@ test('@web @m3u @tmdb recognized movies open the VOD detail view', async ({
).toEqual(['app-html-video-player']);
// Metadata patches the mounted view asynchronously. The shell stamps the
// host templates into BOTH the hero and the watch-state About block, so
// every metadata string legitimately resolves twice.
// host templates into BOTH the hero and the watch-state About block, but
// the collapsed hero turns `visibility: hidden` 300ms after mount,
// sometimes before TMDB answers. Assert the copy a watching user sees.
const about = detail(page).locator('app-content-about');
await expect(
detail(page).getByText('Paul Atreides arrives on Arrakis.').first()
).toBeVisible();
await expect(
detail(page).getByText('Denis Villeneuve').first()
).toBeVisible();
await expect(
detail(page).getByText('Science Fiction').first()
about.getByText('Paul Atreides arrives on Arrakis.')
).toBeVisible();
await expect(about.getByText('Denis Villeneuve')).toBeVisible();
await expect(about.getByText('Science Fiction')).toBeVisible();
// A live channel keeps the classic layout.
await sidebarEntry(page, 'Live One').click();
@@ -406,19 +404,19 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
)
)
.toBe(0.25);
// M3U has no browse Back target, so the shell shows no arrow in either
// M3U has no browse Back target, so the header shows no arrow in either
// state; the now-playing bar's own Close button returns to browse.
const shell = detail(page).locator('app-portal-detail-shell');
await expect(shell.locator('.shell__back-button')).toHaveCount(0);
const headerBack = page.locator('[data-test-id="workspace-header-back"]');
await expect(headerBack).toHaveCount(0);
await shell
.locator('app-portal-inline-player')
.getByRole('button', { name: 'Close player', exact: true })
.click();
await expect(inlineVideo(page)).toHaveCount(0);
await expect(shell.locator('.shell__back-button')).toHaveCount(0);
// Without the arrow there is no lane to reserve: the hero keeps its own
// inset (32px, or 20px in a pane narrower than 760px) instead of the
// 72px column portal details give the Back control.
await expect(headerBack).toHaveCount(0);
// The hero keeps its own inset (32px, or 20px in a pane narrower than
// 760px) in both states.
expect(
await shell
.locator('.hero__content')
@@ -90,8 +90,7 @@ async function openUnsavedDialog(page: Page, locale: Locale): Promise<Locator> {
page.locator('[data-test-id="settings-unsaved-bar"]')
).toBeVisible();
// The rail's Dashboard link stays visible in the phone top bar, unlike
// the brand link.
// The rail's Dashboard link stays visible in the phone top bar.
await page
.getByRole('navigation')
.locator('a[href$="/workspace/dashboard"]')
+22 -19
View File
@@ -1156,7 +1156,11 @@ for (const theme of ['light', 'dark']) {
.click();
await page.locator('app-grid-list mat-card').first().click();
const shell = page.locator('app-portal-detail-shell');
await expect(shell).toBeVisible();
// The loading skeleton is a scrollable shell of its own; a key
// sent to it is lost when the loaded shell replaces it.
await expect(
shell.getByRole('heading', { level: 1 })
).toBeVisible();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
@@ -1178,7 +1182,7 @@ for (const theme of ['light', 'dark']) {
.toBeGreaterThan(0);
});
test(`@xtream sticky detail Back ${section} (${theme})`, async ({
test(`@xtream header detail Back ${section} (${theme})`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 540 });
@@ -1196,30 +1200,28 @@ for (const theme of ['light', 'dark']) {
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const back = shell.getByRole('button', {
name: 'Back',
exact: true,
});
// Back is the header's leading control, not a page overlay.
const back = page.locator(
'app-workspace-shell-header [data-test-id="workspace-header-back"]'
);
await expect(back).toBeVisible();
const backOffset = () =>
back.evaluate((el) => {
const owner = el.closest('app-portal-detail-shell');
return owner
? el.getBoundingClientRect().top -
owner.getBoundingClientRect().top
: NaN;
});
await expect.poll(backOffset).toBeCloseTo(16, 0);
await expect(back).toHaveAccessibleName('Back');
await expect(
shell.getByRole('button', { name: 'Back', exact: true })
).toHaveCount(0);
const backTop = () =>
back.evaluate((el) => el.getBoundingClientRect().top);
const initialTop = await backTop();
await page.keyboard.press('End');
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await waitForScrollIdle(shell);
await expect.poll(backOffset).toBeCloseTo(16, 0);
expect(await backTop()).toBe(initialTop);
await expect(back).toBeInViewport();
await shell.screenshot({
await page.screenshot({
path: testInfo.outputPath(
`sticky-back-${section}-${theme}.png`
`header-back-${section}-${theme}.png`
),
});
await back.click();
@@ -1230,7 +1232,8 @@ for (const theme of ['light', 'dark']) {
).toBeVisible();
await shell.focus();
await page.keyboard.press('End');
// Hover/focus must not make a tooltip consume the advertised Esc.
// Hover/focus must not make a tooltip consume the advertised Esc,
// even with focus on the header rather than inside the page.
await page.clock.install();
await back.focus();
await back.hover();