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

@@ -2,6 +2,28 @@
display: block;
width: 100%;
height: 100%;
// Every colour here follows the app theme class through --app-* tokens,
// never the OS colour scheme. The strong accent is the selection colour
// pulled toward the theme's heading ink: deeper in light, lighter in
// dark, so small accent text keeps 4.5:1 on the tinted cards in both
// themes, and so does --app-selection-on-color across the whole fill.
// Declared on the host, inside .dark-theme, so they resolve with that
// theme's values.
--empty-state-accent-strong: color-mix(
in srgb,
var(--app-selection-color) 60%,
var(--app-heading-color)
);
--empty-state-accent-fill: linear-gradient(
135deg,
color-mix(
in srgb,
var(--app-selection-color) 80%,
var(--app-heading-color)
),
var(--empty-state-accent-strong)
);
}
.empty-state {
@@ -21,7 +43,11 @@
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
margin: 0 0 12px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
background: linear-gradient(
135deg,
var(--app-selection-color) 0%,
var(--empty-state-accent-strong) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -36,7 +62,7 @@
font-size: 0.95rem;
line-height: 1.6;
margin: 0 0 32px 0;
opacity: 0.7;
color: var(--app-body-color);
}
}
@@ -55,6 +81,7 @@
.illustration {
width: 120px;
height: 120px;
color: var(--app-body-color);
.search-circle,
.search-handle {
@@ -86,7 +113,7 @@
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
color: var(--app-selection-color);
opacity: 0.85;
}
@@ -101,7 +101,6 @@ const SOURCE_CARDS: readonly SourceCard[] = [
'./empty-state.welcome-dashboard.scss',
'./empty-state.welcome-sources.scss',
'./empty-state.responsive.scss',
'./empty-state.themes.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatIcon, TranslatePipe],
@@ -1,38 +0,0 @@
@media (prefers-color-scheme: light) {
.empty-state {
.title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.description {
opacity: 0.8;
color: #64748b;
}
}
.no-results-state {
.illustration {
opacity: 0.3;
.search-circle,
.search-handle,
.empty-box,
.empty-line {
stroke: #64748b;
}
}
}
.no-data-state {
.empty-icon {
color: #2563eb;
}
.secondary-button {
color: #2563eb;
}
}
}
@@ -14,6 +14,16 @@
animation: fadeIn 0.5s ease-out;
min-width: 0;
min-height: 0;
--welcome-grid-line: color-mix(
in srgb,
var(--app-selection-color) 4%,
transparent
);
--welcome-grid-diagonal: color-mix(
in srgb,
var(--app-selection-color) 2%,
transparent
);
&::before {
content: '';
@@ -24,22 +34,22 @@
0deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
var(--welcome-grid-line) 39px,
var(--welcome-grid-line) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
var(--welcome-grid-line) 39px,
var(--welcome-grid-line) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(59, 130, 246, 0.015) 79px,
rgba(59, 130, 246, 0.015) 81px
var(--welcome-grid-diagonal) 79px,
var(--welcome-grid-diagonal) 81px
);
mask-image: linear-gradient(
to bottom,
@@ -85,30 +95,33 @@
.welcome-dashboard__illustration {
width: clamp(96px, 16vh, 160px);
height: clamp(96px, 16vh, 160px);
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
color: #60a5fa;
filter: drop-shadow(
0 10px 30px
color-mix(in srgb, var(--app-selection-color) 20%, transparent)
);
color: var(--app-selection-color);
display: block;
.icon-background {
fill: #3b82f6;
fill: currentColor;
opacity: 0.15;
}
.monitor-base,
.monitor-stand {
fill: #3b82f6;
fill: var(--empty-state-accent-strong);
}
.screen-border {
stroke: #3b82f6;
stroke: var(--empty-state-accent-strong);
}
.wifi-dot {
fill: #60a5fa;
fill: currentColor;
}
.wifi-arc {
stroke: #60a5fa;
stroke: currentColor;
opacity: 0.7;
&.arc-1 { opacity: 0.9; }
@@ -122,7 +135,11 @@
font-weight: 600;
font-size: clamp(1.4rem, 3.4vh, 2.1rem);
margin: 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
background: linear-gradient(
135deg,
var(--app-selection-color) 0%,
var(--empty-state-accent-strong) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -133,6 +150,7 @@
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.95rem, 1.9vh, 1.05rem);
font-weight: 500;
color: var(--app-heading-color);
opacity: 0.85;
margin: 0;
line-height: 1.45;
@@ -141,7 +159,7 @@
.welcome-dashboard__description {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.8125rem, 1.7vh, 0.9375rem);
opacity: 0.65;
color: var(--app-body-color);
margin: 0;
line-height: 1.5;
max-width: 480px;
@@ -149,13 +167,14 @@
.welcome-dashboard__cta {
margin-top: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
background: var(--empty-state-accent-fill);
color: var(--app-selection-on-color);
font-weight: 500;
font-size: clamp(0.875rem, 1.8vh, 1rem);
padding: 10px 28px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
box-shadow: 0 4px 16px
color-mix(in srgb, var(--app-selection-color) 30%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
@@ -168,7 +187,8 @@
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
box-shadow: 0 6px 24px
color-mix(in srgb, var(--app-selection-color) 40%, transparent);
}
&:active {
@@ -200,10 +220,11 @@
padding: clamp(12px, 2vh, 18px) clamp(10px, 1.5vw, 14px);
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
color-mix(in srgb, var(--app-selection-color) 8%, transparent),
color-mix(in srgb, var(--app-selection-color) 4%, transparent)
);
border: 1px solid rgba(96, 165, 250, 0.18);
border: 1px solid
color-mix(in srgb, var(--app-selection-color) 18%, transparent);
border-radius: 14px;
backdrop-filter: blur(8px);
transform: rotate(-2deg);
@@ -222,17 +243,30 @@
&:hover {
transform: rotate(0deg) translateY(-4px) scale(1.04);
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
box-shadow: 0 10px 24px
color-mix(in srgb, var(--app-selection-color) 22%, transparent);
border-color: color-mix(
in srgb,
var(--app-selection-color) 40%,
transparent
);
// A light step up from the resting 8%/4%: at 16% the dark
// theme's body text fell under 4.5:1 on the hovered card. The
// lift, border and shadow carry the rest of the hover.
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.16),
rgba(37, 99, 235, 0.1)
color-mix(in srgb, var(--app-selection-color) 10%, transparent),
color-mix(in srgb, var(--app-selection-color) 6%, transparent)
);
.feature-card__icon-wrap {
transform: scale(1.08);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.4);
box-shadow: 0 6px 18px
color-mix(
in srgb,
var(--app-selection-color) 40%,
transparent
);
}
}
}
@@ -244,8 +278,9 @@
width: 40px;
height: 40px;
border-radius: 11px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
background: var(--empty-state-accent-fill);
box-shadow: 0 4px 12px
color-mix(in srgb, var(--app-selection-color) 30%, transparent);
transition:
transform 0.25s ease,
box-shadow 0.25s ease;
@@ -256,7 +291,7 @@
font-size: 22px;
width: 22px;
height: 22px;
color: white;
color: var(--app-selection-on-color);
}
.feature-card__title {
@@ -271,7 +306,7 @@
.feature-card__desc {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.78125rem;
opacity: 0.7;
color: var(--app-body-color);
margin: 0;
line-height: 1.35;
word-break: break-word;
@@ -332,91 +367,3 @@
}
}
}
@media (prefers-color-scheme: light) {
.welcome-dashboard {
&::before {
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(37, 99, 235, 0.02) 79px,
rgba(37, 99, 235, 0.02) 81px
);
}
.welcome-dashboard__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-dashboard__tagline {
color: #1e3a8a;
opacity: 0.85;
}
.welcome-dashboard__description {
color: #64748b;
opacity: 0.85;
}
.welcome-dashboard__illustration {
filter: drop-shadow(0 10px 30px rgba(37, 99, 235, 0.15));
color: #3b82f6;
.icon-background {
fill: #2563eb;
opacity: 0.12;
}
.monitor-base,
.monitor-stand {
fill: #2563eb;
}
.screen-border {
stroke: #2563eb;
}
.wifi-dot {
fill: #3b82f6;
}
.wifi-arc {
stroke: #3b82f6;
}
}
.feature-card {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(29, 78, 216, 0.06));
border-color: rgba(37, 99, 235, 0.3);
}
}
.feature-card__desc {
color: #475569;
opacity: 0.85;
}
}
}
@@ -36,7 +36,7 @@
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
color: var(--app-selection-color);
opacity: 0.85;
}
@@ -45,7 +45,11 @@
font-weight: 500;
font-size: 1.5rem;
margin: 0 0 8px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
background: linear-gradient(
135deg,
var(--app-selection-color) 0%,
var(--empty-state-accent-strong) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -55,7 +59,7 @@
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.95rem;
line-height: 1.6;
opacity: 0.7;
color: var(--app-body-color);
margin: 0;
}
@@ -77,10 +81,11 @@
padding: clamp(14px, 2.2vh, 22px);
background: linear-gradient(
145deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
color-mix(in srgb, var(--app-selection-color) 8%, transparent),
color-mix(in srgb, var(--app-selection-color) 4%, transparent)
);
border: 1px solid rgba(96, 165, 250, 0.2);
border: 1px solid
color-mix(in srgb, var(--app-selection-color) 20%, transparent);
border-radius: 16px;
transition:
transform 0.2s ease,
@@ -90,8 +95,13 @@
&:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
box-shadow: 0 12px 28px
color-mix(in srgb, var(--app-selection-color) 22%, transparent);
border-color: color-mix(
in srgb,
var(--app-selection-color) 40%,
transparent
);
}
}
@@ -106,7 +116,7 @@
font-size: 28px;
width: 28px;
height: 28px;
color: #60a5fa;
color: var(--app-selection-color);
flex-shrink: 0;
}
@@ -122,7 +132,7 @@
.source-card__needs {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.8125rem;
opacity: 0.75;
color: var(--app-body-color);
margin: 0;
line-height: 1.45;
min-height: 2.5em;
@@ -144,9 +154,14 @@
font-weight: 500;
padding: 3px 9px;
border-radius: 999px;
background: rgba(96, 165, 250, 0.14);
color: #93c5fd;
border: 1px solid rgba(96, 165, 250, 0.22);
background: color-mix(
in srgb,
var(--app-selection-color) 14%,
transparent
);
color: var(--empty-state-accent-strong);
border: 1px solid
color-mix(in srgb, var(--app-selection-color) 22%, transparent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -154,13 +169,14 @@
.source-card__cta {
margin-top: auto;
align-self: stretch;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
background: var(--empty-state-accent-fill);
color: var(--app-selection-on-color);
font-weight: 500;
font-size: 0.875rem;
padding: 8px 18px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
box-shadow: 0 4px 12px
color-mix(in srgb, var(--app-selection-color) 25%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
@@ -175,7 +191,8 @@
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
box-shadow: 0 6px 18px
color-mix(in srgb, var(--app-selection-color) 35%, transparent);
}
}
}
@@ -212,48 +229,3 @@
}
}
}
@media (prefers-color-scheme: light) {
.welcome-sources {
.welcome-sources__icon {
color: #2563eb;
}
.welcome-sources__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-sources__body {
color: #64748b;
opacity: 0.85;
}
.source-card {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.1), rgba(29, 78, 216, 0.05));
border-color: rgba(37, 99, 235, 0.3);
}
}
.source-card__icon {
color: #2563eb;
}
.source-card__needs {
color: #475569;
opacity: 0.9;
}
.source-card__chip {
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.18);
}
}
}