Files
iptvnator/apps/electron-backend-e2e/src/theme-contrast.ts
T
4grayandClaude Opus 5.5 f6ad98255e fix(dashboard): keep the hero height stable across slides (#1855)
* fix(dashboard): keep the hero height stable across slides

The hero laid out only the shown slide, so every automatic rotation
between slides of different heights resized the banner and moved every
rail below it by 7px, every 8 s on an idle dashboard (a layout-shift
score of 0.005 per rotation cycle).

Every slide's content now sits in the same grid cell at the bottom of
the banner. The hero is therefore as tall as its tallest slide whichever
one is shown, and it still grows for an unusually full slide. Inactive
slides are inert and visibility: hidden, and carry no test hooks. Enter
on the hero follows the shown slide's primary action.

The rotation dots keep one fixed width. The active pill is the same
18px bar with its clip-path opened, so a slide change no longer moves
the neighbouring dots or the pill itself.

The legibility E2E now records which nodes shift. It allows under 0.001
in all, with nothing inside the hero moving. It measures 0 on this
branch and fails on master (0.0054).

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

* test(dashboard): guard the hero rotation at the narrow width too

The rotation guard ran only at the wide width, before any slide was
enriched. It now runs a second unattended rotation after the contrast
pass, at the narrow width, where slides wrap the most and every slide
carries a rating and a two-line overview. On master's hero that second
rotation also fails, because its dots move.

measureBackdropTextContrast clipped its screenshot to the range of the
element's line boxes. A title cut by the 2-line clamp has line boxes
below the visible box, so the probe measured the pill row there and
scored about 1.0. The Xtream mock gives the recently-added slide a
different title per run, so this failed whenever a long one came up.
The clip is now the line boxes intersected with the element's own box.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:36:07 +02:00

304 lines
12 KiB
TypeScript

import { expect, Locator, Page } from '@playwright/test';
import sharp = require('sharp');
/** Exercise the same live CSS boundary as SettingsService without navigating
* away from (and therefore destroying) the player being measured. */
export async function applyTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((dark) => {
document.body.classList.toggle('dark-theme', dark);
}, theme === 'dark');
}
/** Contrast for app panels with solid/alpha CSS background colors. Gradients,
* video and Material state layers use the raster check below instead. */
export async function expectTextContrast(
locator: Locator,
minimum = 4.5,
siblingFillSelector?: string
) {
await expect(locator).toBeVisible();
const measure = () =>
locator.evaluate((element, fillSelector) => {
type Color = [number, number, number, number];
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const parse = (css: string): Color => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = css;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
const over = (front: Color, back: Color): Color => {
const alpha = front[3] + back[3] * (1 - front[3]);
return [0, 1, 2]
.map((i) =>
alpha
? (front[i] * front[3] +
back[i] * back[3] * (1 - front[3])) /
alpha
: 0
)
.concat(alpha) as Color;
};
let foreground = parse(getComputedStyle(element).color);
let background: Color = [0, 0, 0, 0];
for (
let node: Element | null = element;
node;
node = node.parentElement
) {
const style = getComputedStyle(node);
// A timeline progress fill is a sibling behind the text, not
// an ancestor. Include it before its owning card background.
const siblingFill = fillSelector
? node.querySelector(`:scope > ${fillSelector}`)
: null;
if (siblingFill) {
const layer = parse(
getComputedStyle(siblingFill).backgroundColor
);
foreground = over(foreground, layer);
background = over(background, layer);
}
const fill = parse(style.backgroundColor);
foreground = over(foreground, fill);
background = over(background, fill);
foreground[3] *= Number(style.opacity);
background[3] *= Number(style.opacity);
}
const white: Color = [255, 255, 255, 1];
const luminance = (color: Color) => {
const rgb = color.slice(0, 3).map((value) => {
const s = value / 255;
return s <= 0.04045
? s / 12.92
: ((s + 0.055) / 1.055) ** 2.4;
});
return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;
};
const a = luminance(over(foreground, white));
const b = luminance(over(background, white));
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}, siblingFillSelector);
await expect
.poll(measure, { message: `Rendered contrast of ${locator}` })
.toBeGreaterThanOrEqual(minimum);
}
export async function expectThemeSurface(
locator: Locator,
theme: 'light' | 'dark'
) {
const brightness = await locator.evaluate((element) => {
const color = getComputedStyle(element).backgroundColor;
const channels =
color
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number) ?? [];
return channels.reduce((sum, value) => sum + value, 0) / 3;
});
if (theme === 'light') expect(brightness).toBeGreaterThan(220);
else expect(brightness).toBeLessThan(70);
}
/** Put a worst-case white video frame below the real shared controls. Rasterize
* the actual icon with its gradient scrim and Material hover/focus layers, so
* losing the scrim cannot pass because a black video ancestor masks the bug. */
export async function expectOverlayContrastOnWhite(
controls: Locator,
button: Locator
) {
await controls.evaluate((element) => {
const backing = document.createElement('div');
backing.dataset['themeContrastBacking'] = '';
Object.assign(backing.style, {
position: 'absolute',
inset: '0',
background: '#fff',
zIndex: '-1',
pointerEvents: 'none',
});
element.append(backing);
});
try {
const icon = button.locator('mat-icon');
const buffer = await icon.screenshot({ animations: 'disabled' });
const { data, info } = await sharp(buffer)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const luminances: number[] = [];
for (let i = 0; i < data.length; i += info.channels) {
const linear = [data[i], data[i + 1], data[i + 2]].map((value) => {
const s = value / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
luminances.push(
linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
);
}
luminances.sort((a, b) => a - b);
// Interior background and filled glyph, excluding antialiased edges.
const background = luminances[Math.floor(luminances.length * 0.1)];
const foreground = luminances[Math.floor(luminances.length * 0.9)];
expect(
(foreground + 0.05) / (background + 0.05)
).toBeGreaterThanOrEqual(3);
} finally {
await controls.evaluate((element) =>
element.querySelector('[data-theme-contrast-backing]')?.remove()
);
}
}
/** Skeletons are decorative, but their faintest gradient stop must remain
* distinguishable from the guide's background in either theme. */
export async function expectSkeletonContrast(
skeleton: Locator,
guide: Locator
) {
const background = await guide.evaluate((element) =>
getComputedStyle(element)
.backgroundColor.match(/[\d.]+/g)!
.slice(0, 3)
.map(Number)
);
const { data, info } = await sharp(
await skeleton.screenshot({ animations: 'disabled' })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const luminance = (rgb: number[]) =>
rgb
.map((value) => {
const s = value / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
const bg = luminance(background);
// Middle row avoids the rounded transparent corners.
for (let x = 8; x < info.width - 8; x++) {
const offset =
(Math.floor(info.height / 2) * info.width + x) * info.channels;
const fg = luminance([
data[offset],
data[offset + 1],
data[offset + 2],
]);
expect(
(Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05)
).toBeGreaterThanOrEqual(1.3);
}
}
/** Contrast of an element's own text against whatever is painted behind it
* (images, gradient scrims, translucent chips), read back from the screen.
* The text is made transparent for the capture, so its `text-shadow` stays
* in the backdrop it is meant to support; its CSS colour (with alpha and
* ancestor opacity) is then composited over every pixel under its line
* boxes. Returns the worst ratio, so one dark patch of artwork under one
* letter fails it. */
export async function measureBackdropTextContrast(
page: Page,
text: Locator
): Promise<number> {
await expect(text).toBeVisible();
const probe = await text.evaluate((element) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = getComputedStyle(element).color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
let alpha = a / 255;
for (
let node: Element | null = element;
node;
node = node.parentElement
) {
alpha *= Number(getComputedStyle(node).opacity);
}
const range = document.createRange();
range.selectNodeContents(element);
// The line boxes, cut to the element's own box: a line-clamped or
// ellipsized text has line boxes past it that never show, and those
// would measure whatever sits there (the next row's pills).
const lines = range.getBoundingClientRect();
const own = element.getBoundingClientRect();
const left = Math.max(lines.left, own.left);
const top = Math.max(lines.top, own.top);
const right = Math.min(lines.right, own.right);
const bottom = Math.min(lines.bottom, own.bottom);
const style = (element as HTMLElement).style;
const previous = style.getPropertyValue('color');
style.setProperty('color', 'transparent', 'important');
(element as HTMLElement).dataset['contrastPrevious'] = previous;
return {
color: [r, g, b, alpha],
// Whole pixels inside the line boxes, clear of glyph edges that
// spill past them.
clip: {
x: Math.ceil(left),
y: Math.ceil(top),
width: Math.max(1, Math.floor(right - left) - 1),
height: Math.max(1, Math.floor(bottom - top) - 1),
},
};
});
try {
const { data, info } = await sharp(
await page.screenshot({ clip: probe.clip })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const [fr, fg, fb, fa] = probe.color;
const luminance = (r: number, g: number, b: number) =>
[r, g, b]
.map((value) => {
const s = value / 255;
return s <= 0.04045
? s / 12.92
: ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
let worst = Infinity;
for (let i = 0; i < data.length; i += info.channels) {
const [br, bg, bb] = [data[i], data[i + 1], data[i + 2]];
const front = luminance(
fr * fa + br * (1 - fa),
fg * fa + bg * (1 - fa),
fb * fa + bb * (1 - fa)
);
const back = luminance(br, bg, bb);
worst = Math.min(
worst,
(Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05)
);
}
return worst;
} finally {
await text.evaluate((element) => {
const style = (element as HTMLElement).style;
const previous = (element as HTMLElement).dataset[
'contrastPrevious'
];
style.removeProperty('color');
if (previous) style.setProperty('color', previous);
delete (element as HTMLElement).dataset['contrastPrevious'];
});
}
}