fix(ui): restore detail surface boundaries in light theme

This commit is contained in:
4gray committed 2026-09-05 22:17:50 +02:00
1 parent 812ec69fd4
commit 3cdbb2bcff
9 files changed
+261 -32

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: ui
---
Detail buttons, episode cards and episode rows now keep visible backgrounds and borders in the light theme. The grid/list switch also clearly highlights the selected view in both themes.
+113 -1
View File
@@ -1,4 +1,9 @@
import type { APIRequestContext, Locator } from '@playwright/test';
import type {
APIRequestContext,
Locator,
Page,
TestInfo,
} from '@playwright/test';
import { expect } from './fixtures';
export async function setInputValue(
@@ -71,3 +76,110 @@ export async function waitForScrollIdle(scrollOwner: Locator): Promise<void> {
})
);
}
/** Measure flat UI surfaces after alpha compositing, including CSS color-mix. */
export async function surfaceContrast(locator: Locator): Promise<{
border: number;
fill: number;
}> {
return locator.evaluate((element) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
if (!context)
throw new Error('Canvas is required to measure surface contrast');
const paint = (color: string) => {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
};
const luminance = () => {
const channels = Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
});
return (
channels[0] * 0.2126 +
channels[1] * 0.7152 +
channels[2] * 0.0722
);
};
const ratio = (first: number, second: number) =>
(Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
const ancestors: Element[] = [];
for (
let parent = element.parentElement;
parent;
parent = parent.parentElement
) {
ancestors.unshift(parent);
}
paint('white');
ancestors.forEach((parent) =>
paint(getComputedStyle(parent).backgroundColor)
);
const behind = luminance();
const style = getComputedStyle(element);
paint(style.backgroundColor);
const fill = luminance();
paint(
parseFloat(style.borderTopWidth) > 0
? style.borderTopColor
: 'transparent'
);
return { border: ratio(luminance(), fill), fill: ratio(fill, behind) };
});
}
/** Check the shared series UI under a provider host before testing watched state. */
export async function expectSeriesSurfacesInBothThemes(
page: Page,
testInfo: TestInfo
): Promise<void> {
// Shared episode surfaces must also resolve the Stalker host's theme.
await page.setViewportSize({ width: 1600, height: 1100 });
await expect(page.locator('.hero__content')).toHaveCSS('opacity', '1');
for (const theme of ['light', 'dark']) {
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const shell = page.locator('app-portal-detail-shell');
for (const selector of [
'.favorite-btn',
'.episode-card',
'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);
await shell.screenshot({
path: testInfo.outputPath(`stalker-series-list-${theme}.png`),
animations: 'disabled',
});
await page
.getByRole('radio', { name: 'Grid view', exact: true })
.click();
}
}
+5 -5
View File
@@ -2,7 +2,7 @@ import {
type APIRequestContext,
type Page,
} from '@playwright/test';
import { setInputValue } from './e2e-helpers';
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
@@ -1069,7 +1069,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
test('@stalker season watched toggle — embedded series marks and clears every episode', async ({
page,
request,
}) => {
}, testInfo) => {
// Reuse the modeled embedded-series flow: find a VOD item carrying an
// embedded series[] array, open it from its category, and land on the
// series detail with its episode list.
@@ -1103,11 +1103,11 @@ test('@stalker season watched toggle — embedded series marks and clears every
})
).toBeVisible({ timeout: 10_000 });
await expectSeriesSurfacesInBothThemes(page, testInfo);
// The season header's bulk toggle (data-test-id with a dash — getByTestId
// only matches data-testid in this suite) counts every unwatched episode.
const seasonToggle = page.locator(
'[data-test-id="toggle-season-watched"]'
);
const seasonToggle = page.locator('[data-test-id="toggle-season-watched"]');
await expect(seasonToggle).toBeVisible();
await expect(seasonToggle).toContainText(
`Mark season as watched (${episodeCount})`
+76 -1
View File
@@ -2,7 +2,11 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { setInputValue, waitForScrollIdle } from './e2e-helpers';
import {
setInputValue,
surfaceContrast,
waitForScrollIdle,
} from './e2e-helpers';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
@@ -1221,6 +1225,77 @@ test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
});
for (const theme of ['light', 'dark']) {
test(`@xtream detail surfaces stay visible (${theme})`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await addXtreamPortal(page, {
username: 'marketing',
password: 'marketing',
});
await page.getByRole('link', { name: 'Series', exact: true }).click();
await page.locator('app-grid-list mat-card').first().click();
const shell = page.locator('app-portal-detail-shell');
await expect(shell).toBeVisible();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const favorite = shell.locator('.favorite-btn').first();
const card = shell.locator('.episode-card').first();
const toggle = shell.locator('mat-button-toggle-group');
await expect(card).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.
// This catches white-alpha borders that disappear in the light theme.
for (const surface of [favorite, card, toggle]) {
await expect
.poll(async () => (await surfaceContrast(surface)).border)
.toBeGreaterThan(1.15);
}
const selected = toggle.locator('.mat-button-toggle-checked');
await expect
.poll(async () => (await surfaceContrast(selected)).fill)
.toBeGreaterThan(1.1);
await shell.screenshot({
path: testInfo.outputPath(`series-grid-${theme}.png`),
animations: 'disabled',
});
await card.hover();
await expect
.poll(async () => (await surfaceContrast(card)).border)
.toBeGreaterThan(1.15);
await page
.getByRole('radio', { name: 'List view', exact: true })
.click();
const row = shell.locator('.episode-list-item').first();
await expect(row).toBeVisible();
await page.mouse.move(0, 0);
await expect
.poll(async () => (await surfaceContrast(row)).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`),
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 })
.focus();
await page.keyboard.press('ArrowLeft');
await expect(
page.getByRole('radio', { name: 'Grid view', exact: true })
).toBeChecked();
await expect(card).toBeVisible();
});
test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({
page,
}) => {
@@ -365,6 +365,22 @@ Use the shared `nav-list.scss` treatment for sidebar and context-panel list item
If the label is too long for the rail, shorten the label key instead of shrinking the component until it becomes inconsistent.
## Detail Actions And Episode Surfaces
Secondary detail buttons, episode cards and list rows, and the season view
toggle must keep visible edges in both themes before hover. Use app-owned
surface colors and neutral borders derived from `--app-on-surface`; fixed
white-alpha fills and borders disappear over the light detail background.
Grid cards keep the thumbnail and title on one continuous widget surface.
List rows use a subtle neutral fill, with the number on a slightly stronger
inset surface. The checked grid/list toggle uses `--app-selection-surface`
and `--app-selection-color`; hover uses the app's neutral surface treatment.
Keep these treatments in the shared season components and detail-action
partial so Xtream and Stalker share the same behavior.
Browser regression coverage measures the composited neutral edges and selected
toggle fill, in addition to capturing light/dark grid and list screenshots.
## Settings Surfaces
Settings use the same system but are flatter than content-heavy views.
@@ -486,9 +486,7 @@
@include detail-actions.secondary-action-button;
}
// Theme-aware surface for the chip only: the shared mixin's white-alpha
// fill vanishes over a bright light-theme hero, and the chip now sits
// beside icon buttons that already wear the tag vars. The chip's own
// Match the chip to its neighbouring icon buttons' tag surfaces. Its own
// pressed state (.sources-chip--open) is more specific and still wins.
::ng-deep app-vod-sources-chip .sources-chip {
background: var(--tag-bg);
@@ -84,12 +84,12 @@ app-season-tabs {
// Episode Card
.episode-card {
background: rgba(255, 255, 255, 0.03);
background: var(--app-widget-bg);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent);
position: relative;
// In-progress state
@@ -203,8 +203,8 @@ app-season-tabs {
&:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.1);
background: var(--app-card-hover-bg);
border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent);
.episode-card__overlay {
opacity: 1;
@@ -469,15 +469,15 @@ app-season-tabs {
align-items: center;
gap: 16px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
background: color-mix(in srgb, var(--app-on-surface) 3%, transparent);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent);
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
background: var(--app-card-hover-bg);
border-color: color-mix(in srgb, var(--app-on-surface) 22%, transparent);
.episode-list-item__actions {
opacity: 1;
@@ -508,7 +508,7 @@ app-season-tabs {
justify-content: center;
min-width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.06);
background: var(--app-hover-overlay);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
@@ -35,10 +35,15 @@
.view-toggle {
mat-button-toggle-group {
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
background: color-mix(
in srgb,
var(--app-on-surface) 3%,
transparent
);
::ng-deep {
.mat-button-toggle {
@@ -57,15 +62,15 @@
}
&.mat-button-toggle-checked {
background: rgba(255, 255, 255, 0.1);
background: var(--app-selection-surface);
mat-icon {
color: var(--accent-color);
color: var(--app-selection-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: rgba(255, 255, 255, 0.05);
background: var(--app-hover-overlay);
}
}
@@ -77,7 +82,12 @@
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid rgba(255, 255, 255, 0.1);
border-left: 1px solid
color-mix(
in srgb,
var(--app-on-surface) 14%,
transparent
);
}
}
}
+19 -7
View File
@@ -41,16 +41,20 @@
padding: 0 24px;
font-size: 1rem;
font-weight: 600;
background: rgba(255, 255, 255, 0.08);
background: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
color: var(--text-primary, #fff);
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.2);
background: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
border-color: color-mix(
in srgb,
var(--app-on-surface) 22%,
transparent
);
}
mat-icon {
@@ -86,13 +90,21 @@
}
&--downloading {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
background: color-mix(in srgb, var(--app-on-surface) 4%, transparent);
border-color: color-mix(
in srgb,
var(--app-on-surface) 14%,
transparent
);
cursor: not-allowed;
opacity: 0.6;
&:hover {
background: rgba(255, 255, 255, 0.04);
background: color-mix(
in srgb,
var(--app-on-surface) 4%,
transparent
);
}
}