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:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:22:23 +02:00
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);
}
});
});