feat(details): redesigned episode list, section rhythm and hero fade (#1859)

* fix(details): continue the hero artwork under the first section

The vertical scrim stopped at the hero's bottom edge, so a bright
backdrop ended in a visible band above the Episodes heading. The hero
now grows by a 140px tail that the shell pulls the sections up over,
and the scrim resolves to the exact page surface behind the heading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(details): one section header and a 56px rhythm below the hero

Episodes, Cast & crew and Similar now share app-detail-section-header:
an 18px/600 title, a muted tabular counter and lead/end slots. The
Episodes counter adds the watched count ("8 episodes · 3 watched"),
and sections sit 56px apart, with 56px after the last one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(details): flat episode rows with a reserved action slot

Each episode is now one app-episode-item, a list row or a grid card:
- a number column, a 168px thumbnail with a watched check, a progress
  bar only for started episodes and a play overlay
- title with "46 min · 12 Jan" (time left once started) and a plot
  clamped to two lines at 74ch
- mark watched, download and a "…" menu in a 112px slot that is always
  reserved and shows on hover or keyboard focus, so nothing shifts
- the whole item is one stretched button: Tab focuses it, Enter plays
- the playing or last unfinished episode is highlighted

The "…" menu holds Episode details and, for a started episode, Play
from beginning, which Xtream and Stalker now start at 0. TMDB's episode
runtime fills the meta line when the provider sends none. The list is
the default view for anyone who has not chosen one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(details): segmented list/grid switch and a 30px season pill

The view toggle becomes a 2px-padded segmented track with 30×26
segments and a neutral checked segment; the season pills and the season
dropdown share the section header's 30px pill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(details): no season synopsis that repeats the series description

Providers often send the series plot, or its first sentences, as every
season's description, so the same text showed twice a few hundred
pixels apart. The season strip now drops a synopsis that equals the
hero's description, or is cut short from it, and clamps its own to two
lines at 72ch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(details): draw the episode thumbnail hairline as a border

An inset shadow over the artwork is invisible to the surface-contrast
checks and to forced-colors mode; a 1px border is what both read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): follow the list-first, flat episode items

The list is now the default view, rows and cards share .episode-item
classes, and grid titles no longer carry the "N." prefix. The surface
checks measure the thumbnail hairline instead of a row border, assert
that hovering a row does not move its title, and capture list, grid and
hover screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(ui): episode list, section rhythm and hero tail contracts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashboard): continue the hero artwork under the first rail

The dashboard hero's fade ended at its edge, so the artwork stopped
just above the Continue Watching heading. Like the details hero, it now
grows by a 160px tail that the rails are pulled up over: the art fades
behind the first rail's heading and reaches the page colour before its
cards, so the rail's scroll-edge fades never show as a box. The narrow
layout, whose slide carries its own scrim block, keeps no tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changes): note the smoother hero fade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(details): season chips up to four, a counts menu from five, no season art

The season picker drops every poster: chips for up to four seasons, a menu
button from five whose rows read the season and "N episodes · M watched".
The season synopsis loses its cover, sits on the number column 24px above
the list and renders nothing without a plot of its own. The player's episode
panel shares the picker and loses its dropdown thumbnails too; its season
strip stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(details): bare rows for seasons without metadata, skeletons while it loads

A season whose final episode data has no plot and no usable still (the
series poster or season cover repeated as a still counts as none) renders
44px rows: number, title, meta and an inline check or progress bar, the same
action slot, no grid toggle. While the provider list or a TMDB lookup that
could still fill a bare-looking season is outstanding, the episodes are
skeleton rows at the full row's exact geometry instead of a spinner; a
season the provider already describes renders at once.

Xtream tracks the show match and each season's enrichment in the store;
Stalker tracks its show match in the selection state and settled season
fetches in its TMDB service, and now reports a regular series' season
request as loading instead of an empty series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(details): loading skeletons laid out like the loaded page

The hero skeleton targeted ngx-skeleton-loader's old `.loader` class, so
its blocks kept the library's light default fill and margins; `display:
block` stacked the chips and buttons into columns; and the details column
sat at the top while the loaded one is bottom-aligned, so the title dropped
~150px on load. It is now plain shimmer blocks at the loaded hero's
geometry with the stage height kept while loading. The Xtream series page
shows an episodes section skeleton under it, and the Stalker series hero
holds the Play button's place while seasons load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): the modeled Stalker series renders bare rows

Its episodes repeat the series poster and carry no plots, so the shared
series surface check now expects 44px bare rows without thumbnails or a
grid toggle in both themes. The thumbnail hairline and grid checks stay in
the Xtream suite, whose episodes have stills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(details): format the season picker stylesheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(details): episode skeletons never outlive their metadata

The loading state added for TMDB-enriched episode rows could stick or
flicker:
- an empty season is never enriched, so its host never settled it and the
  season showed six skeleton rows forever instead of its empty state;
- Xtream re-marked an enriched season pending on every selection write,
  turning rendered rows back into skeletons (and dropping row focus) for
  each cache read;
- a superseded lookup of a reopened series, or of a Stalker item without
  an id, could settle a newer lookup's pending state or never clear it;
- TMDB requests have no timeout, so a blocked TMDB host held provider
  episodes back for minutes.

Metadata now only holds back a season that has episodes, the wait is
capped at 4s per season, only the latest lookup of a key (or selection)
settles it, a settled Xtream season stays settled within a visit and a
new visit starts its seasons afresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(details): described seasons render at once; room for titles on phones

Metadata lookups held every season as skeletons until TMDB answered,
even one the provider had already described with plots and stills. The
state now waits for metadata only when the current data would render
bare; described rows show at once and the metadata lands in place.

At a pane width of 480px or less the list row kept the 112px thumbnail
and the action slot beside the text, leaving a 320px phone about 18px
for the title. The thumbnail shrinks to 96px there and the actions take
their own row under the text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(details): keep phone-width grid cards stacked

The phone-width grid-area placements applied to grid cards too, which
declare no template areas, so a card's artwork and text overlapped in a
narrow pane. The placements are scoped to list rows; the surface check
now also asserts a card's text stays under its artwork at 360px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(details): a still that only one episode carries is not a repeat

The distinct-stills check counted unique image URLs and treated a single
one as the series poster repeated, so a season where one episode has a
genuine still and the rest have none lost that still and went bare,
with its grid toggle. One image on one episode is now a still; only one
image on several episodes counts as a repeat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(details): phone-width skeleton rows match the rows they precede

At a pane width of 480px or less the finished list row uses a 96px
thumbnail and an action row under the text, but its skeleton kept the
112px single-row layout, so pictures and text moved when loading ended.
The skeleton row now carries an empty 34px action slot and mirrors the
phone layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(details): no air date from a placeholder or an impossible day

The Date constructor turned a provider's "0000-00-00" into a day in
1899 and rolled "2025-02-31" into March, so the episode meta line
showed dates nobody sent. An ISO day is now validated part by part and
an invalid one leaves the date out. The season menu's container colour
goes through mat.menu-overrides(), as the UI guidelines require.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* style(details): spinner colours through mat.progress-spinner-overrides()

The episode item set the spinner's indicator colour as raw --mat-*
declarations; the UI guidelines want Material tokens set through the
component's overrides mixin, which rejects unknown names at build time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(details): unknown season counts, resume position, hero-skeleton spec

A lazy Stalker VOD season the portal has not answered yet showed
"0 episodes" in the season menu: the picker now takes the per-season
load states and shows no count for such a season. A started episode
whose duration nobody knows (no provider or TMDB runtime, none saved
with the position) lost its saved position from the meta line; it now
reads "Resume at 12:34". The downloads offline-detail spec queried the
ngx-skeleton-loader the hero skeleton no longer uses; it queries the
hero-skeleton test id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(playback): a reused external player starts each load at its own offset

MPV and VLC are launched with a global --start / --start-time for a
resumed title, and a reused process applies that to every later load:
"Play from beginning", the next episode and a later resume all began
at the first launch's offset. The seek sent right after loadfile does
not help — mpv rejects it before the file is loaded, and the command
sender never reads the reply (verified against a real mpv over IPC).

Every reuse load now carries a per-file start (0 unless an offset is
requested): mpv's loadfile options and VLC's :start-time input option.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(details): loading flags follow the detail container, menus know unloaded seasons

The fullscreen episode picker reports its season through the same
onSeasonSelected as the detail container, so the Stalker view's
episode-list and metadata loading flags followed whichever season was
picked last: choosing another lazy season in fullscreen turned the
detail page's loaded season into skeletons until the portal answered.
Both flags now follow the detail container's own selection.

The fullscreen panel also shares the season picker but never forwarded
its per-season load states, so a lazy season read "0 episodes" in its
menu; the states are forwarded now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(details): Xtream metadata loading follows the detail container too

Like the Stalker view, the Xtream seasons service keyed its metadata
loading flag off the season selected last, which the fullscreen episode
picker also sets: picking another season there could hide the detail
page's settled bare rows behind skeletons for up to four seconds. The
flag now reads the detail container's own selection; the picker's
choice still gets enriched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-10 10:44:05 +02:00
1 parent 2dff91c9f2
commit 50d45bc7c0
119 files changed
+5065 -2341

No files matched your search

+16 -31
View File
@@ -243,7 +243,7 @@ export async function rasterizedBorderContrast(
return ratios[Math.floor(ratios.length / 2)];
}
/** Check the shared series UI under a provider host before testing watched state. */
/** Check the shared series UI under the Stalker host before testing watched state. */
export async function expectSeriesSurfacesInBothThemes(
page: Page,
testInfo: TestInfo
@@ -266,39 +266,24 @@ export async function expectSeriesSurfacesInBothThemes(
)
)
.toBeGreaterThan(1.1);
// The flat card's visible edge is its artwork's hairline.
for (const selector of [
'.episode-card__thumbnail',
'mat-button-toggle-group',
]) {
await expect
.poll(
async () =>
(await surfaceContrast(shell.locator(selector).first()))
.border
)
.toBeGreaterThan(1.15);
}
await page
.getByRole('radio', { name: 'List view', exact: true })
.click();
await expect
.poll(
async () =>
(
await surfaceContrast(
shell.locator('.episode-list-item').first()
)
).border
)
.toBeGreaterThan(1.15);
// The modeled Stalker series carry neither stills (every episode
// repeats the series poster) nor plots: the season renders bare
// 44px rows without thumbnails, and there is no grid form to offer.
const rows = shell.locator('app-episode-item');
await expect(rows.first()).toBeVisible();
expect(await rows.count()).toBeGreaterThan(0);
await expect(
shell.locator('app-episode-item:not(.episode-item--bare)')
).toHaveCount(0);
await expect(shell.locator('.episode-item__thumb')).toHaveCount(0);
await expect(shell.locator('mat-button-toggle-group')).toHaveCount(0);
expect(
Math.round((await rows.first().boundingBox())?.height ?? 0)
).toBe(44);
await shell.screenshot({
path: testInfo.outputPath(`stalker-series-list-${theme}.png`),
path: testInfo.outputPath(`stalker-series-bare-${theme}.png`),
animations: 'disabled',
});
await page
.getByRole('radio', { name: 'Grid view', exact: true })
.click();
}
}
@@ -52,7 +52,7 @@ export async function openEmbeddedSeriesItem(
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
name: `Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
@@ -137,30 +137,30 @@ export async function verifyStalkerSeasonMarkers(
);
await expect(
page.getByRole('heading', {
name: `1. Season ${season} premiere`,
name: `Season ${season} premiere`,
exact: true,
})
).toBeVisible();
await expect(page.getByTestId('season-description')).toContainText(
`Season ${season} overview`
);
await expect(page.locator('.episode-card')).toContainText(
await expect(page.locator('.episode-item')).toContainText(
`Season ${season} episode plot`
);
await expect(page.locator('.episode-card img')).toHaveAttribute(
await expect(page.locator('.episode-item img')).toHaveAttribute(
'src',
new RegExp(`/season-${season}\\.jpg$`)
);
await page.locator('[data-testid="series-more-menu"]').click();
await page.locator('[data-test-id="toggle-season-watched"]').click();
await expect(page.locator('.episode-card--watched')).toHaveCount(1);
await expect(page.locator('.episode-item--watched')).toHaveCount(1);
await page.getByRole('button', { name: 'Back', exact: true }).click();
}
await page.getByText(titles[0], { exact: true }).first().click();
await expect(
page.getByRole('tab', { name: 'Season 2', exact: true })
).toBeVisible();
await expect(page.locator('.episode-card--watched')).toHaveCount(1);
await expect(page.locator('.episode-item--watched')).toHaveCount(1);
expect(seasonRequests).toEqual(expect.arrayContaining([2, 3]));
expect(seasonRequests).not.toContain(1);
expect(searchLanguages).toEqual(expect.arrayContaining(['en-US', 'ru-RU']));
+5 -5
View File
@@ -803,12 +803,12 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
// Snapshot episodes are visible immediately…
await expect(
page.getByRole('heading', { name: '1. Episode 1', exact: true })
page.getByRole('heading', { name: 'Episode 1', exact: true })
).toBeVisible({ timeout: 10_000 });
// …and the newly released episode appears after the background refresh
await expect(
page.getByRole('heading', {
name: `${episodeCount + 1}. Episode ${episodeCount + 1}`,
name: `Episode ${episodeCount + 1}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
@@ -847,7 +847,7 @@ test('@stalker season watched toggle — embedded series marks and clears every
await expect(seasonToggle).toContainText(
`Mark season as watched (${episodeCount})`
);
const watchedCards = page.locator('.episode-card--watched');
const watchedCards = page.locator('.episode-item--watched');
await expect(watchedCards).toHaveCount(0);
await seasonToggle.click();
@@ -857,7 +857,7 @@ test('@stalker season watched toggle — embedded series marks and clears every
await expect(watchedCards).toHaveCount(episodeCount, { timeout: 15_000 });
await expect(
page.locator(
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
'[data-testid="episode-watched-toggle"].episode-item__watched-toggle--watched'
)
).toHaveCount(episodeCount);
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
@@ -904,7 +904,7 @@ test('@stalker series watched toggle — embedded series marks and clears from t
// Every episode of the embedded season flips to watched and the action
// becomes unwatch-all.
const watchedCards = page.locator('.episode-card--watched');
const watchedCards = page.locator('.episode-item--watched');
await expect(watchedCards).toHaveCount(episodeCount, {
timeout: 15_000,
});
@@ -225,7 +225,7 @@ export async function playFirstSeriesEpisode(
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
const episodeCards = page.locator('.episode-card');
const episodeCards = page.locator('.episode-item');
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
await episodeCards.first().click();
+94 -43
View File
@@ -663,9 +663,9 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
await expect(seasonToggle).toContainText('Mark season as watched (8)');
await closeSeriesMenu(page);
const episodeCards = page.locator('.episode-card');
const episodeCards = page.locator('.episode-item');
await expect(episodeCards).toHaveCount(8, { timeout: 10_000 });
const watchedCards = page.locator('.episode-card--watched');
const watchedCards = page.locator('.episode-item--watched');
await expect(watchedCards).toHaveCount(0);
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
@@ -680,7 +680,7 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
await closeSeriesMenu(page);
await expect(
page.locator(
'[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'
'[data-testid="episode-watched-toggle"].episode-item__watched-toggle--watched'
)
).toHaveCount(8);
@@ -738,7 +738,7 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
// cannot fold the column (the app hides a cover whose image failed).
// ---------------------------------------------------------------------------
test('@xtream season cover — shows the provider season cover and follows the selected tab', async ({
test('@xtream season header — chips, no season cover, synopsis on the number column', async ({
page,
request,
}) => {
@@ -780,22 +780,38 @@ test('@xtream season cover — shows the provider season cover and follows the s
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// Season 1 is auto-selected; its provider cover sits beside the tabs.
// The mock seeds season art as `season-<id>-<n>` (cover) and
// `season-big-<id>-<n>` (cover_big); the app prefers cover_big.
const cover = page.locator('[data-testid="season-cover"]');
await expect(cover).toBeVisible({ timeout: 15_000 });
await expect(cover).toHaveAttribute(
'src',
new RegExp(`season(-big)?-${targetSeries.series_id}-1/`)
);
// The mock seeds season art (`season-<id>-<n>`) and a synopsis per
// season. Three seasons → chips; the art is never shown in the header
// or beside the synopsis.
const description = page.getByTestId('season-description');
await expect(description).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.season-tabs__pill')).toHaveCount(3);
await expect(page.getByTestId('season-dropdown')).toHaveCount(0);
await expect(page.locator('[data-testid="season-cover"]')).toHaveCount(0);
await expect(
page.locator('app-season-container img[src*="/season-"]')
).toHaveCount(0);
// Offset to the episode number column, 24px above the list.
const geometry = await description.evaluate((element) => {
const style = getComputedStyle(element);
const list = element.parentElement?.querySelector('.episodes-list');
return {
paddingLeft: style.paddingLeft,
gapToList: list
? list.getBoundingClientRect().top -
element.getBoundingClientRect().bottom
: null,
};
});
expect(geometry.paddingLeft).toBe('52px');
expect(geometry.gapToList).toBeCloseTo(24, 0);
// The cover follows the selected tab.
await page.locator('.season-tabs__pill').nth(1).click();
await expect(cover).toHaveAttribute(
'src',
new RegExp(`season(-big)?-${targetSeries.series_id}-2/`)
);
await expect(description).toBeVisible();
await expect(
page.locator('app-season-container img[src*="/season-"]')
).toHaveCount(0);
});
// ---------------------------------------------------------------------------
@@ -854,7 +870,7 @@ test('@xtream series watched toggle — marks every season from the header menu,
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
timeout: 15_000,
});
await expect(page.locator('.episode-card--watched')).toHaveCount(8);
await expect(page.locator('.episode-item--watched')).toHaveCount(8);
// The action now offers unwatch-all.
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
@@ -878,7 +894,7 @@ test('@xtream series watched toggle — marks every season from the header menu,
await expect(page.locator('.season-tabs__done')).toHaveCount(0, {
timeout: 15_000,
});
await expect(page.locator('.episode-card--watched')).toHaveCount(0);
await expect(page.locator('.episode-item--watched')).toHaveCount(0);
seriesToggle = await seriesMenuRow(page, 'toggle-series-watched');
await expect(seriesToggle).toContainText('Mark series as watched (24)');
});
@@ -999,11 +1015,12 @@ for (const theme of ['light', 'dark']) {
const favorite = shell
.locator('[data-testid="series-favorite-toggle"]')
.first();
const card = shell.locator('.episode-card').first();
// The card is flat; its visible edge is the artwork's hairline.
const artwork = card.locator('.episode-card__thumbnail');
// List is the default view: borderless rows whose visible edge is
// the thumbnail's hairline.
const row = shell.locator('.episode-list-item').first();
const rowArtwork = row.locator('.episode-item__thumb');
const toggle = shell.locator('mat-button-toggle-group');
await expect(card).toBeVisible();
await expect(row).toBeVisible();
await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1');
await page.mouse.move(0, 0);
// A subtle edge must survive compositing on the actual theme surface.
@@ -1011,7 +1028,7 @@ for (const theme of ['light', 'dark']) {
await expect
.poll(() => rasterizedBorderContrast(favorite))
.toBeGreaterThan(1.1);
for (const surface of [artwork, toggle]) {
for (const surface of [rowArtwork, toggle]) {
await expect
.poll(async () => (await surfaceContrast(surface)).border)
.toBeGreaterThan(1.15);
@@ -1021,41 +1038,75 @@ for (const theme of ['light', 'dark']) {
.poll(async () => (await surfaceContrast(selected)).fill)
.toBeGreaterThan(1.1);
await shell.screenshot({
path: testInfo.outputPath(`series-grid-${theme}.png`),
path: testInfo.outputPath(`series-list-${theme}.png`),
animations: 'disabled',
});
// Hover reveals the reserved actions without moving the text.
const title = row.locator('.episode-item__title');
const before = await title.boundingBox();
await row.hover();
await expect(row.locator('.episode-item__actions')).toHaveCSS(
'opacity',
'1'
);
expect(await title.boundingBox()).toEqual(before);
await row.screenshot({
path: testInfo.outputPath(`series-row-hover-${theme}.png`),
animations: 'disabled',
});
await card.hover();
await expect
.poll(async () => (await surfaceContrast(artwork)).border)
.toBeGreaterThan(1.15);
await page
.getByRole('radio', { name: 'List view', exact: true })
.getByRole('radio', { name: 'Grid view', exact: true })
.click();
const row = shell.locator('.episode-list-item').first();
await expect(row).toBeVisible();
const card = shell.locator('.episode-card').first();
const artwork = card.locator('.episode-card__thumbnail');
await expect(card).toBeVisible();
await page.mouse.move(0, 0);
await expect
.poll(async () => (await surfaceContrast(row)).border)
.poll(async () => (await surfaceContrast(artwork)).border)
.toBeGreaterThan(1.15);
await expect
.poll(async () => (await surfaceContrast(selected)).fill)
.toBeGreaterThan(1.1);
await shell.screenshot({
path: testInfo.outputPath(`series-list-${theme}.png`),
path: testInfo.outputPath(`series-grid-${theme}.png`),
animations: 'disabled',
});
await row.hover();
await expect
.poll(async () => (await surfaceContrast(row)).border)
.toBeGreaterThan(1.15);
await page
.getByRole('radio', { name: 'List view', exact: true })
.getByRole('radio', { name: 'Grid view', exact: true })
.focus();
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowRight');
await expect(
page.getByRole('radio', { name: 'Grid view', exact: true })
page.getByRole('radio', { name: 'List view', exact: true })
).toBeChecked();
await expect(card).toBeVisible();
await expect(row).toBeVisible();
// Phone width: the title keeps readable room and the actions move
// under the text instead of squeezing it.
await page.setViewportSize({ width: 360, height: 740 });
await row.hover();
const narrowTitle = await title.boundingBox();
const narrowActions = await row
.locator('.episode-item__actions')
.boundingBox();
expect(narrowTitle?.width ?? 0).toBeGreaterThan(120);
expect(narrowActions?.y ?? 0).toBeGreaterThanOrEqual(
(narrowTitle?.y ?? 0) + (narrowTitle?.height ?? 0)
);
// A grid card at that width keeps its text under the artwork.
await page
.getByRole('radio', { name: 'Grid view', exact: true })
.click();
const narrowCard = shell.locator('.episode-card').first();
const cardArt = await narrowCard
.locator('.episode-item__thumb')
.boundingBox();
const cardTitle = await narrowCard
.locator('.episode-item__title')
.boundingBox();
expect(cardArt?.width ?? 0).toBeGreaterThan(200);
expect(cardTitle?.y ?? 0).toBeGreaterThanOrEqual(
(cardArt?.y ?? 0) + (cardArt?.height ?? 0)
);
});
test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({