diff --git a/.changes/ui-light-detail-surfaces.md b/.changes/ui-light-detail-surfaces.md new file mode 100644 index 000000000..4602cd79a --- /dev/null +++ b/.changes/ui-light-detail-surfaces.md @@ -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. diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 2b4045522..6f60b8f7c 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -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 { }) ); } + +/** 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 { + 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 { + // 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(); + } +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 335023919..cf7b71fae 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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})` diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 44fa7e861..0b938a887 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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, }) => { diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a1e1963e3..1e0c02958 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -365,6 +365,25 @@ 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. +Hero action edges use matching pixels from rendered screenshots with the border +visible and transparent, retaining the artwork and gradient behind the button; +flat ancestor-color compositing is only appropriate outside that layered hero. + ## Settings Surfaces Settings use the same system but are flatter than content-heavy views. diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 24e42bc79..cf82ba9a5 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -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); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.scss b/libs/ui/components/src/lib/season-container/season-container.component.scss index fbfc138c1..568daffe9 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.scss +++ b/libs/ui/components/src/lib/season-container/season-container.component.scss @@ -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; diff --git a/libs/ui/components/src/lib/season-container/season-header.component.scss b/libs/ui/components/src/lib/season-container/season-header.component.scss index 99d45b785..2f84ae0fb 100644 --- a/libs/ui/components/src/lib/season-container/season-header.component.scss +++ b/libs/ui/components/src/lib/season-container/season-header.component.scss @@ -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 + ); } } } diff --git a/libs/ui/styles/_detail-view-actions.scss b/libs/ui/styles/_detail-view-actions.scss index 096457523..71cfae339 100644 --- a/libs/ui/styles/_detail-view-actions.scss +++ b/libs/ui/styles/_detail-view-actions.scss @@ -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 + ); } }