fix(header): gradient height = header container (ends at divider), artist-page parity, action buttons readable with gradient off

The album header gradient was a fixed px that overshot the header/track-list divider on tall (description-bearing) headers; bind its height to the divider's y so it ends exactly at the header container's bottom at any size. Apply the same readable-text + gradient-to-boundary treatment to the artist page. And CircleAction gains an on-surface variant (theme-colored fill/border/icon) driven by the gradient toggle, so when the header gradient is off the action buttons are visible on light themes instead of white-on-white. Below-divider content untouched. Extends #491/#519.

cc
This commit is contained in:
vicrodh committed 2026-07-13 07:50:46 -06:00
1 parent d0a7ded531
commit fa161be525
3 files changed
+100 -19

No files matched your search

+35 -5
View File
@@ -170,6 +170,22 @@ export component AlbumPageView inherits Rectangle {
property <color> hdr-muted: AppearanceState.album-header-gradient
? #ffffff.with-alpha(0.62) : Theme.text-muted;
// With the gradient OFF the header's CircleActions sit on the plain
// theme surface — switch them to the theme-aware on-surface variant so
// they stay visible on light themes (their default palette is white-on-
// dark, tuned for the atmosphere).
property <bool> hdr-on-surface: !AppearanceState.album-header-gradient;
// Atmosphere/gradient extent: the background blocks must end EXACTLY at
// the header/track-list divider — the header height varies (long
// editorial descriptions), so a fixed px either overshoots into the
// track list or fades early. `header-divider.y` is the divider's offset
// within `page`, and the backgrounds share the page's top origin inside
// the Flickable viewport (page.y = its own offset there, normally 0).
// The backgrounds are absolutely positioned and don't participate in
// the page layout, so this cannot form a binding loop.
property <length> atmo-height: page.y + header-divider.y;
background: Theme.surface-main;
flick := Flickable {
@@ -206,7 +222,7 @@ export component AlbumPageView inherits Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 440px;
height: root.atmo-height;
source-image: AlbumState.header-atmosphere;
fallback-image: AlbumState.artwork;
animated: false;
@@ -218,7 +234,7 @@ export component AlbumPageView inherits Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 440px;
height: root.atmo-height;
background: @linear-gradient(180deg,
AlbumState.header-color 0%,
AlbumState.header-color 16%,
@@ -228,10 +244,13 @@ export component AlbumPageView inherits Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 440px;
height: root.atmo-height;
// Fade start sits proportionally near the bottom so the blend
// to surface-main always COMPLETES right at the divider,
// whatever height the header resolved to.
background: @linear-gradient(180deg,
transparent 0%,
transparent 74%,
transparent 82%,
Theme.surface-main 100%);
}
@@ -486,6 +505,7 @@ export component AlbumPageView inherits Rectangle {
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
on-surface: root.hdr-on-surface;
// Spin only while THIS album's first track is fetching.
loading: NowPlayingState.loading
&& NowPlayingState.album-id == AlbumState.id;
@@ -497,6 +517,7 @@ export component AlbumPageView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
on-surface: root.hdr-on-surface;
clicked => {
root.media-action("album", AlbumState.id, "shuffle");
}
@@ -508,6 +529,7 @@ export component AlbumPageView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/pencil.svg");
on-surface: root.hdr-on-surface;
clicked => {
root.media-action("album", AlbumState.id, "edit");
}
@@ -523,6 +545,7 @@ export component AlbumPageView inherits Rectangle {
icon: AlbumState.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
on-surface: root.hdr-on-surface;
active: AlbumState.is-favorite;
loading: AlbumState.favorite-loading;
clicked => {
@@ -534,6 +557,7 @@ export component AlbumPageView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/radio.svg");
on-surface: root.hdr-on-surface;
clicked => {
root.media-action("album", AlbumState.id, "radio");
}
@@ -543,6 +567,7 @@ export component AlbumPageView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/book-open.svg");
on-surface: root.hdr-on-surface;
clicked => {
root.media-action("album", AlbumState.id, "booklet");
}
@@ -552,6 +577,7 @@ export component AlbumPageView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cassette-tape.svg");
on-surface: root.hdr-on-surface;
clicked => {
root.media-action("album", AlbumState.id, "add-to-mixtape");
}
@@ -564,6 +590,7 @@ export component AlbumPageView inherits Rectangle {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/info.svg");
on-surface: root.hdr-on-surface;
clicked => {
root.media-action("album", AlbumState.id, "info");
}
@@ -576,6 +603,7 @@ export component AlbumPageView inherits Rectangle {
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
on-surface: root.hdr-on-surface;
clicked => {
album-menu.show();
}
@@ -600,7 +628,9 @@ export component AlbumPageView inherits Rectangle {
}
Rectangle { height: 20px; }
Rectangle {
// The atmosphere/fade blocks above size themselves to this
// divider (root.atmo-height) so the gradient ends exactly here.
header-divider := Rectangle {
height: 1px;
background: Theme.border-subtle;
}
+38 -6
View File
@@ -112,6 +112,31 @@ export component ArtistPageView inherits Rectangle {
// needs to shrink for the sidebar — it always uses the full
// window width minus the page padding + portrait + header gap.
property <length> content-width: root.width;
// Header text colors (same rule as AlbumPageView): the
// ImmersiveAtmosphere behind the header is always dark, so when the
// gradient setting is ON the header text must be light regardless of
// theme (light themes' Theme.text-* are dark and vanish on the
// atmosphere). With the gradient OFF the header sits on
// Theme.surface-main and keeps the normal theme colors.
property <color> hdr-strong: AppearanceState.album-header-gradient
? #ffffff : Theme.text-primary;
property <color> hdr-body: AppearanceState.album-header-gradient
? #ffffff.with-alpha(0.88) : Theme.text-secondary;
// With the gradient OFF the header's CircleActions sit on the plain
// theme surface — switch them to the theme-aware on-surface variant so
// they stay visible on light themes.
property <bool> hdr-on-surface: !AppearanceState.album-header-gradient;
// Atmosphere/gradient extent: end EXACTLY at the header/content
// boundary — `body-row.y` is where the body content starts within
// `page` (the header height varies with the bio length, so a fixed px
// either overshoots into the content or fades early). The backgrounds
// share the page's top origin inside the Flickable viewport and are
// absolutely positioned (not part of the page layout), so this cannot
// form a binding loop.
property <length> atmo-height: page.y + body-row.y;
// Popular Tracks shows the first 5; Load more expands to all.
property <bool> top-tracks-expanded: false;
property <int> top-tracks-preview: 5;
@@ -181,7 +206,7 @@ export component ArtistPageView inherits Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 360px;
height: root.atmo-height;
source-image: ArtistState.header-atmosphere;
fallback-image: ArtistState.artwork;
animated: false;
@@ -193,7 +218,7 @@ export component ArtistPageView inherits Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 360px;
height: root.atmo-height;
background: @linear-gradient(180deg,
ArtistState.header-color 0%,
ArtistState.header-color 16%,
@@ -203,10 +228,13 @@ export component ArtistPageView inherits Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 360px;
height: root.atmo-height;
// Fade start sits proportionally near the bottom so the blend
// to surface-main always COMPLETES right at the header/content
// boundary, whatever height the header resolved to.
background: @linear-gradient(180deg,
transparent 0%,
transparent 58%,
transparent 82%,
Theme.surface-main 100%);
}
@@ -325,7 +353,7 @@ export component ArtistPageView inherits Rectangle {
Text {
text: ArtistState.name;
color: Theme.text-primary;
color: root.hdr-strong;
font-size: Typography.section;
font-weight: Typography.bold;
overflow: elide;
@@ -341,7 +369,7 @@ export component ArtistPageView inherits Rectangle {
if ArtistState.bio != "": Text {
width: root.content-width - 296px;
text: ArtistState.bio-short;
color: Theme.text-secondary;
color: root.hdr-body;
font-size: Typography.legal;
wrap: word-wrap;
}
@@ -379,6 +407,7 @@ export component ArtistPageView inherits Rectangle {
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
on-surface: root.hdr-on-surface;
active: ArtistState.is-following;
clicked => {
root.media-action("artist", ArtistState.id, "follow");
@@ -392,6 +421,7 @@ export component ArtistPageView inherits Rectangle {
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/radio.svg");
on-surface: root.hdr-on-surface;
clicked => {
radio-popup.show();
}
@@ -424,6 +454,7 @@ export component ArtistPageView inherits Rectangle {
// is open, matching Tauri's `.network-btn.active`.
CircleAction {
icon: @image-url("../assets/icons/element-connect.svg");
on-surface: root.hdr-on-surface;
active: NetworkSidebarState.open;
clicked => {
NetworkSidebarState.open = !NetworkSidebarState.open;
@@ -439,6 +470,7 @@ export component ArtistPageView inherits Rectangle {
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
on-surface: root.hdr-on-surface;
clicked => {
artist-overflow-menu.show();
}
+27 -8
View File
@@ -27,6 +27,14 @@ export component CircleAction inherits Rectangle {
in property <bool> enabled: true;
// Optional hover tooltip (empty = none), using the global TooltipOverlay.
in property <string> tooltip;
// Theme-aware variant for header rows rendered WITHOUT the dark
// atmosphere (album-header-gradient OFF): the default light-on-dark
// palette (white fills/rings/icons) vanishes on light themes' plain
// surface. `on-surface: true` swaps to the app's standard on-page
// circular look (MixView pattern): accent play disc with a white glyph,
// elevated-surface ghost buttons with a themed ring and text-primary
// icon. Additive — every existing caller keeps the overlay palette.
in property <bool> on-surface: false;
callback clicked();
width: root.diameter;
@@ -34,12 +42,18 @@ export component CircleAction inherits Rectangle {
border-radius: root.diameter / 2;
opacity: root.enabled ? 1.0 : 0.4;
// Overlay-matched contrast: solid white for the primary play, semi-
// transparent white fill + bright 1.5px ring otherwise.
background: root.primary
? (ta.has-hover ? #ffffffd6 : #ffffff)
: ((ta.has-hover || root.active) ? #ffffff3d : #ffffff24);
// transparent white fill + bright 1.5px ring otherwise. The on-surface
// variant mirrors the theme-aware circular buttons used on plain page
// backgrounds instead.
background: root.on-surface
? (root.primary
? (ta.has-hover ? Theme.accent-hover : Theme.accent)
: ((ta.has-hover || root.active) ? Theme.surface-hover : Theme.surface-elevated))
: (root.primary
? (ta.has-hover ? #ffffffd6 : #ffffff)
: ((ta.has-hover || root.active) ? #ffffff3d : #ffffff24));
border-width: root.primary ? 0px : 1.5px;
border-color: #ffffffcc;
border-color: root.on-surface ? Theme.border-strong : #ffffffcc;
if !root.loading: QbzIcon {
source: root.icon;
@@ -51,13 +65,18 @@ export component CircleAction inherits Rectangle {
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? Theme.accent
: (root.primary ? #000000 : #ffffff);
: root.on-surface
? (root.primary ? #ffffff : Theme.text-primary)
: (root.primary ? #000000 : #ffffff);
}
if root.loading: LoadingSpinner {
size: root.diameter * 0.45;
// Dark on the solid white primary disc, white on the ghost variant.
tint: root.primary ? #000000 : #ffffff;
// Dark on the solid white primary disc, white on the ghost variant;
// themed tints for the on-surface variant.
tint: root.primary
? (root.on-surface ? #ffffff : #000000)
: (root.on-surface ? Theme.text-primary : #ffffff);
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}