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

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

* test(ui): measure detail action edges over rendered artwork
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 00:41:53 +02:00
1 parent eb96b4c9f2
commit 0140146716
9 files changed
+338 -33

No files matched your search

+182 -1
View File
@@ -1,5 +1,11 @@
import type { APIRequestContext, Locator } from '@playwright/test';
import type {
APIRequestContext,
Locator,
Page,
TestInfo,
} from '@playwright/test';
import { expect } from './fixtures';
import sharp from 'sharp';
export async function setInputValue(
input: Locator,
@@ -71,3 +77,178 @@ 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) };
});
}
/** Compare the actual hero edge with the same pixels with just its border hidden.
* Both rasters retain artwork, gradients, opacity and backdrop filters. Matching
* pixels prevent artwork detail from being mistaken for a visible button edge. */
export async function rasterizedBorderContrast(
locator: Locator
): Promise<number> {
const screenshot = () =>
locator.screenshot({ animations: 'disabled', scale: 'css' });
const visible = await screenshot();
const originalStyle = await locator.getAttribute('style');
let hidden: Buffer;
try {
await locator.evaluate((element) => {
const style = (element as HTMLElement).style;
style.setProperty('transition', 'none', 'important');
style.setProperty('border-top-color', 'transparent', 'important');
});
hidden = await screenshot();
} finally {
await locator.evaluate((element, style) => {
if (style === null) element.removeAttribute('style');
else element.setAttribute('style', style);
}, originalStyle);
}
const decode = (buffer: Buffer) =>
sharp(buffer).removeAlpha().raw().toBuffer({ resolveWithObject: true });
const [before, after] = await Promise.all([
decode(visible),
decode(hidden),
]);
expect(after.info).toEqual(before.info);
const luminance = (data: Buffer, x: number, y: number) => {
const offset = (y * before.info.width + x) * before.info.channels;
return [0.2126, 0.7152, 0.0722].reduce((sum, weight, index) => {
const value = data[offset + index] / 255;
return (
sum +
weight *
(value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4)
);
}, 0);
};
const ratios: number[] = [];
// Avoid rounded corners. Inspect three raster rows to tolerate a fractional
// CSS edge, retaining the strongest edge pixel in each sampled column.
for (
let x = Math.ceil(before.info.width / 4);
x < (before.info.width * 3) / 4;
x++
) {
let strongest = 1;
for (let y = 0; y < Math.min(3, before.info.height); y++) {
const a = luminance(before.data, x, y);
const b = luminance(after.data, x, y);
strongest = Math.max(
strongest,
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
);
}
ratios.push(strongest);
}
ratios.sort((a, b) => a - b);
return ratios[Math.floor(ratios.length / 2)];
}
/** 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');
await expect
.poll(() =>
rasterizedBorderContrast(shell.locator('.favorite-btn').first())
)
.toBeGreaterThan(1.1);
for (const selector of ['.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();
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { type APIRequestContext, type Page } from '@playwright/test';
import { setInputValue } from './e2e-helpers';
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
@@ -1072,7 +1072,7 @@ test('@stalker title season markers align lazy VOD labels, TMDB episodes and wat
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.
@@ -1106,11 +1106,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})`
+80 -1
View File
@@ -2,7 +2,12 @@ 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 {
rasterizedBorderContrast,
setInputValue,
surfaceContrast,
waitForScrollIdle,
} from './e2e-helpers';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
@@ -1221,6 +1226,80 @@ 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.
await expect
.poll(() => rasterizedBorderContrast(favorite))
.toBeGreaterThan(1.1);
for (const surface of [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,
}) => {