mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
fix(ui): restore detail surface boundaries in light theme
This commit is contained in:
1 parent
812ec69fd4
commit
3cdbb2bcff
9 files changed
+261
-32
No files matched your search
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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})`
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user