mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(ui): welcome screens and drop overlay follow the app theme (#1886)
* fix(ui): welcome screens and drop overlay follow the app theme The welcome dashboard, the empty Sources page and the playlist drop overlay switched on `prefers-color-scheme`, so with the app set to dark on a light OS (or light on a dark OS) they painted the other theme's colours. They now read `--app-*` tokens, which follow the `.dark-theme` class set from Settings, and the hard-coded blues are derived from the selection colour (a local "strong" accent mixed toward the heading ink keeps chips and filled labels at 4.5:1 in both themes). White rgba() fills that vanished in the light theme (season empty panel, catalog refinement chips and menu divider, Xtream archive banner and disabled paginator icons, shell download-activity track, search field) now use the widget surface, on-surface mixes or the search tokens. Reads of custom properties that nothing declares are fixed: the release notes error, the search-layout empty state and the collection reload dim now use declared tokens; unset hooks are replaced by their fallback value. New guard `pnpm run styles:theme-references:validate` (CI) rejects undeclared var() reads and prefers-color-scheme outside the settings resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under each explicit app theme and checks colours, contrast and screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): retry the rejected-drop comparison when the card dismisses mid-read The rejection hides itself after 1.8s, and the OS-scheme comparison reads the overlay twice with an emulateMedia call between. A slow runner could lose the card between the reads; the comparison now drops again until both reads see it. A colour that follows the OS still fails every attempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): measure welcome text contrast from rendered pixels The feature and source cards paint gradients, which a backgroundColor walk ignores, so card titles, descriptions and chips could pass while falling short on the actual card. Every text on the three surfaces is now measured against the pixels under it, as the filled button labels were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tools): scan only runtime sources in the theme-references guard The guard read every tracked file under apps/ and libs/ except specs, so an E2E, mock-server or test-helper declaration could satisfy a runtime var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts sets --cover-rail-width), and a test-only read could fail the check. It now skips spec/test/e2e files, test helpers and stubs, testing projects, the E2E and mock-server apps and the marketing website. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(xtream): set live paginator tokens through mat.paginator-overrides The live layout hand-declared --mat-paginator-* tokens, which the UI guidelines route through the overrides mixin so a mistyped name fails the build instead of silently doing nothing. Same values, same output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tools): skip every test-only naming convention in the theme guard The runtime scan still read .stub, .harness, .fixtures, .spec-stubs, .spec-helpers, .spec-fixtures, test-setup and test-double sources, and test-stubs/ or *fixtures/ directories, so a declaration in one could mask an undeclared runtime read. A file is now test-only when a dot segment after its name marks it (spec, stub, fixture, harness, mock, spec-*, test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test directory. Runtime names such as xtream-connection-test.service.ts stay in the scan; no runtime source imports an excluded file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): keep welcome card text legible while hovered At the 16%/10% hover tint the dark theme's body text on a feature card measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E now measures feature and source card text while hovered, in both themes; with the old tint it fails at 4.45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
197fbee781
commit
43358e10f2
32 files changed
+1033
-334
No files matched your search
@@ -0,0 +1,334 @@
|
||||
import type { Locator, Page, TestInfo } from '@playwright/test';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSources,
|
||||
saveSettings,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import { measureBackdropTextContrast } from './theme-contrast';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
const THEMES: readonly Theme[] = ['dark', 'light'];
|
||||
const COLOR_PROPERTIES = [
|
||||
'color',
|
||||
'background-color',
|
||||
'background-image',
|
||||
'border-top-color',
|
||||
'fill',
|
||||
'stroke',
|
||||
'box-shadow',
|
||||
];
|
||||
|
||||
/** Every colour the subtree paints, keyed by element and pseudo-element. */
|
||||
async function paintedColors(scope: Locator): Promise<Record<string, string>> {
|
||||
return scope.evaluate((root, properties) => {
|
||||
const colors: Record<string, string> = {};
|
||||
[root, ...Array.from(root.querySelectorAll('*'))].forEach(
|
||||
(element, index) => {
|
||||
const name = `${index}:${element.tagName.toLowerCase()}.${(
|
||||
element.getAttribute('class') ?? ''
|
||||
)
|
||||
.trim()
|
||||
.replace(/\s+/g, '.')}`;
|
||||
for (const pseudo of ['', '::before']) {
|
||||
const style = getComputedStyle(element, pseudo || null);
|
||||
for (const property of properties) {
|
||||
colors[`${name}${pseudo} ${property}`] =
|
||||
style.getPropertyValue(property);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
return colors;
|
||||
}, COLOR_PROPERTIES);
|
||||
}
|
||||
|
||||
/** Lets entrance fades finish, so contrast reads the settled page. */
|
||||
async function settleAnimations(page: Page): Promise<void> {
|
||||
await page.mouse.move(1, 1);
|
||||
await finishTransitions(page);
|
||||
}
|
||||
|
||||
/** Waits for the finite animations and transitions now running. */
|
||||
async function finishTransitions(page: Page): Promise<void> {
|
||||
await page.evaluate(() =>
|
||||
Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.filter(
|
||||
(animation) =>
|
||||
animation.effect?.getComputedTiming().iterations !==
|
||||
Infinity
|
||||
)
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface Surface {
|
||||
name: string;
|
||||
scope: Locator;
|
||||
/**
|
||||
* Text measured against the rendered pixels under it, so gradient cards,
|
||||
* tinted chips and filled buttons all count.
|
||||
*/
|
||||
texts: Locator[];
|
||||
/** Cards whose hover changes their fill, with the text to re-measure. */
|
||||
hovered?: { card: Locator; texts: Locator[] }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks an explicit app theme in Settings and saves it. A fresh profile
|
||||
* follows the System theme, whose OS listener would rewrite the theme class
|
||||
* as soon as the OS scheme changes.
|
||||
*/
|
||||
async function chooseAppTheme(page: Page, theme: Theme): Promise<void> {
|
||||
await openSettings(page);
|
||||
await page
|
||||
.getByTestId(theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME')
|
||||
.click();
|
||||
await saveSettings(page);
|
||||
await expect(page.locator('body')).toHaveClass(
|
||||
theme === 'dark' ? /dark-theme/ : /^(?!.*dark-theme)/
|
||||
);
|
||||
// The "saved" snackbar would cover the screenshots.
|
||||
await expect(page.locator('mat-snack-bar-container')).toBeHidden({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface takes its colours from the app theme alone: flipping the OS
|
||||
* colour scheme changes nothing, and text stays legible where the app and
|
||||
* the OS disagree. Returns the colours for the cross-theme comparison.
|
||||
*/
|
||||
async function expectFollowsAppTheme(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
theme: Theme,
|
||||
surface: Surface
|
||||
): Promise<Record<string, string>> {
|
||||
const osScheme: Theme = theme === 'dark' ? 'light' : 'dark';
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
const underMatchingOs = await paintedColors(surface.scope);
|
||||
await page.emulateMedia({ colorScheme: osScheme });
|
||||
expect(
|
||||
await paintedColors(surface.scope),
|
||||
`${surface.name}: app ${theme} theme on a ${osScheme} OS`
|
||||
).toEqual(underMatchingOs);
|
||||
|
||||
await settleAnimations(page);
|
||||
const shot = testInfo.outputPath(
|
||||
`${surface.name}-app-${theme}-os-${osScheme}.png`
|
||||
);
|
||||
await page.screenshot({ path: shot });
|
||||
await testInfo.attach(`${surface.name} app ${theme} on ${osScheme} OS`, {
|
||||
path: shot,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
for (const [index, text] of surface.texts.entries()) {
|
||||
expect(
|
||||
await measureBackdropTextContrast(page, text),
|
||||
`${surface.name}: text ${index} in the ${theme} theme`
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
for (const { card, texts } of surface.hovered ?? []) {
|
||||
await card.hover();
|
||||
await finishTransitions(page);
|
||||
for (const [index, text] of texts.entries()) {
|
||||
expect(
|
||||
await measureBackdropTextContrast(page, text),
|
||||
`${surface.name}: hovered card text ${index} in the ${theme} theme`
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
return underMatchingOs;
|
||||
}
|
||||
|
||||
/** Starts (or, with `drop`, finishes) a file drag over the workspace. */
|
||||
async function dragFileOverWorkspace(
|
||||
page: Page,
|
||||
fileName: string,
|
||||
drop = false
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ name, dropFile }) => {
|
||||
const target = document.querySelector('.workspace-shell');
|
||||
if (!target) throw new Error('Workspace drop target not found.');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(new File(['#EXTM3U'], name));
|
||||
const types = dropFile
|
||||
? ['dragenter', 'dragover', 'drop']
|
||||
: ['dragenter', 'dragover'];
|
||||
for (const type of types) {
|
||||
target.dispatchEvent(
|
||||
new DragEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
{ name: fileName, dropFile: drop }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('App theme over the OS colour scheme', () => {
|
||||
test('@theme @electron welcome screens and the drop overlay follow the app theme, not the OS', async ({
|
||||
dataDir,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(150_000);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
const colors: Record<string, Partial<Record<Theme, unknown>>> = {};
|
||||
const record = (name: string, theme: Theme, value: unknown) => {
|
||||
colors[name] = { ...colors[name], [theme]: value };
|
||||
};
|
||||
try {
|
||||
for (const theme of THEMES) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await chooseAppTheme(page, theme);
|
||||
|
||||
await goToDashboard(page);
|
||||
const dashboard = page.locator(
|
||||
'app-empty-state .welcome-dashboard'
|
||||
);
|
||||
await expect(dashboard).toBeVisible();
|
||||
record(
|
||||
'welcome-dashboard',
|
||||
theme,
|
||||
await expectFollowsAppTheme(page, testInfo, theme, {
|
||||
name: 'welcome-dashboard',
|
||||
scope: dashboard,
|
||||
texts: [
|
||||
dashboard.locator('.welcome-dashboard__tagline'),
|
||||
dashboard.locator(
|
||||
'.welcome-dashboard__description'
|
||||
),
|
||||
dashboard.locator('.feature-card__title').first(),
|
||||
dashboard.locator('.feature-card__desc').first(),
|
||||
dashboard.locator(
|
||||
'.welcome-dashboard__cta .mdc-button__label'
|
||||
),
|
||||
],
|
||||
hovered: [
|
||||
{
|
||||
card: dashboard
|
||||
.locator('.feature-card')
|
||||
.first(),
|
||||
texts: [
|
||||
dashboard
|
||||
.locator('.feature-card__title')
|
||||
.first(),
|
||||
dashboard
|
||||
.locator('.feature-card__desc')
|
||||
.first(),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await openSources(page);
|
||||
const sources = page.locator(
|
||||
'app-empty-state .welcome-sources'
|
||||
);
|
||||
await expect(sources).toBeVisible();
|
||||
record(
|
||||
'welcome-sources',
|
||||
theme,
|
||||
await expectFollowsAppTheme(page, testInfo, theme, {
|
||||
name: 'welcome-sources',
|
||||
scope: sources,
|
||||
texts: [
|
||||
sources.locator('.welcome-sources__body'),
|
||||
sources.locator('.source-card__name').first(),
|
||||
sources.locator('.source-card__needs').first(),
|
||||
sources.locator('.source-card__chip').first(),
|
||||
sources
|
||||
.locator('.source-card__cta .mdc-button__label')
|
||||
.first(),
|
||||
],
|
||||
hovered: [
|
||||
{
|
||||
card: sources.locator('.source-card').first(),
|
||||
texts: [
|
||||
sources
|
||||
.locator('.source-card__name')
|
||||
.first(),
|
||||
sources
|
||||
.locator('.source-card__needs')
|
||||
.first(),
|
||||
sources
|
||||
.locator('.source-card__chip')
|
||||
.first(),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const overlay = page.locator('app-playlist-drop-overlay');
|
||||
const card = overlay.locator('.drop-overlay__card');
|
||||
await dragFileOverWorkspace(page, 'channels.m3u');
|
||||
await expect(card).toBeVisible();
|
||||
record(
|
||||
'drop-overlay',
|
||||
theme,
|
||||
await expectFollowsAppTheme(page, testInfo, theme, {
|
||||
name: 'drop-overlay',
|
||||
scope: overlay,
|
||||
texts: [
|
||||
card.locator('.drop-overlay__title'),
|
||||
card.locator('.drop-overlay__body'),
|
||||
],
|
||||
})
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card).toBeHidden();
|
||||
|
||||
// The rejection dismisses itself after 1.8s. Both reads must
|
||||
// see it, so a slow runner that loses it between them drops
|
||||
// again; a colour that follows the OS fails every attempt.
|
||||
await expect(async () => {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await dragFileOverWorkspace(page, 'notes.txt', true);
|
||||
await expect(card).toHaveClass(/is-rejected/, {
|
||||
timeout: 1_000,
|
||||
});
|
||||
const rejected = await paintedColors(overlay);
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme === 'dark' ? 'light' : 'dark',
|
||||
});
|
||||
const underOtherOs = await paintedColors(overlay);
|
||||
expect(
|
||||
await card.getAttribute('class'),
|
||||
'rejection still shown after both reads'
|
||||
).toMatch(/is-rejected/);
|
||||
expect(
|
||||
underOtherOs,
|
||||
`rejected drop: app ${theme} theme`
|
||||
).toEqual(rejected);
|
||||
record('rejected drop', theme, rejected);
|
||||
}).toPass({ timeout: 20_000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card).toBeHidden();
|
||||
}
|
||||
for (const [name, byTheme] of Object.entries(colors)) {
|
||||
expect(byTheme.light, `${name}: light vs dark`).not.toEqual(
|
||||
byTheme.dark
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user