diff --git a/crates/qbz-qt/qml/LoginScreen.qml b/crates/qbz-qt/qml/LoginScreen.qml
index ec5d75ac3..754319069 100644
--- a/crates/qbz-qt/qml/LoginScreen.qml
+++ b/crates/qbz-qt/qml/LoginScreen.qml
@@ -106,7 +106,16 @@ Rectangle {
name: "check"
width: 12
height: 12
- tintName: "primary"
+ // On the accent fill -> the on-accent selector, same
+ // as the real controls/QbzCheckbox.qml this row
+ // hand-copies (primitives/QbzCheckbox.slint:24 says
+ // accent-text, and the selector returns accent-text
+ // on 34 of the 35 palettes; it only overrides where
+ // accent-text drops under 3:1 — rose-pine-dawn,
+ // 2.56:1). The hand-copy has to track the real
+ // control or the TOS check and the Settings checks
+ // diverge on exactly that theme.
+ tintName: theme.accentGlyphTint
}
MouseArea {
anchors.fill: parent
@@ -151,7 +160,12 @@ Rectangle {
Text {
anchors.centerIn: parent
text: QbzSession.tr("Sign in with your browser", QbzSession.trRev)
- color: theme.accentText
+ // On the accent fill (accentHover under the cursor). NOT
+ // a departure from primitives/QbzPrimaryButton.slint:36
+ // (`Theme.accent-text`), which is what the twin returns on
+ // 34 of the 35 palettes — a floor for rose-pine-dawn only
+ // (accent-text #575279 on accent #d7827e is 2.56:1).
+ color: theme.accentGlyphColor
font.pixelSize: theme.fontButton
font.weight: theme.weightSemibold
}
diff --git a/crates/qbz-qt/qml/assets/icons/accent/mp3.svg b/crates/qbz-qt/qml/assets/icons/accent/mp3.svg
new file mode 100644
index 000000000..41cf5e207
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/mp3.svg
@@ -0,0 +1,2 @@
+
+MP3
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/black/cd.svg b/crates/qbz-qt/qml/assets/icons/black/cd.svg
index d34d68005..73c45a78c 100644
--- a/crates/qbz-qt/qml/assets/icons/black/cd.svg
+++ b/crates/qbz-qt/qml/assets/icons/black/cd.svg
@@ -1,7 +1,7 @@
diff --git a/crates/qbz-qt/qml/assets/icons/black/mp3.svg b/crates/qbz-qt/qml/assets/icons/black/mp3.svg
new file mode 100644
index 000000000..0fa6e5de9
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/mp3.svg
@@ -0,0 +1,2 @@
+
+MP3
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/black/translate.svg b/crates/qbz-qt/qml/assets/icons/black/translate.svg
new file mode 100644
index 000000000..318510725
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/translate.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/mp3.svg b/crates/qbz-qt/qml/assets/icons/secondary/mp3.svg
new file mode 100644
index 000000000..2ae1ba0ee
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/mp3.svg
@@ -0,0 +1,2 @@
+
+MP3
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/warning/mp3.svg b/crates/qbz-qt/qml/assets/icons/warning/mp3.svg
new file mode 100644
index 000000000..16984b643
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/mp3.svg
@@ -0,0 +1,2 @@
+
+MP3
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/warning/translate.svg b/crates/qbz-qt/qml/assets/icons/warning/translate.svg
new file mode 100644
index 000000000..c257dc1b3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/translate.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml
index 3c6a24775..9e92a2cc0 100644
--- a/crates/qbz-qt/qml/cards/AlbumCard.qml
+++ b/crates/qbz-qt/qml/cards/AlbumCard.qml
@@ -228,7 +228,8 @@ Rectangle {
width: 14
height: 14
anchors.centerIn: parent
- tintName: root.isPinned ? "accent" : "primary"
+ // On a #99000000/#cc000000 scrim — dark under every theme.
+ tintName: root.isPinned ? "accent" : "white"
}
MouseArea {
id: pinArea
@@ -339,7 +340,8 @@ Rectangle {
width: 14
height: 14
anchors.centerIn: parent
- tintName: root.source === "plex" ? "accent" : "primary"
+ // On the #b3000000 badge — dark under every theme.
+ tintName: root.source === "plex" ? "accent" : "white"
}
}
}
diff --git a/crates/qbz-qt/qml/cards/ArtistCard.qml b/crates/qbz-qt/qml/cards/ArtistCard.qml
index 8198bf3aa..13dcac38e 100644
--- a/crates/qbz-qt/qml/cards/ArtistCard.qml
+++ b/crates/qbz-qt/qml/cards/ArtistCard.qml
@@ -179,7 +179,8 @@ Rectangle {
width: 14
height: 14
anchors.centerIn: parent
- tintName: root.isPinned ? "accent" : "primary"
+ // On a #99000000/#cc000000 scrim — dark under every theme.
+ tintName: root.isPinned ? "accent" : "white"
}
MouseArea {
id: pinArea
diff --git a/crates/qbz-qt/qml/cards/LabelCard.qml b/crates/qbz-qt/qml/cards/LabelCard.qml
index ae6f81bca..f9f2ce2b5 100644
--- a/crates/qbz-qt/qml/cards/LabelCard.qml
+++ b/crates/qbz-qt/qml/cards/LabelCard.qml
@@ -111,7 +111,9 @@ Rectangle {
width: 56
height: 56
anchors.centerIn: parent
- tintName: "primary"
+ // On the #6366f1 -> #8b5cf6 gradient disc, which is a
+ // fixed brand colour, not a theme surface.
+ tintName: "white"
}
}
diff --git a/crates/qbz-qt/qml/cards/PlaylistCard.qml b/crates/qbz-qt/qml/cards/PlaylistCard.qml
index d073e53cd..14816d240 100644
--- a/crates/qbz-qt/qml/cards/PlaylistCard.qml
+++ b/crates/qbz-qt/qml/cards/PlaylistCard.qml
@@ -176,7 +176,8 @@ Rectangle {
width: 14
height: 14
anchors.centerIn: parent
- tintName: root.isPinned ? "accent" : "primary"
+ // On a #99000000/#cc000000 scrim — dark under every theme.
+ tintName: root.isPinned ? "accent" : "white"
}
MouseArea {
id: pinArea
diff --git a/crates/qbz-qt/qml/cards/TrackCard.qml b/crates/qbz-qt/qml/cards/TrackCard.qml
index dfa76c0d5..798fbb828 100644
--- a/crates/qbz-qt/qml/cards/TrackCard.qml
+++ b/crates/qbz-qt/qml/cards/TrackCard.qml
@@ -118,7 +118,8 @@ Rectangle {
width: 14
height: 14
anchors.centerIn: parent
- tintName: root.item.source === "plex" ? "accent" : "primary"
+ // On the #b3000000 badge — dark under every theme.
+ tintName: root.item.source === "plex" ? "accent" : "white"
}
}
CardMenu {
diff --git a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml
index 0795e487f..9beeb734a 100644
--- a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml
+++ b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml
@@ -55,7 +55,17 @@ Rectangle {
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
- tintName: root.active ? "primary" : "secondary"
+ // Both halves of this pill take the SAME decision now. They did
+ // not before: the glyph said "primary" (the legacy alias of a
+ // literal #ffffff) while the label below said accent-text, so on
+ // every pale-accent theme the pill drew a black label next to a
+ // white glyph. discover/BrowseHeaderTools.slint:123 and :132 are
+ // at least self-consistent — they are BOTH #ffffff — but that
+ // white measures 1.70:1 on high-contrast, 1.74 on ikari and
+ // 1.82 on wcag-dark, under 2.6:1 on 16 of the 35 palettes, so
+ // the port diverges from both lines at once, by measurement.
+ // See theme/QbzTheme.qml, "ON AN ACCENT FILL".
+ tintName: root.active ? theme.accentGlyphTint : "secondary"
}
Text {
text: root.count === 0
@@ -63,7 +73,10 @@ Rectangle {
: root.count === 1
? QbzSession.tr("1 genre", QbzSession.trRev)
: QbzSession.tr("{} genres", QbzSession.trRev).replace("{}", root.count)
- color: root.active ? theme.accentText : theme.textSecondary
+ // The colour twin of the glyph's tint (see above) — accent-text
+ // on 34 of the 35 palettes, so this is a no-op everywhere except
+ // where accent-text itself falls under 3:1.
+ color: root.active ? theme.accentGlyphColor : theme.textSecondary
font.pixelSize: 13
font.weight: root.active ? theme.weightMedium : theme.weightRegular
anchors.verticalCenter: parent.verticalCenter
diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml
index 0162b697c..23abb8fa6 100644
--- a/crates/qbz-qt/qml/controls/CardMenu.qml
+++ b/crates/qbz-qt/qml/controls/CardMenu.qml
@@ -73,7 +73,10 @@ QbzContextMenu {
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
- tintName: row.hot ? "primary" : "secondary"
+ // Host is `surfaceHover`/transparent over a theme surface,
+ // so the glyph tracks the sibling label (textPrimary/
+ // textSecondary below) rather than a fixed white.
+ tintName: row.hot ? "textPrimary" : "secondary"
}
Text {
height: parent.height
diff --git a/crates/qbz-qt/qml/controls/CardOverlayButton.qml b/crates/qbz-qt/qml/controls/CardOverlayButton.qml
index 634c0cf3c..0ba5ebd4f 100644
--- a/crates/qbz-qt/qml/controls/CardOverlayButton.qml
+++ b/crates/qbz-qt/qml/controls/CardOverlayButton.qml
@@ -30,7 +30,11 @@ Rectangle {
width: primary ? 18 : 16
height: primary ? 18 : 16
anchors.centerIn: parent
- tintName: parent.active ? "accent" : (parent.primary ? "black" : "primary")
+ // Both non-accent arms are theme-INDEPENDENT by design: this button
+ // only ever sits on artwork. `primary` is a solid #ffffff disc (black
+ // glyph), the ghost arm is #24ffffff over the artwork itself (white
+ // glyph). "white" is the explicit spelling of what "primary" baked.
+ tintName: parent.active ? "accent" : (parent.primary ? "black" : "white")
}
property alias hovered: obArea.containsMouse
MouseArea {
diff --git a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml
index 0b9007ad8..9434f4c63 100644
--- a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml
+++ b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml
@@ -115,7 +115,7 @@ Item {
name: "x"
width: 17
height: 17
- tintName: cfgCloseArea.containsMouse ? "primary" : "muted"
+ tintName: cfgCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: cfgCloseArea
diff --git a/crates/qbz-qt/qml/controls/GenreFilterPopup.qml b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml
index adb893604..c9491bf71 100644
--- a/crates/qbz-qt/qml/controls/GenreFilterPopup.qml
+++ b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml
@@ -156,7 +156,7 @@ Item {
name: "x"
width: 15
height: 15
- tintName: closeArea.containsMouse ? "primary" : "muted"
+ tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
@@ -341,7 +341,15 @@ Item {
name: "check"
width: 11
height: 11
- tintName: "primary"
+ // DELIBERATE DIVERGENCE from
+ // discover/GenreFilterPopup.slint:85,
+ // which hardcodes `tint: #ffffff`
+ // on this accent box — 1.70:1 on
+ // high-contrast, 1.74 on ikari,
+ // 16 of the 35 palettes under
+ // 2.6:1. theme/QbzTheme.qml, "ON
+ // AN ACCENT FILL".
+ tintName: theme.accentGlyphTint
}
}
@@ -410,7 +418,16 @@ Item {
width: parent.width - 24
height: parent.height
text: chip.modelData.name
- color: chip.modelData.selected ? theme.accentText : theme.textSecondary
+ // On the accent fill -> the measured on-accent
+ // colour. GenreFilterPopup.slint:125 hardcodes
+ // #ffffff (same defect as :85 above); the port
+ // had already moved to accent-text, and
+ // `accentGlyphColor` IS accent-text on 34 of
+ // the 35 palettes — it only differs where
+ // accent-text itself drops under 3:1
+ // (rose-pine-dawn, 2.56:1).
+ color: chip.modelData.selected
+ ? theme.accentGlyphColor : theme.textSecondary
font.pixelSize: 13
font.weight: chip.modelData.selected ? theme.weightMedium : theme.weightRegular
verticalAlignment: Text.AlignVCenter
@@ -421,7 +438,21 @@ Item {
width: 16
height: 16
radius: 8
- color: chip.modelData.selected ? theme.accentText : "transparent"
+ // INVERTED host: this 16px disc is the chip's
+ // FOREGROUND punched out of the accent fill,
+ // and the check inside it is tinted `accent`.
+ // So the pair that has to clear the floor is
+ // (accent glyph, this fill) — the same two
+ // colours as everywhere else on this pill,
+ // just swapped. Painting it with
+ // `accentGlyphColor` rather than raw
+ // accent-text is what keeps that true:
+ // identical on 34 palettes, and on
+ // rose-pine-dawn it turns a 2.56:1 check into
+ // 7.38:1. GenreFilterPopup.slint:141 paints
+ // this disc #ffffff.
+ color: chip.modelData.selected
+ ? theme.accentGlyphColor : "transparent"
border.width: chip.modelData.selected ? 0 : 2
border.color: theme.borderMuted
QbzIcon {
diff --git a/crates/qbz-qt/qml/controls/QbzCheckbox.qml b/crates/qbz-qt/qml/controls/QbzCheckbox.qml
index 1a34038d5..910aa6349 100644
--- a/crates/qbz-qt/qml/controls/QbzCheckbox.qml
+++ b/crates/qbz-qt/qml/controls/QbzCheckbox.qml
@@ -1,5 +1,5 @@
// QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill
-// with a black check when on, 1.5px muted border when off. Emits toggled();
+// with an accent-text check when on, muted border when off. Emits toggled();
// like the Slint original it never self-flips (the owner of the state does).
//
// Used by Settings > Local Library (folder selection) and the Plex library
@@ -27,7 +27,18 @@ Rectangle {
anchors.centerIn: parent
visible: root.checked
name: "check"
- tintName: "black"
+ // The glyph sits ON the accent fill. primitives/QbzCheckbox.slint:24
+ // says `tint: Theme.accent-text` and this was 1:1 with it; it now
+ // goes through the selector, which RETURNS accent-text on 34 of the
+ // 35 palettes — so this is not a divergence from :24 so much as a
+ // floor under it. The one row it changes is rose-pine-dawn, where
+ // accent-text #575279 on accent #d7827e is 2.56:1, under the 3:1
+ // non-text floor, and the selector hands back black at 7.38:1.
+ // Routing every on-accent glyph through one property is also what
+ // stops this control and views/local/SelectCheck.qml from drifting
+ // apart again — upstream contradicts itself between the two
+ // (SelectionCheckbox.slint:37 hardcodes #ffffff for the same job).
+ tintName: theme.accentGlyphTint
width: 12
height: 12
}
diff --git a/crates/qbz-qt/qml/controls/QbzCircleAction.qml b/crates/qbz-qt/qml/controls/QbzCircleAction.qml
index fadb55dd4..d2474992a 100644
--- a/crates/qbz-qt/qml/controls/QbzCircleAction.qml
+++ b/crates/qbz-qt/qml/controls/QbzCircleAction.qml
@@ -29,17 +29,32 @@
// `Theme.text-primary` (CircleAction.slint:73) — the theme's max-contrast
// colour, DARK on a light theme.
//
-// The port could not express that, and this is where it was still broken:
-// icon tints here are PRE-BAKED SVG variants (QbzIcon.qml), and the
-// "primary" bake is a hardcoded `fill="#ffffff"` — it is NOT text-primary,
-// it is white. So the on-surface secondary (a `surface-elevated` disc, i.e.
-// near-white on a light theme) was painting a WHITE glyph on it: the exact
-// vanishing act the .slint arm was written to stop. `onSurfaceTint` below
-// resolves text-primary to the only baked dark variant ("black") whenever
-// the active theme is light — theme.isDark is Rust-published from the
-// surface-main luminance (theme_qt.rs:221), the same luminance test Slint's
-// `Theme.is-dark` uses. The OVERLAY arm keeps its literals: it sits on the
-// artwork band, which is dark under every theme.
+// The port could not express that at first: icon tints were PRE-BAKED SVG
+// variants only, and the "primary" bake is a hardcoded `fill="#ffffff"` — it
+// is NOT text-primary, it is white. So the on-surface secondary (a
+// `surface-elevated` disc, i.e. near-white on a light theme) painted a WHITE
+// glyph on it: the exact vanishing act the .slint arm was written to stop.
+// The stopgap was `onSurfaceTint = theme.isDark ? "primary" : "black"`, a
+// 2-value approximation of text-primary.
+//
+// It is no longer needed. QbzIcon resolves theme-following tints through
+// src/icon_tint_qt.rs, which bakes the live theme's ACTUAL text-primary hex,
+// so this arm now says "textPrimary" and is exact under all 36 themes.
+//
+// The OVERLAY arm keeps its literals: it sits on hosts that are
+// theme-independent — the artwork band, the solid white disc — and Slint
+// hardcodes them for the same reason (CircleAction.slint:70-74, `#ffffff` /
+// `#000000` literals next to `Theme.text-primary`). It spells them
+// "white"/"black" now, not "primary", so no one re-reads the legacy alias as
+// the theme token.
+//
+// The on-surface PRIMARY glyph USED to be in that group and is not any more.
+// Its host is NOT theme-independent — it is `theme.accent` — and the fixed
+// #ffffff CircleAction.slint:72 puts on it is unreadable on 16 of the 35
+// shipped palettes (worst: high-contrast 1.70:1, then ikari 1.74, wcag-dark
+// 1.82). It now takes `theme.accentGlyphTint`, the measured
+// on-accent selector; see the note at the tint binding and the long rationale
+// in theme/QbzTheme.qml.
import QtQuick
import com.blitzfc.qbz
@@ -58,10 +73,9 @@ Rectangle {
QbzTheme { id: theme }
- /// Slint's `Theme.text-primary` glyph tint, expressed in baked variants:
- /// the "primary" bake is a literal #ffffff, so a light theme has to take
- /// the "black" bake or the glyph paints white-on-near-white.
- readonly property string onSurfaceTint: theme.isDark ? "primary" : "black"
+ /// Slint's `Theme.text-primary` glyph tint (CircleAction.slint:73),
+ /// runtime-tinted so it is the real token under every theme.
+ readonly property string onSurfaceTint: "textPrimary"
width: primary ? 44 : 32
height: primary ? 44 : 32
@@ -80,19 +94,43 @@ Rectangle {
name: root.name
width: root.primary ? 19 : 15
height: root.primary ? 19 : 15
- anchors.centerIn: parent
- // CircleAction.slint:70-74, verbatim:
+ // Whole-pixel centring, both axes — CircleAction.slint:67-68 rounds
+ // them explicitly for the same reason: 44-19 and 32-15 are both ODD,
+ // so `anchors.centerIn` lands the glyph on 12.5px / 8.5px and the
+ // software renderer resamples it soft, right next to the now
+ // pixel-crisp 28px QbzNavButton chevrons.
+ x: Math.round((parent.width - width) / 2)
+ y: Math.round((parent.height - height) / 2)
+ // CircleAction.slint:70-74:
// active -> Theme.accent
- // on-surface + primary -> #ffffff (glyph on the accent disc)
+ // on-surface + primary -> #ffffff <- DELIBERATE DIVERGENCE, see
+ // below; this port uses the measured
+ // on-accent selector instead
// on-surface + secondary -> Theme.text-primary <- the light-theme
- // case; "primary" would be white here
+ // case; a fixed white would vanish here
// overlay + primary -> #000000 (glyph on the white disc)
// overlay + secondary -> #ffffff
+ //
+ // The ONE divergence is the on-surface PRIMARY glyph. Its host is the
+ // 44px `theme.accent` disc (`color:` above, accentHover on hover —
+ // which is why the selector folds both fills in) and CircleAction.slint:72
+ // hardcodes #ffffff on it — which measures 1.70:1 on high-contrast,
+ // 1.74 on ikari, 1.82 on wcag-dark, 1.85 on kurosaki and so on for
+ // 16 of the 35 shipped palettes (the two accessibility themes lead
+ // that list, which is the strongest part of the argument).
+ // `theme.accentGlyphTint` is that decision made against the
+ // live accent (QbzTheme.qml, "ON AN ACCENT FILL"); it still resolves
+ // to a white glyph on Dark / Light / OLED, so the default themes'
+ // play button is unchanged.
+ //
+ // The OVERLAY arm keeps its literals: those hosts (the solid white
+ // disc, the artwork band) are theme-independent, so there is nothing
+ // to measure against and nothing to diverge from.
tintName: root.active
? "accent"
: (root.overlay
- ? (root.primary ? "black" : "primary")
- : (root.primary ? "primary" : root.onSurfaceTint))
+ ? (root.primary ? "black" : "white")
+ : (root.primary ? theme.accentGlyphTint : root.onSurfaceTint))
}
MouseArea {
id: cbArea
diff --git a/crates/qbz-qt/qml/controls/QbzIconButton.qml b/crates/qbz-qt/qml/controls/QbzIconButton.qml
index 1f537b92e..517259c25 100644
--- a/crates/qbz-qt/qml/controls/QbzIconButton.qml
+++ b/crates/qbz-qt/qml/controls/QbzIconButton.qml
@@ -6,16 +6,19 @@
// `active` (accent glyph), `activeBackground` (HeaderBar: elevated fill
// when active), `btnEnabled` (dims 0.3 + muted glyph, disarms).
//
-// LIGHT-THEME CORRECTNESS: the glyph tints are PRE-BAKED SVG colours
-// (assets/icons//) — "primary" is literally #ffffff and "secondary"
-// #cccccc, i.e. the DARK theme's text-primary / text-secondary frozen into
-// the asset. On a light theme those render near-white on a light surface and
-// the whole button set disappears, which is the reported bug. The bake is
-// therefore chosen from the LIVE token's lightness instead of being
-// hardcoded: bright token -> the light bake, dark token -> the dark bake.
-// TODO(glue): the durable home for this is a QbzTheme.tintFor(token) helper
-// (or real runtime tinting in QbzIcon) — see the report; it is duplicated in
-// FavToggle / TransportControls / SongCard until then.
+// LIGHT-THEME CORRECTNESS: this button's fills are THEME surfaces
+// (surface-hover / surface-elevated / transparent), so its glyphs must be
+// theme tokens, never fixed colours.
+//
+// This used to pick between two FIXED bakes by reading the live token's
+// lightness (`textPrimary.hslLightness > 0.5 ? "primary" : "black"`) — a
+// 2-value approximation of a token, from the era when assets/icons//
+// was all there was and "primary" was literally #ffffff. That era is over:
+// QbzIcon now resolves theme-following tints through src/icon_tint_qt.rs,
+// which bakes the REAL hex of the live theme's token, so "textPrimary" and
+// "secondary" are exact under all 36 themes and the ternary is dead weight.
+// The old TODO asking for a QbzTheme.tintFor(token) helper is closed by the
+// same change — there is nothing left to hoist.
import QtQuick
import com.blitzfc.qbz
@@ -33,12 +36,10 @@ Rectangle {
QbzTheme { id: theme }
- // Bake that actually matches Theme.text-primary / text-secondary in the
- // ACTIVE theme (BarControls.slint IconButton: hover -> text-primary,
- // idle -> text-secondary). text-muted maps to the "muted" bake in both
- // polarities: #888888 is mid-grey and reads on light and dark alike.
- readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
- readonly property string tintWeak: theme.textSecondary.hslLightness > 0.5 ? "secondary" : "muted"
+ // BarControls.slint IconButton: hover -> text-primary, idle ->
+ // text-secondary. Both are runtime-tinted, so they ARE those tokens.
+ readonly property string tintStrong: "textPrimary"
+ readonly property string tintWeak: "secondary"
width: btnSize
height: btnSize
diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml
index abe937c2d..bbbdfa35c 100644
--- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml
+++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml
@@ -91,7 +91,7 @@ Rectangle {
width: root.sm ? 14 : 16
height: root.sm ? 14 : 16
anchors.centerIn: parent
- tintName: openArea.containsMouse ? "primary" : "muted"
+ tintName: openArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: openArea
@@ -199,7 +199,7 @@ Rectangle {
width: 12
height: 12
anchors.centerIn: parent
- tintName: clearArea.containsMouse ? "primary" : "muted"
+ tintName: clearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: clearArea
diff --git a/crates/qbz-qt/qml/controls/QbzNavButton.qml b/crates/qbz-qt/qml/controls/QbzNavButton.qml
index b296492a3..5a31550b8 100644
--- a/crates/qbz-qt/qml/controls/QbzNavButton.qml
+++ b/crates/qbz-qt/qml/controls/QbzNavButton.qml
@@ -2,6 +2,82 @@
// consolidated in phase 22 from THREE identical copies (HomeView.NavBtn,
// SectionRail.RailNavBtn, HeaderBar.HeaderNavBtn). Contract: icon `name`,
// `btnEnabled` (dims to 0.4 + muted glyph, disarms), `clicked()`.
+//
+// Geometry and states are 1:1 with BOTH Slint originals: 28x28, r14,
+// surface-elevated idle / surface-hover on hover, opacity 0.4 disabled, glyph
+// 15x15 at a WHOLE-PIXEL offset (28-15 = 13 -> centerIn would land on 6.5px
+// and render blurry; Slint rounds deliberately, HeaderBar.slint:329-330).
+//
+// The IDLE FILL is where the two originals part company, and the difference
+// is not cosmetic drift:
+// - HeaderNavBtn (shell/HeaderBar.slint:312-342) has two arms, hover and
+// idle. Its host — the header chrome bar — is ALREADY the translucent
+// surface under the ambient background (HeaderBar.qml: surfaceCardA50),
+// so the disc stays opaque and reads as a solid control on frosted
+// chrome.
+// - The six carousel NavButtons (discover/Carousel.slint:15-45, and the
+// byte-identical copies in ArtistCarousel / PlaylistCarousel /
+// RadioCarousel / SlimCarousel / PinnedCarousel) have a THIRD arm: with
+// `ShellState.app-background-active` they idle at
+// `Theme.surface-elevated.with-alpha(AppearanceState
+// .app-background-surface-alpha)`, so the artwork wash reads THROUGH the
+// chevrons instead of leaving opaque discs pasted on it. They sit on the
+// frosted content panel, not on chrome.
+// This file carries that arm behind `ambient` (see below), defaulting OFF —
+// which is the header's behaviour, i.e. the shared default is the SAFE one.
+//
+// WHERE THIS COMPONENT IS MOUNTED — all of it, because the previous version
+// of this header named only the two carousel sites and read as if they were
+// the whole story. Seven files, thirteen instances, and the ambient decision
+// is per HOST SURFACE, not per file:
+//
+// CONTENT PANEL (translucent under the wash) — arm ON:
+// controls/QbzSectionHeader.qml:76,82 the carousel page chevrons.
+// WIRED. Those two are the six Slint carousel navigators (that header
+// is a POC consolidation of Home / SectionRail / Search / Label
+// carousel headers, and upstream all four import the carousel
+// components), so `ambient: true` there is 1:1, not a deviation.
+//
+// CHROME (opaque control on the frosted chrome bar) — arm OFF:
+// shell/HeaderBar.qml:230,235,241 panel-left + back + forward.
+// Host is HeaderBar's own root, `ambientOn ? surfaceCardA50 :
+// surfaceCard` (HeaderBar.qml:40) — the bar is what goes translucent,
+// the discs on it stay solid. Exactly HeaderNavBtn.
+//
+// CONTENT PANEL BY HOST, ARM DELIBERATELY OFF — upstream contradicts
+// itself and the divergence is not this round's to take:
+// views/local/LocalChrome.qml:43,53 settings gear + Plex re-sync
+// views/local/LocalToolbar.qml:187,201 the two square-check-big
+// multi-select toggles
+// All four render inside AppShell's contentFrame, which IS the frosted
+// panel (AppShell.qml:149, surface-main @0.22 + hairline), and their
+// Slint root goes fully transparent under the wash
+// (locallibrary/LocalLibraryView.slint:705) so that panel shows
+// through. By host surface these are content panel, same as the
+// carousels. But their upstream counterpart is `CircleIconBtn`
+// (LocalLibraryView.slint:313-321, used at :731 :742 :915 :931) and it
+// has only TWO arms — `ta.has-hover ? surface-hover : surface-elevated`
+// — i.e. Slint leaves opaque discs on its own translucent panel here
+// while giving the carousels the third arm on the same kind of
+// surface. Wiring these four is a one-word edit and probably the
+// correct look; it is also an unapproved app-wide visual divergence,
+// so it stops here with the measurement attached. Owner: `ambient:
+// true` on those four lines is the whole change.
+// views/LocalAlbumView.qml:164,169 the in-page back/forward pair.
+// Content panel by host, and there is NO upstream to be 1:1 with:
+// album/LocalAlbumView.slint:205-208 puts `NavButtons { }` there, and
+// shell/NavButtons.slint is a zero-size placeholder since the real
+// back/forward moved into the header. Slint draws nothing at all at
+// this spot. Left OFF to match the identical HeaderBar pair it
+// duplicates rather than the carousels it sits beside — flag, not
+// conclusion.
+//
+// The glyph tint is `text-primary` / `text-muted`, NOT a fixed colour: the
+// disc is a THEME surface, so on the 11 light themes a literal #ffffff glyph
+// is 1.13-1.38:1 against it (APCA Lc 0.0-12.3 — no contrast at all). That was
+// the owner-reported bug: this file asked for "primary", which in this port is
+// the LEGACY ALIAS OF LITERAL WHITE and not Theme.text-primary (see
+// theme/QbzIcon.qml and src/icon_tint_qt.rs `token_for`).
import QtQuick
import com.blitzfc.qbz
@@ -10,21 +86,43 @@ import "../theme"
Rectangle {
property string name: ""
property bool btnEnabled: true
+ /// Carousel arm (Carousel.slint:25-27): let the ambient wash through the
+ /// idle disc. OFF by default because the header must NOT do this.
+ property bool ambient: false
signal clicked()
QbzTheme { id: theme }
+ // `ShellState.app-background-active` (state.slint:4184) — the port spells
+ // it this way app-wide, npHasTrack included: with nothing playing the
+ // shell restores its opaque theme surfaces (rule D4).
+ readonly property bool ambientOn:
+ ambient && QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
+
width: 28
height: 28
radius: 14
opacity: btnEnabled ? 1.0 : 0.4
- color: (nbArea.containsMouse && btnEnabled) ? theme.surfaceHover : theme.surfaceElevated
+ // surface-elevated, at app-background-surface-alpha when the wash is up.
+ // `with-alpha` on an opaque token is a plain alpha set, and
+ // QbzShell.ambientSurfaceAlpha IS AppearanceState.app-background-surface
+ // -alpha — same QBZ_BG_SURFACE_ALPHA knob, same 0.5 default
+ // (settings_qt.rs:211 / state.slint:3374).
+ color: (nbArea.containsMouse && btnEnabled)
+ ? theme.surfaceHover
+ : (ambientOn
+ ? Qt.rgba(theme.surfaceElevated.r, theme.surfaceElevated.g,
+ theme.surfaceElevated.b, QbzShell.ambientSurfaceAlpha)
+ : theme.surfaceElevated)
QbzIcon {
name: parent.name
width: 15
height: 15
- anchors.centerIn: parent
- tintName: parent.btnEnabled ? "primary" : "muted"
+ // Whole-pixel centring (Slint rounds this explicitly; 28-15 = 13, so
+ // `anchors.centerIn` would sit at 6.5px and blur the glyph).
+ x: Math.round((parent.width - width) / 2)
+ y: Math.round((parent.height - height) / 2)
+ tintName: parent.btnEnabled ? "textPrimary" : "muted"
}
MouseArea {
id: nbArea
diff --git a/crates/qbz-qt/qml/controls/QbzSectionHeader.qml b/crates/qbz-qt/qml/controls/QbzSectionHeader.qml
index fadf7b64b..1de7a18ff 100644
--- a/crates/qbz-qt/qml/controls/QbzSectionHeader.qml
+++ b/crates/qbz-qt/qml/controls/QbzSectionHeader.qml
@@ -73,15 +73,33 @@ Item {
onClicked: parent.parent.parent.viewAllClicked()
}
}
+ // `ambient: true` on BOTH: these two are the carousel page chevrons,
+ // and all six Slint carousels give them the third idle-fill arm —
+ // Carousel / ArtistCarousel / PlaylistCarousel / RadioCarousel /
+ // SlimCarousel / PinnedCarousel each declare a local `component
+ // NavButton` at :11-:15 with the identical three-arm `background`
+ // (verified: same `app-background-active` ->
+ // `surface-elevated.with-alpha(app-background-surface-alpha)` line in
+ // all six). Hardcoded rather than exposed as a property because every
+ // consumer of this header is one of those carousels: HomeView,
+ // SectionRail, SearchView and LabelView all render inside AppShell's
+ // frosted contentFrame (AppShell.qml:149, surface-main @0.22), and
+ // upstream HomeView.slint / SearchResultsView.slint /
+ // LabelPageView.slint import the carousel components literally. A
+ // section header on OPAQUE chrome would need the arm off — there is
+ // no such host today, and adding one means adding the property then,
+ // not carrying a dead knob now.
QbzNavButton {
visible: parent.parent.showChevrons
name: "chevron-left"
+ ambient: true
btnEnabled: parent.parent.leftEnabled
onClicked: parent.parent.pageLeft()
}
QbzNavButton {
visible: parent.parent.showChevrons
name: "chevron-right"
+ ambient: true
btnEnabled: parent.parent.rightEnabled
onClicked: parent.parent.pageRight()
}
diff --git a/crates/qbz-qt/qml/controls/QbzToggleButton.qml b/crates/qbz-qt/qml/controls/QbzToggleButton.qml
index 09705b776..a747c3ce3 100644
--- a/crates/qbz-qt/qml/controls/QbzToggleButton.qml
+++ b/crates/qbz-qt/qml/controls/QbzToggleButton.qml
@@ -11,11 +11,11 @@
//
// NOT QbzToggle.qml — that one is the on/off SWITCH (checked/toggled(bool)).
//
-// LIGHT-THEME CORRECTNESS: icon tints are PRE-BAKED SVG colours
-// (assets/icons//), and the "primary" bake is literally #ffffff. On a
-// light theme the active glyph would paint white on a near-white fill, so the
-// bake is chosen from the LIVE token's lightness — the same rule
-// QbzIconButton.qml and QbzCircleAction.qml already carry.
+// LIGHT-THEME CORRECTNESS: the active fill is `surface-hover`, a THEME
+// surface, so the active glyph is `text-primary` — a runtime-tinted token
+// (src/icon_tint_qt.rs), not the fixed #ffffff the "primary" bake is. The
+// old lightness ternary that picked between two fixed bakes is gone; the
+// tinter reproduces the live token exactly.
import QtQuick
import com.blitzfc.qbz
@@ -33,7 +33,7 @@ Rectangle {
QbzTheme { id: theme }
- readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
+ readonly property string tintStrong: "textPrimary"
width: sm ? 30 : 34
height: sm ? 30 : 34
diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml
index bc14e7ef6..14c248a8f 100644
--- a/crates/qbz-qt/qml/rows/TrackRow.qml
+++ b/crates/qbz-qt/qml/rows/TrackRow.qml
@@ -129,11 +129,11 @@ Rectangle {
? theme.alphaTier(10) : (theme.isDark ? "#1affffff" : "#1a000000")
readonly property color playCellRing: theme.alphaTiers.length > 0
? theme.alphaTier(15) : (theme.isDark ? "#26ffffff" : "#26000000")
- /// Slint's `Theme.text-primary` icon tint in baked-variant terms: the
- /// "primary" bake is a literal #ffffff (QbzIcon.qml), so a light theme
- /// has to take "black" or the glyph is white-on-white. Also drives the
- /// hover tints of the row's trailing controls, which had the same hole.
- readonly property string playGlyphTint: theme.isDark ? "primary" : "black"
+ /// Slint's `Theme.text-primary` icon tint, runtime-tinted (QbzIcon.qml /
+ /// src/icon_tint_qt.rs) so it is the live token under all 36 themes. Every
+ /// host it lands on is a THEME surface — the play cell's polarity-baked
+ /// alpha disc, and `surface-elevated` for the row's trailing controls.
+ readonly property string playGlyphTint: "textPrimary"
/// TrackRow.slint:123-125 — the row hover is `Theme.alpha-8`, not
/// surface-hover, and the .slint says why: "Hover uses the polarity-baked
/// alpha ramp so the hover state is visible on light themes too". The
@@ -191,11 +191,10 @@ Rectangle {
//
// The port had the pre-fix shape: a hardcoded `#3dffffff` disc, no
// ring, and the "primary" icon bake — which is a literal #ffffff, not
- // text-primary (QbzIcon.qml bakes tints into the SVG). Both halves are
- // now the polarity-baked ramp: theme.alphaTier() is white-based on
- // dark themes and black-based on light (qbz-theme colors.rs:125-129,
- // republished per theme by theme_qt.rs), and `playGlyphTint` resolves
- // text-primary to the only baked dark variant when the theme is light.
+ // text-primary. Both halves are now theme-correct: theme.alphaTier()
+ // is white-based on dark themes and black-based on light (qbz-theme
+ // colors.rs:125-129, republished per theme by theme_qt.rs), and
+ // `playGlyphTint` is the real text-primary token, runtime-tinted.
//
// Numbers off the .slint: 24px disc, 1.5px ring ("a 1px stroked circle
// aliases badly in femtovg", :215), 16px glyph.
diff --git a/crates/qbz-qt/qml/shell/CastPicker.qml b/crates/qbz-qt/qml/shell/CastPicker.qml
index ad3943c7d..02bdd7ef1 100644
--- a/crates/qbz-qt/qml/shell/CastPicker.qml
+++ b/crates/qbz-qt/qml/shell/CastPicker.qml
@@ -532,7 +532,15 @@ Popup {
spacing: 6
Text {
text: tabRoot.label
- color: tabRoot.selected ? theme.accentText : theme.textPrimary
+ // Selected -> this text sits ON `theme.accent`, so it takes
+ // the on-accent selector like every other accent-filled
+ // control (theme/QbzTheme.qml, "ON AN ACCENT FILL"). This was
+ // the LAST accent fill in the tree still reading raw
+ // accent-text, i.e. the one place the app contradicted its
+ // own rule. shell/CastPicker.slint:86 is a hardcoded #ffffff
+ // — 1.70:1 on high-contrast — so this diverges from upstream
+ // by measurement, same as the rest.
+ color: tabRoot.selected ? theme.accentGlyphColor : theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: tabRoot.selected ? theme.weightSemibold : Font.Normal
anchors.verticalCenter: parent.verticalCenter
@@ -540,7 +548,24 @@ Popup {
Text {
visible: tabRoot.count > 0
text: tabRoot.count
- color: tabRoot.selected ? theme.accentText : theme.textMuted
+ // Same selector as the label beside it — the two halves of a
+ // tab cannot disagree. This was the worst on-accent number
+ // left anywhere: raw accent-text at 80% composites to
+ // #715c7a on rose-pine-dawn's #d7827e, i.e. 2.10:1; through
+ // the selector it composites to #2b1a19 and 5.84:1.
+ color: tabRoot.selected ? theme.accentGlyphColor : theme.textMuted
+ // The 80% is NOT ours to drop: CastPicker.slint:93 spells the
+ // count `#ffffffcc`, and that de-emphasis against the
+ // full-strength label is the tab's whole visual hierarchy.
+ // It does cost contrast, and the residue is stated rather
+ // than hidden: on breeze-light the selector returns #ffffff
+ // (its own accent-text), which at 80% over accent #1d99f3
+ // composites to #d2ebfd = 2.47:1, under the 3:1 floor. That
+ // is the ALPHA's doing, not the colour's — the same label at
+ // full strength is 3.04:1 and clears. Fixing it means either
+ // dropping upstream's alpha or giving de-emphasised
+ // on-accent text its own selector; both are owner calls, and
+ // neither is this round's approved deviation.
opacity: tabRoot.selected ? 0.8 : 1.0
font.pixelSize: theme.fontLegal
anchors.verticalCenter: parent.verticalCenter
diff --git a/crates/qbz-qt/qml/shell/FavToggle.qml b/crates/qbz-qt/qml/shell/FavToggle.qml
index 69b31cd96..815e338cb 100644
--- a/crates/qbz-qt/qml/shell/FavToggle.qml
+++ b/crates/qbz-qt/qml/shell/FavToggle.qml
@@ -18,11 +18,12 @@ Rectangle {
QbzTheme { id: theme }
- // Bakes matching the LIVE tokens (TransportControls.slint's fav-ta:
- // hover -> Theme.text-primary, idle -> Theme.text-secondary). Hardcoded
- // bake names are dark-theme-only colours — see QbzIconButton's header.
- readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
- readonly property string tintWeak: theme.textSecondary.hslLightness > 0.5 ? "secondary" : "muted"
+ // TransportControls.slint's fav-ta: hover -> Theme.text-primary, idle ->
+ // Theme.text-secondary. Both are runtime-tinted (see QbzIconButton's
+ // header), so they ARE those tokens; the fill under them is
+ // `surface-hover`/transparent, a theme surface.
+ readonly property string tintStrong: "textPrimary"
+ readonly property string tintWeak: "secondary"
width: 32
height: 32
diff --git a/crates/qbz-qt/qml/shell/LyricsPanel.qml b/crates/qbz-qt/qml/shell/LyricsPanel.qml
index a4568a568..eb1e16c52 100644
--- a/crates/qbz-qt/qml/shell/LyricsPanel.qml
+++ b/crates/qbz-qt/qml/shell/LyricsPanel.qml
@@ -115,7 +115,7 @@ Rectangle {
name: "sliders-horizontal"
width: 16
height: 16
- tintName: dsArea.containsMouse ? "primary" : "secondary"
+ tintName: dsArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: dsArea
@@ -139,7 +139,7 @@ Rectangle {
name: "x"
width: 15
height: 15
- tintName: closeArea.containsMouse ? "primary" : "secondary"
+ tintName: closeArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: closeArea
@@ -285,7 +285,7 @@ Rectangle {
width: 20
height: 20
tintName: QbzLyrics.showTranslation
- ? "accent" : (trArea.containsMouse ? "primary" : "muted")
+ ? "accent" : (trArea.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: trArea
diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml
index abda146b4..52f007d2e 100644
--- a/crates/qbz-qt/qml/shell/QueuePanel.qml
+++ b/crates/qbz-qt/qml/shell/QueuePanel.qml
@@ -294,7 +294,7 @@ Rectangle {
name: "ellipsis"
width: 16
height: 16
- tintName: menuArea.containsMouse ? "primary" : "muted"
+ tintName: menuArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: menuArea
@@ -516,7 +516,7 @@ Rectangle {
width: 15
height: 15
anchors.centerIn: parent
- tintName: closeArea.containsMouse ? "primary" : "secondary"
+ tintName: closeArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: closeArea
@@ -631,7 +631,7 @@ Rectangle {
name: root.currentRow && root.currentRow.isFavorite ? "heart-filled" : "heart"
width: 17
height: 17
- tintName: root.currentRow && root.currentRow.isFavorite ? "favorite" : (npFavArea.containsMouse ? "primary" : "muted")
+ tintName: root.currentRow && root.currentRow.isFavorite ? "favorite" : (npFavArea.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: npFavArea
diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml
index 871b259a3..eee6d6c2d 100644
--- a/crates/qbz-qt/qml/shell/Sidebar.qml
+++ b/crates/qbz-qt/qml/shell/Sidebar.qml
@@ -413,7 +413,7 @@ Rectangle {
width: 16
height: 16
anchors.centerIn: parent
- tintName: navRow.lit ? "primary" : "secondary"
+ tintName: navRow.lit ? "textPrimary" : "secondary"
}
}
Text {
@@ -552,7 +552,7 @@ Rectangle {
width: 15
height: 15
anchors.centerIn: parent
- tintName: (searchBtnArea.containsMouse || root.searchOpen) ? "primary" : "muted"
+ tintName: (searchBtnArea.containsMouse || root.searchOpen) ? "textPrimary" : "muted"
}
MouseArea {
id: searchBtnArea
@@ -583,7 +583,7 @@ Rectangle {
width: 16
height: 16
anchors.centerIn: parent
- tintName: plusArea.containsMouse ? "primary" : "muted"
+ tintName: plusArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: plusArea
@@ -604,7 +604,7 @@ Rectangle {
width: 15
height: 15
anchors.centerIn: parent
- tintName: sortBtnArea.containsMouse ? "primary" : "muted"
+ tintName: sortBtnArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: sortBtnArea
@@ -746,7 +746,7 @@ Rectangle {
width: 15
height: 15
anchors.centerIn: parent
- tintName: collapseArea.containsMouse ? "primary" : "muted"
+ tintName: collapseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: collapseArea
@@ -910,7 +910,7 @@ Rectangle {
height: 15
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
- tintName: (rowArea.containsMouse || isActive) ? "primary" : "muted"
+ tintName: (rowArea.containsMouse || isActive) ? "textPrimary" : "muted"
}
}
// Name (hidden in mini). A Row is a positioner
@@ -962,7 +962,7 @@ Rectangle {
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
- tintName: rowArea.containsMouse ? "primary" : "muted"
+ tintName: rowArea.containsMouse ? "textPrimary" : "muted"
}
}
// Active-row redundant shape cue: 3px accent bar.
diff --git a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml
index d2e728786..b4b149687 100644
--- a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml
+++ b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml
@@ -146,7 +146,9 @@ Item {
name: root.bandOn ? "eye" : "eye-off"
width: 15
height: 15
- tintName: "primary"
+ // On the #80000000 / #2effffff chip over the artwork —
+ // theme-independent, so a fixed white glyph is correct.
+ tintName: "white"
}
MouseArea {
id: eyeHover
diff --git a/crates/qbz-qt/qml/shell/TrackInfoBody.qml b/crates/qbz-qt/qml/shell/TrackInfoBody.qml
index 8ea7f7aa6..9bcea3a3c 100644
--- a/crates/qbz-qt/qml/shell/TrackInfoBody.qml
+++ b/crates/qbz-qt/qml/shell/TrackInfoBody.qml
@@ -136,9 +136,9 @@ Column {
}
// Close X — the Slint is a bare 32x32 touch area with an 18px
- // glyph, muted -> primary, with NO hover fill; QbzIconButton
- // always paints one and idles at `secondary`, so this one stays
- // hand-rolled to keep the port 1:1.
+ // glyph, text-muted -> text-primary, with NO hover fill;
+ // QbzIconButton always paints one and idles at `secondary`, so
+ // this one stays hand-rolled to keep the port 1:1.
Item {
width: 32
height: 32
@@ -149,7 +149,7 @@ Column {
width: 18
height: 18
anchors.centerIn: parent
- tintName: closeArea.containsMouse ? "primary" : "muted"
+ tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
diff --git a/crates/qbz-qt/qml/shell/TransportControls.qml b/crates/qbz-qt/qml/shell/TransportControls.qml
index b4d245fdc..71ff5f3e5 100644
--- a/crates/qbz-qt/qml/shell/TransportControls.qml
+++ b/crates/qbz-qt/qml/shell/TransportControls.qml
@@ -32,14 +32,22 @@ Row {
QbzTheme { id: theme }
- // Bakes matching the LIVE tokens (see QbzIconButton's header: the icon
- // tints are frozen SVG colours, so a hardcoded name is a dark-theme-only
- // colour). `tintOnAccent` is the glyph sitting ON the filled play disc:
- // the disc is Theme.accent, so its glyph is Theme.accent-text — which is
- // near-white on some themes and near-black on others (qbz-theme picks it
- // for worst-case contrast against the accent), never a fixed "black".
- readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
- readonly property string tintOnAccent: theme.accentText.hslLightness > 0.5 ? "primary" : "black"
+ // Both are runtime-tinted tokens now (see QbzIconButton's header), so they
+ // are the live colours rather than a two-value approximation of them.
+ //
+ // `tintOnAccent` is the glyph sitting ON the filled play disc, whose fill
+ // is theme.accent (theme.accentHover under the cursor). It used to name
+ // `accentText` directly; it goes through the shared on-accent selector
+ // now. That is NOT a colour change on 34 of the 35 palettes — the
+ // selector's first arm IS accent-text — the point is that PLAY, the
+ // checkboxes, the genre pills and the circle actions all take one
+ // decision. They did not before: CircleAction.slint:72 hardcodes #ffffff
+ // for the same job while QbzCheckbox.slint:24 uses accent-text, and the
+ // port inherited both spellings. See theme/QbzTheme.qml, "ON AN ACCENT
+ // FILL", for why accent-text alone is not enough (rose-pine-dawn: 2.56:1)
+ // and why the hover fill does not get a property of its own.
+ readonly property string tintStrong: "textPrimary"
+ readonly property string tintOnAccent: theme.accentGlyphTint
spacing: 2
height: 44
diff --git a/crates/qbz-qt/qml/theme/QbzIcon.qml b/crates/qbz-qt/qml/theme/QbzIcon.qml
index 17e0dd4bd..1314570f7 100644
--- a/crates/qbz-qt/qml/theme/QbzIcon.qml
+++ b/crates/qbz-qt/qml/theme/QbzIcon.qml
@@ -43,10 +43,17 @@
// "white" a light glyph on a host that is dark under EVERY theme:
// artwork scrims (#a6000000 / #b3000000), gradient discs,
// the close button's #e81123 hover
-// "primary" LEGACY ALIAS OF "white". It has always been a literal
-// #ffffff and ~14 call sites depend on that; it is NOT
-// Theme.text-primary. New code says "white" when it means
-// white and "textPrimary" when it means the theme token.
+// "primary" LEGACY ALIAS OF "white" — a literal #ffffff, NOT
+// Theme.text-primary. The app-wide migration is done: the
+// fixed-host call sites now say "white" and the ones that
+// always meant the theme token say "textPrimary". Only two
+// call sites still spell it "primary"
+// (controls/BrowseGenreButton.qml, views/LibraryView.qml —
+// the glyph on an accent fill, where Slint hardcodes
+// #ffffff but the sibling LABEL uses Theme.accent-text, so
+// the intent is genuinely ambiguous and is the owner's
+// call). The alias stays so those two keep working; do not
+// add new ones.
// "black" a dark glyph on a host painted light under every theme
// "amber" #e0b341, the audio stamp's brand accent (one icon)
//
@@ -66,15 +73,36 @@
// resolves LAZILY and would otherwise take every icon in the app down with
// it). It also falls back per-icon on Image.Error, so a name that exists in
// the qrc set but not in the runtime masters degrades to the old fixed colour
-// instead of rendering nothing.
+// instead of rendering nothing. Verified against src/icon_tint_qt.rs: every
+// one of those paths is a real `QString::default()` return (`tint_root()`
+// None, `bake()` false, empty/tokenless theme_json), plus one more that is
+// not on the list — `prune()` sweeps the OLDEST directories past
+// MAX_TINT_DIRS = 32, and a dir reused from a previous session keeps its old
+// mtime because `bake()` early-returns on the `.complete` stamp, so the
+// CURRENT theme's dir can be deleted under the running app and every Image
+// 404s into `qrcFallback`. The floor is not a formality; it is reachable.
+//
+// AND THE FLOOR HAS TO CARRY THE POLARITY. Mapping a theme-following tint to
+// a fixed dir is only harmless while the runtime bake is alive: send
+// "textPrimary" to primary/ (a literal #ffffff) and the degraded path paints
+// white-on-near-white across the 11 light themes, at ~35 call sites — the
+// exact bug the runtime tinting exists to fix. So `qrcTint` below picks its
+// dir from the LIVE token, with the same `hslLightness > 0.5` test the call
+// sites carried before they were collapsed into this vocabulary.
//
// THE ONE WAY A BAKE STILL SILENTLY LIES — worth keeping in mind when adding
// an icon: NO VARIANT ON DISK -> the Image resolves to nothing and the glyph
// is simply ABSENT, with nothing logged. The runtime sets are always complete
-// (every master, every tint), so this now only applies to the qrc fallback
-// dirs, where `favorite/` and `amber/` are deliberately partial. A new icon
-// goes into assets/icons/primary/ AND into the MASTERS table in
-// src/icon_tint_qt.rs; the qrc dirs stay whatever they are.
+// (every master, every tint); the qrc dirs were NOT, which is why the floor
+// could not be trusted: `black/` and `warning/` were missing mp3 + translate,
+// `secondary/` and `accent/` were missing mp3 (and `black/cd.svg` was a
+// #cccccc glyph — a light-grey disc in the DARK-glyph directory). All six
+// neutral/chromatic dirs are now 96 files, the same set as primary/ and as
+// MASTERS; only `favorite/` (52) and `amber/` (1) stay deliberately partial,
+// and neither is reachable from the polarity mapping. A new icon goes into
+// assets/icons/primary/, into the MASTERS table in src/icon_tint_qt.rs, AND
+// into black/ + secondary/ + muted/ if any theme-following tint can ask for
+// it.
import QtQuick
import com.blitzfc.qbz
@@ -86,22 +114,92 @@ Image {
// One of the names in the vocabulary above.
property string tintName: "secondary"
+ // The HSL lightness test for one theme token, read straight out of the
+ // live document without parsing it. `QbzShell.themeJson` is COMPACT serde
+ // output (theme_qt.rs:266 `serde_json::to_string`) and every colour goes
+ // through `argb()` (theme_qt.rs:124-126), so a token always reads exactly
+ // "textPrimary":"#ff0f0f0f"
+ // Reading it costs one indexOf over ~2.7 KB — next to nothing beside the
+ // QbzIconTint.dirFor() round trip this file already makes per icon (which
+ // hashes that whole string). The alternatives were both per-icon and much
+ // worse: a JSON.parse here, or a `QbzTheme { }` instance inside QbzIcon —
+ // ~70 eager bindings AND a parse each, multiplied by the hundreds of
+ // icons alive at once and re-run on every theme switch. QbzIcon is the
+ // one component in this tree that cannot afford an owned QbzTheme.
+ //
+ // Missing / unparsable -> true (light), which is the same default
+ // QbzTheme.qml takes pre-publish: its baked fallback document is the DARK
+ // palette, whose text-primary IS #ffffff.
+ function tokenIsLight(key) {
+ // Also the binding dependency — property reads are captured through
+ // the call, so `qrcTint` re-resolves when the theme is republished.
+ const json = QbzShell.themeJson
+ const needle = '"' + key + '":"#'
+ const at = json.indexOf(needle)
+ if (at < 0)
+ return true
+ const end = json.indexOf('"', at + needle.length)
+ if (end < 0)
+ return true
+ const hex = json.substring(at + needle.length, end)
+ if (hex.length < 6)
+ return true
+ // Drop the alpha byte of #aarrggbb (6-digit values work unchanged).
+ const o = hex.length - 6
+ const r = parseInt(hex.substring(o, o + 2), 16)
+ const g = parseInt(hex.substring(o + 2, o + 4), 16)
+ const b = parseInt(hex.substring(o + 4, o + 6), 16)
+ if (isNaN(r) || isNaN(g) || isNaN(b))
+ return true
+ // (max + min) / 2 over 0..255 — `color.hslLightness > 0.5`, the
+ // metric the per-view ternaries used before the vocabulary landed.
+ return (Math.max(r, g, b) + Math.min(r, g, b)) / 510 > 0.5
+ }
+
// The qrc directory this tint falls back to. Every tint name — including
// the ones with no directory of their own ("white", "textPrimary",
// "accentText" ...) — must land on a REAL dir, or the fallback renders
- // nothing at all.
+ // nothing at all. And a theme-following name must land on a dir whose
+ // POLARITY matches the token it stands for (see the header): this is the
+ // floor, so it is what a light theme gets when the bake is unreachable.
readonly property string qrcTint: {
switch (root.tintName) {
+ // Fixed by design — a light glyph on a host that is dark under every
+ // theme (scrims, gradient discs, the #e81123 close hover). "primary"
+ // is the legacy alias of "white" and stays a literal #ffffff.
case "white":
- case "textPrimary":
- case "accentText":
+ case "primary":
return "primary"
+
+ // Theme-following. Both of these read their OWN token, never the
+ // theme polarity: accent-text is near-BLACK on plenty of DARK themes
+ // whose accent fill is bright (qbz-theme registry.rs — tokyo-night
+ // #1a1b26, catppuccin-mocha #1e1e2e, ...), so `isDark` would get it
+ // backwards in both directions.
+ case "textPrimary":
+ return root.tokenIsLight("textPrimary") ? "primary" : "black"
+ case "accentText":
+ return root.tokenIsLight("accentText") ? "primary" : "black"
+
+ // The weak text ramp: #cccccc is unreadable on a light theme's
+ // surfaces, #888888 is the mid-grey that survives both polarities.
+ case "secondary":
case "textSecondary":
- return "secondary"
+ return root.tokenIsLight("textSecondary") ? "secondary" : "muted"
+
+ // muted / disabled stay on muted/ under BOTH polarities on purpose.
+ // #888888 reads on white and on near-black alike, and it is the only
+ // mid-grey bake there is: routing these by lightness would send the
+ // dark themes' text-disabled (#555555, lightness 0.33) to black/ —
+ // an invisible glyph on a dark surface, the bug in mirror image.
+ case "muted":
case "textMuted":
case "textDisabled":
case "disabled":
return "muted"
+
+ // "accent", "warning", "favorite" (chromatic, legible on both), and
+ // the fixed "black" / "amber" — each already names its own dir.
default:
return root.tintName
}
diff --git a/crates/qbz-qt/qml/theme/QbzTheme.qml b/crates/qbz-qt/qml/theme/QbzTheme.qml
index 026473298..8a3940e87 100644
--- a/crates/qbz-qt/qml/theme/QbzTheme.qml
+++ b/crates/qbz-qt/qml/theme/QbzTheme.qml
@@ -108,6 +108,214 @@ QtObject {
readonly property color surfaceMainA30: _c("surfaceMainA30")
readonly property color frostBorder: _c("frostBorder")
+ // --- ON AN ACCENT FILL: the glyph tint and its colour twin -----------
+ //
+ // AN OWNER-APPROVED DEVIATION FROM SLINT. Read this before touching a
+ // call site, and before "restoring" a #ffffff anywhere.
+ //
+ // Slint hardcodes a WHITE glyph on every accent fill:
+ // primitives/CircleAction.slint:72 (glyph on the primary disc)
+ // primitives/SelectionCheckbox.slint:37 (`tint: #ffffff`)
+ // discover/GenreFilterPopup.slint:85 (`tint: #ffffff`)
+ // discover/BrowseHeaderTools.slint:123 + :132 (glyph AND label)
+ // favorites/FavoritesView.slint:301 + :309 (glyph AND label)
+ // Measured against every accent in qbz-theme's registry.rs, that white
+ // falls UNDER 2.6:1 on 16 of the 35 shipped palettes — high-contrast
+ // 1.70, ikari 1.74, wcag-dark 1.82, kurosaki 1.85, ayanami 1.87,
+ // frost 2.00, catppuccin-mocha 2.03, aurora 2.04, macchiato 2.15,
+ // frappe 2.20, warm 2.33, zoey 2.41, dracula 2.41, breeze-dark 2.49,
+ // tokyo-night 2.52, rumi 2.53.
+ //
+ // Read the head of that list before the tail: `high-contrast` (accent
+ // #62d4ff) is the WORST row in the app at 1.70:1 and `wcag-dark`
+ // (#9ec1ff) is third at 1.82:1. Those are the two ACCESSIBILITY
+ // palettes — the ones whose entire reason to exist is contrast — and
+ // upstream paints their glyph at a ratio that fails every WCAG
+ // threshold there is. Everything after them is a dark theme with a
+ // bright accent. This is not the light-theme polarity bug the tint
+ // vocabulary already fixed, it is a separate defect that comes from
+ // upstream, so porting it 1:1 would ship it. The owner ruled the
+ // divergence in ("si venía mal de origen es una posibilidad"); every
+ // call site that takes it says so and cites the .slint line it departs
+ // from.
+ //
+ // The count is 16, not the 14 an earlier pass wrote here and at five
+ // other call sites: that pass had dropped exactly the two accessibility
+ // rows, i.e. the strongest evidence for diverging. Every number in this
+ // block was re-derived from the 35 `fn * -> ThemeColors` bodies in
+ // qbz-theme/src/registry.rs (resolving the `..dark()` spread, the
+ // `let accent = ...` shorthand and the StdSpec builder) and spot-checked
+ // against an independent contrast tool. Re-derive before editing them.
+ //
+ // THE OBVIOUS FIX — "just use accent-text" — IS ALSO WRONG. That token is
+ // chosen for the theme's TEXT ramp on an accent surface, not for an
+ // 11-20px glyph, and it is not universally better: on Rose Pine Dawn
+ // (accent #d7827e, accent-text #575279) a check glyph goes from 7.38:1
+ // at #000000 to 2.56:1 — UNDER the 3:1 non-text floor. So the durable
+ // answer is a SELECTOR, not a constant.
+ //
+ // THE RULE — theme intent first, floor-guarded:
+ // 1. `accentText`, whenever it clears `onAccentFloor` against the LIVE
+ // accent. It is the theme author's own on-accent colour, and — via
+ // `accentGlyphColor` — it is what the sibling LABELS across the app
+ // resolve to on 34 of the 35 rows (FilterChip, CastPicker,
+ // LocalToolbar, the GenreFilterPopup chips ...), so the glyph and
+ // the label agree by construction instead of by luck.
+ // 2. otherwise the extreme that survives best, "white" or "black".
+ // Only Rose Pine Dawn reaches branch 2 today (-> black, 7.38:1).
+ // 3. and, if branch 2 cannot clear the floor either, a documented last
+ // resort — see `accentGlyphTint` below. Unreachable on all 35
+ // registry rows and on `custom`; reachable on `auto`.
+ // Worst REST case after the selector, over all 35 registry rows: 3.04:1
+ // (breeze-light). Before: 1.70:1 (high-contrast). Hover is a separate
+ // number and is stated as such below — do not quote 3.04 for both.
+ //
+ // WHY THE FLOOR IS 3.0 AND NOT 4.5. The glyph is non-text — WCAG 1.4.11
+ // asks 3:1. Raising the bar to 4.5 would push Dark / Light / OLED
+ // (accent #4285f4, accent-text #ffffff, 3.56:1) off their own accent-text
+ // and onto a BLACK glyph: repainting the DEFAULT theme's play button is a
+ // far larger deviation than the bug being fixed. The label twin
+ // deliberately inherits the same floor rather than the 4.5:1 body-text
+ // one — item-for-item agreement with the glyph beats a per-role
+ // threshold that would split the two halves of one pill apart again,
+ // which is the defect being repaired in BrowseGenreButton / LibraryView.
+ //
+ // WHY THERE IS NO `accentHoverGlyphTint` SIBLING. This was checked, not
+ // assumed: running the same rule against `accentHover` picks a DIFFERENT
+ // arm on 6 of the 35 palettes (breeze-light, dark, light, oled,
+ // duotone-snow, rose-pine-dawn), so a second property would flip the
+ // glyph white->black under the cursor on the three default themes. A
+ // glyph that changes colour on hover is a worse defect than the dip it
+ // would fix.
+ //
+ // The dip, stated honestly, because an earlier pass here quoted Dark's
+ // 2.83:1 as the worst hover case and it is not: holding the selector's
+ // rest-time choice across both fills, the worst HOVER ratio over the 35
+ // rows is BREEZE-LIGHT at 2.49:1 — its `accent-text` IS #ffffff and its
+ // accentHover is #3daee9 — then Dark / Light / OLED at 2.83:1 (#ffffff
+ // on #5a9bf4) and duotone-snow at 2.84:1. All four are what ships today
+ // and what the owner has never reported; none of them is what the 3.04:1
+ // headline above measures, which is rest only.
+ //
+ // The hover fill is folded into the FALLBACK arm instead: once
+ // accent-text is out, the winning extreme is the one that holds on
+ // accent AND accentHover, so a diverging theme's choice cannot depend on
+ // which state the control is in.
+
+ /// WCAG 2.x relative luminance of a QML `color` (whose r/g/b are 0..1
+ /// sRGB floats). Spelled out on purpose: `hslLightness` is the stale
+ /// approximation this tree just finished removing from ~35 call sites,
+ /// and on the yellow/green accents (ikari #7eda53, kurosaki #d5be58,
+ /// ayanami #e5b82e) it misses by more than a whole arm of this decision.
+ function _relLum(c) {
+ const r = c.r <= 0.03928 ? c.r / 12.92 : Math.pow((c.r + 0.055) / 1.055, 2.4)
+ const g = c.g <= 0.03928 ? c.g / 12.92 : Math.pow((c.g + 0.055) / 1.055, 2.4)
+ const b = c.b <= 0.03928 ? c.b / 12.92 : Math.pow((c.b + 0.055) / 1.055, 2.4)
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+ }
+
+ /// The non-text contrast floor (WCAG 1.4.11). Read the note above before
+ /// raising it: 4.5 moves the three DEFAULT themes.
+ readonly property real onAccentFloor: 3.0
+
+ /// The QbzIcon tint NAME for a glyph drawn on an accent fill.
+ ///
+ /// A NAME and not a colour, because QbzIcon resolves a baked/runtime-
+ /// tinted DIRECTORY per tint (QbzIcon.qml header) — it cannot take an
+ /// arbitrary hex. All three values it can return are complete 96-glyph
+ /// sets: "accentText" is baked at runtime from MASTERS and falls back to
+ /// primary/ or black/ by its own polarity, "white" is primary/ and
+ /// "black" is black/, and those two are FIXED tints that never depend on
+ /// the runtime bake being alive at all.
+ ///
+ /// COST: one binding per QbzTheme INSTANCE — not per icon, not per
+ /// repaint. Two `_relLum()` calls (6 Math.pow) in the branch every
+ /// palette but one takes; white and black need none, their luminances are
+ /// exactly 1.0 and 0.0 so their ratios are closed forms.
+ ///
+ /// "Per instance" is NOT "once per theme switch", which is how an earlier
+ /// pass phrased it. QbzTheme is a plain instantiable QtObject (see the
+ /// file header) and the tree stamps out ~120 of them, several inside list
+ /// DELEGATES — views/local/SelectCheck.qml owns one, so this binding is
+ /// evaluated afresh on every delegate creation while a 16K-track Local
+ /// Library list scrolls. It is still negligible: the same instance is
+ /// already paying a `JSON.parse` of ~2.7 KB in the `_doc` binding above,
+ /// which dwarfs 6 Math.pow by orders of magnitude. Said out loud so the
+ /// next person profiling that scroll knows this line was measured and
+ /// dismissed rather than never looked at.
+ readonly property string accentGlyphTint: {
+ const la = _relLum(accent) + 0.05
+ const lt = _relLum(accentText) + 0.05
+ if ((la > lt ? la / lt : lt / la) >= onAccentFloor)
+ return "accentText"
+ // Accent-text is not legible on this accent. Pick the extreme that
+ // holds on BOTH fills a control paints (rest + hover) — see the
+ // "no sibling" note above.
+ const lh = _relLum(accentHover) + 0.05
+ // white: (1.0 + 0.05) / (Lfill + 0.05); black: (Lfill + 0.05) / 0.05
+ const white = Math.min(1.05 / la, 1.05 / lh)
+ const black = Math.min(la, lh) / 0.05
+ if (Math.max(white, black) >= onAccentFloor)
+ return white >= black ? "white" : "black"
+ // LAST RESORT — neither extreme survives BOTH fills. Scoring the two
+ // fills jointly takes a `min()`, and a `min()` has no floor of its
+ // own: a theme whose accent is dark and whose accentHover is light
+ // fails white on one and black on the other, so the "winning extreme"
+ // can win at 1.1:1. Drop the hover fill and score against `accent`
+ // alone, which is provably safe — max(white, black) on a SINGLE fill
+ // bottoms out at 4.58:1, where 1.05/(L+0.05) meets (L+0.05)/0.05.
+ // Rest legibility is the one being protected; hover legibility is
+ // what gets sacrificed to buy it, deliberately and in that order,
+ // because a control is at rest far more of the time than under a
+ // cursor and an illegible resting glyph is the defect this whole
+ // selector exists to prevent.
+ //
+ // REACHABILITY, checked rather than assumed. Dead on all 35
+ // registry.rs rows (their floor across both fills is 4.40:1) and on
+ // `custom`, whose accent_hover is the accent shifted +0.10 lightness
+ // (custom.rs:175) so both fills stay in one lightness neighbourhood
+ // — swept over the accent cube, that floor is 3.26:1. LIVE on `auto`:
+ // there accent_hover is `scheme.selection_hover` and accent_text is
+ // `scheme.selection_fg` (auto/generator.rs:261-268), and those
+ // `unwrap_or_else` arms only fire when the system colour scheme is
+ // SILENT. A desktop theme that names both hands us an arbitrary
+ // triple with no derived relationship at all.
+ return (1.05 / la) >= (la / 0.05) ? "white" : "black"
+ }
+
+ /// The COLOUR twin of `accentGlyphTint`, for the Text labels that share
+ /// an accent pill with such a glyph (Text takes a colour, not a tint
+ /// name). DERIVED from the decision above rather than re-deciding it, so
+ /// the two halves of a pill can never disagree.
+ ///
+ /// The literals must be what the tint names actually paint, or the label
+ /// and the glyph drift on the palettes that take the fallback arm.
+ /// Verified by reading the sets: assets/icons/primary/ and
+ /// assets/icons/black/ hold 96 files each with identical name sets (=
+ /// the MASTERS table in src/icon_tint_qt.rs), and EVERY primary master
+ /// carries the literal `#ffffff` somewhere in a paint attribute, every
+ /// black master `#000000` — zero exceptions on either side.
+ ///
+ /// That invariant is about the literal, NOT about `fill=`, which is how
+ /// an earlier pass wrote it. Only 11 of the 96 primary masters paint with
+ /// `fill="#ffffff"`; the other 85 are `fill="none"` outlines carrying
+ /// `stroke="#ffffff"` (3 files use both). Of the five glyphs this
+ /// selector can actually reach — play-fill (QbzCircleAction's primary
+ /// disc, always play-fill at every `primary: true` call site), pause
+ /// (TransportControls), check + minus (QbzCheckbox, SelectCheck,
+ /// GenreFilterPopup, LoginScreen) and list-filter (BrowseGenreButton,
+ /// LibraryView) — exactly ONE, play-fill, is fill-painted.
+ ///
+ /// The behaviour was never at risk: `bake()` does a plain hex replace
+ /// over the whole SVG text (src/icon_tint_qt.rs:389-391), so a stroke is
+ /// recoloured with the fill and stroke-only glyphs tint correctly. Only
+ /// the written claim was wrong. When adding a master, the rule to hold is
+ /// "paints with the literal #ffffff SOMEWHERE", not "has fill=#ffffff".
+ readonly property color accentGlyphColor:
+ accentGlyphTint === "accentText"
+ ? accentText
+ : (accentGlyphTint === "white" ? "#ffffff" : "#000000")
+
// --- Spacing (spacing.slint) --------------------------------------
readonly property int spacingXs: 4
readonly property int spacingSm: 8
@@ -162,4 +370,31 @@ QtObject {
// The Large dock's sidebar reservation subtracts this, so it must be the
// SAME value the shell pins the bar to.
readonly property int npbLargeHeight: 112
+
+ // --- Now-playing-bar responsive breakpoints ------------------------
+ // ONE family of WINDOW-width breakpoints drives BOTH bars
+ // (PlayerBar.slint:142-144 and PlayerBarSmall.slint:145-147 carry the
+ // identical expression, and PlayerBarSmall.slint:152 reuses the 1366
+ // edge for the volume control):
+ // width < 1366 -> 39% | 22% | 39% volume = button + flyout
+ // 1366..<1920 -> 30% | 40% | 30% volume = inline slider
+ // width >= 1920 -> 25% | 50% | 25%
+ // The two side columns always share the fraction — that symmetry is what
+ // lands PLAY on the window centre. These lived as copy-pasted literals in
+ // PlayerBar.qml and NowPlayingBarSmall.qml; as tokens the two bars cannot
+ // drift apart when the breakpoints move.
+ //
+ // NOT here: Classic's fixed 0.324 side column. That is a per-MODE
+ // override (PlayerBar.slint:149), not a breakpoint — Classic ignores the
+ // window width entirely, so folding it in would misrepresent the rule.
+ readonly property int npbBreakMid: 1366
+ readonly property int npbBreakWide: 1920
+ // `w` is the BAR's width, which is the window's width: both bars are
+ // anchored left-to-right on the shell root (AppShell.qml:72-80), NOT
+ // inset by the sidebar or the queue column (those anchor to npb.top).
+ // Slint reads the same number off its own bar root. If a bar ever stops
+ // spanning the window, this argument must switch to Window.width.
+ function npbSideFrac(w) {
+ return w >= npbBreakWide ? 0.25 : (w >= npbBreakMid ? 0.30 : 0.39)
+ }
}
diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml
index 403649cd3..a29cea7f3 100644
--- a/crates/qbz-qt/qml/views/AlbumListRow.qml
+++ b/crates/qbz-qt/qml/views/AlbumListRow.qml
@@ -176,7 +176,7 @@ Rectangle {
width: 18
height: 18
anchors.centerIn: parent
- tintName: moreArea.containsMouse ? "primary" : "muted"
+ tintName: moreArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: moreArea
diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml
index a324ca664..1115c395c 100644
--- a/crates/qbz-qt/qml/views/AlbumView.qml
+++ b/crates/qbz-qt/qml/views/AlbumView.qml
@@ -242,7 +242,9 @@ Rectangle {
width: 13
height: 13
anchors.centerIn: parent
- tintName: "primary"
+ // On the card's fixed brand gradient disc (indigo/violet
+ // or amber), never on a theme surface.
+ tintName: "white"
}
}
Text {
diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml
index 337823298..c76e63176 100644
--- a/crates/qbz-qt/qml/views/ArtistView.qml
+++ b/crates/qbz-qt/qml/views/ArtistView.qml
@@ -132,13 +132,13 @@ Rectangle {
readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary
readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary
readonly property bool hdrOverlay: headerLight
- /// Slint's `Theme.text-primary` as an ICON tint. Icon tints in this port
- /// are pre-baked SVG variants (QbzIcon.qml) and the "primary" bake is a
- /// literal `fill="#ffffff"` — it is not the theme token. Every hover that
- /// raises a muted glyph to text-primary therefore has to pick the "black"
- /// bake on a light theme, or the glyph disappears at the moment it is
- /// supposed to light up. Same #638 class as the track-row play glyph.
- readonly property string tintOnSurface: theme.isDark ? "primary" : "black"
+ /// Slint's `Theme.text-primary` as an ICON tint, for the hovers that raise
+ /// a muted glyph on a THEME surface (every consumer sits on
+ /// `surface-elevated`/transparent, never on the artwork header — that one
+ /// uses `hdrStrong` above). Runtime-tinted via src/icon_tint_qt.rs, so it
+ /// is the live token; it used to be `isDark ? "primary" : "black"`, a
+ /// two-value stand-in from when only fixed bakes existed.
+ readonly property string tintOnSurface: "textPrimary"
/// TrackRow.slint:123-125 — the row hover uses the polarity-baked alpha
/// ramp "so the hover state is visible on light themes too (the old
/// #ffffff16 was invisible white-on-white there)". The zebra stripe is
@@ -504,7 +504,9 @@ Rectangle {
name: isActive && QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 16
height: 16
- tintName: "primary"
+ // On the #000000 @ 0.6 artwork scrim above — dark under
+ // every theme.
+ tintName: "white"
}
MouseArea {
anchors.fill: parent
diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml
index fa00213be..c96e0a36a 100644
--- a/crates/qbz-qt/qml/views/HomeView.qml
+++ b/crates/qbz-qt/qml/views/HomeView.qml
@@ -906,7 +906,7 @@ Rectangle {
width: 20
height: 20
anchors.centerIn: parent
- tintName: gearArea.containsMouse ? "primary" : "muted"
+ tintName: gearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: gearArea
diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml
index 9d18d3066..4066ff8f8 100644
--- a/crates/qbz-qt/qml/views/LibraryView.qml
+++ b/crates/qbz-qt/qml/views/LibraryView.qml
@@ -353,7 +353,7 @@ Rectangle {
height: 16
anchors.centerIn: parent
tintName: parent.active ? "accent"
- : ttArea.containsMouse ? "primary" : "secondary"
+ : ttArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: ttArea
@@ -385,7 +385,16 @@ Rectangle {
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
- tintName: gtb.active ? "primary" : "secondary"
+ // Same pair-of-halves fix as controls/BrowseGenreButton.qml:
+ // the glyph said "primary" (legacy alias of a literal
+ // #ffffff) next to an accent-text label, i.e. a white glyph
+ // beside a black label on the pale-accent themes.
+ // favorites/FavoritesView.slint:301 + :309 are consistently
+ // #ffffff, but that white is 1.70:1 on high-contrast, 1.74
+ // on ikari, 1.82 on wcag-dark and under 2.6:1 on 16 of the
+ // 35 palettes — deliberate divergence from both lines.
+ // theme/QbzTheme.qml, "ON AN ACCENT FILL".
+ tintName: gtb.active ? theme.accentGlyphTint : "secondary"
}
Text {
anchors.verticalCenter: parent.verticalCenter
@@ -395,7 +404,9 @@ Rectangle {
? QbzSession.tr("1 genre", QbzSession.trRev)
: QbzSession.tr("{} genres", QbzSession.trRev)
.replace("{}", root.genreCount)
- color: gtb.active ? theme.accentText : theme.textSecondary
+ // The colour twin of the glyph's tint above — accent-text on
+ // 34 of the 35 palettes.
+ color: gtb.active ? theme.accentGlyphColor : theme.textSecondary
font.pixelSize: 12
}
}
@@ -532,7 +543,8 @@ Rectangle {
color: "#a6000000"
opacity: lrPlayArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
- QbzIcon { name: "play-fill"; width: 16; height: 16; anchors.centerIn: parent; tintName: "primary" }
+ // On the #a6000000 artwork scrim — dark under every theme.
+ QbzIcon { name: "play-fill"; width: 16; height: 16; anchors.centerIn: parent; tintName: "white" }
MouseArea {
id: lrPlayArea
anchors.fill: parent
@@ -638,7 +650,10 @@ Rectangle {
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
- tintName: item.source === "plex" ? "accent" : "primary"
+ // Host is a transparent column over the theme row (NOT an
+ // artwork scrim), so this matches its siblings
+ // local/LocalAlbumRow.qml and local/LocalTrackRow.qml.
+ tintName: item.source === "plex" ? "accent" : "muted"
}
}
// Col — quality (albums + tracks).
diff --git a/crates/qbz-qt/qml/views/LocalAlbumView.qml b/crates/qbz-qt/qml/views/LocalAlbumView.qml
index 4c98d59ed..8e78155a2 100644
--- a/crates/qbz-qt/qml/views/LocalAlbumView.qml
+++ b/crates/qbz-qt/qml/views/LocalAlbumView.qml
@@ -369,7 +369,7 @@ Rectangle {
width: 16
height: 16
anchors.centerIn: parent
- tintName: discArea.containsMouse ? "primary" : "muted"
+ tintName: discArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: discArea
diff --git a/crates/qbz-qt/qml/views/PlaylistListRow.qml b/crates/qbz-qt/qml/views/PlaylistListRow.qml
index 75875038c..74f0657b9 100644
--- a/crates/qbz-qt/qml/views/PlaylistListRow.qml
+++ b/crates/qbz-qt/qml/views/PlaylistListRow.qml
@@ -126,7 +126,7 @@ Rectangle {
width: 16
height: 16
anchors.centerIn: parent
- tintName: root.rowHovered ? "primary" : "muted"
+ tintName: root.rowHovered ? "textPrimary" : "muted"
}
MouseArea {
id: playArea
@@ -144,7 +144,7 @@ Rectangle {
width: 16
height: 16
anchors.centerIn: parent
- tintName: moreArea.containsMouse ? "primary" : "secondary"
+ tintName: moreArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: moreArea
diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml
index 80c8b0c35..3fe9b7c27 100644
--- a/crates/qbz-qt/qml/views/PlaylistView.qml
+++ b/crates/qbz-qt/qml/views/PlaylistView.qml
@@ -680,7 +680,15 @@ Rectangle {
id: saveText
anchors.centerIn: parent
text: QbzSession.tr("Save", QbzSession.trRev)
- color: "#ffffff"
+ // Was a literal #ffffff, 1:1 with
+ // primitives/EditPlaylistModal.slint:171 — and that
+ // white is 1.70:1 on high-contrast, 1.74 on ikari,
+ // 1.82 on wcag-dark, under 2.6:1 on 16 of the 35
+ // palettes, on the SAME accent fill every other
+ // on-accent site was just fixed against. The colour
+ // twin of the glyph selector; see
+ // theme/QbzTheme.qml, "ON AN ACCENT FILL".
+ color: theme.accentGlyphColor
font.pixelSize: 13
font.weight: theme.weightMedium
}
diff --git a/crates/qbz-qt/qml/views/SearchView.qml b/crates/qbz-qt/qml/views/SearchView.qml
index c218bd1ee..c65203ee2 100644
--- a/crates/qbz-qt/qml/views/SearchView.qml
+++ b/crates/qbz-qt/qml/views/SearchView.qml
@@ -361,7 +361,7 @@ Rectangle {
radius: 12
anchors.verticalCenter: parent.verticalCenter
color: clrArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
- QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "primary" : "muted" }
+ QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "textPrimary" : "muted" }
MouseArea {
id: clrArea
anchors.fill: parent
diff --git a/crates/qbz-qt/qml/views/local/FilterChip.qml b/crates/qbz-qt/qml/views/local/FilterChip.qml
index 146ff50f2..96fd49596 100644
--- a/crates/qbz-qt/qml/views/local/FilterChip.qml
+++ b/crates/qbz-qt/qml/views/local/FilterChip.qml
@@ -28,7 +28,14 @@ Rectangle {
id: lbl
anchors.centerIn: parent
text: root.label
- color: root.active ? theme.accentText : theme.textSecondary
+ // On the accent fill. NOT a departure from
+ // locallibrary/LocalLibraryView.slint:690 (`Theme.accent-text`) —
+ // `accentGlyphColor` RETURNS accent-text on 34 of the 35 palettes.
+ // It is a floor under it: rose-pine-dawn's accent-text #575279 on
+ // accent #d7827e is 2.56:1, and the twin hands back black at 7.38:1.
+ // Reading it here instead of accent-text is also what keeps this chip
+ // agreeing with the genre pills, which had to diverge outright.
+ color: root.active ? theme.accentGlyphColor : theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: root.active ? theme.weightSemibold : theme.weightRegular
}
diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml
index ee0e73454..c41ad0279 100644
--- a/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml
+++ b/crates/qbz-qt/qml/views/local/LocalAlbumRow.qml
@@ -189,7 +189,7 @@ Rectangle {
width: 18
height: 18
anchors.centerIn: parent
- tintName: moreArea.containsMouse ? "primary" : "muted"
+ tintName: moreArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: moreArea
diff --git a/crates/qbz-qt/qml/views/local/LocalArtistRow.qml b/crates/qbz-qt/qml/views/local/LocalArtistRow.qml
index e63a5ee67..05932ccfb 100644
--- a/crates/qbz-qt/qml/views/local/LocalArtistRow.qml
+++ b/crates/qbz-qt/qml/views/local/LocalArtistRow.qml
@@ -80,7 +80,13 @@ Rectangle {
Text {
width: parent.width
text: root.item.displayName || root.item.name || ""
- color: root.selected ? theme.accentText : theme.textPrimary
+ // Both labels sit on the selected row's `theme.accent` fill.
+ // Same story as views/local/FilterChip.qml: NOT a departure
+ // from locallibrary/LocalLibraryView.slint:653 + :660
+ // (`Theme.accent-text`), which is what the twin returns on 34
+ // of the 35 palettes — only a floor for rose-pine-dawn, where
+ // accent-text measures 2.56:1 on this accent.
+ color: root.selected ? theme.accentGlyphColor : theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: root.selected ? theme.weightSemibold : theme.weightMedium
elide: Text.ElideRight
@@ -91,7 +97,7 @@ Rectangle {
+ QbzSession.tr("albums", QbzSession.trRev) + " · "
+ (root.item.trackCount || 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev)
- color: root.selected ? theme.accentText : theme.textMuted
+ color: root.selected ? theme.accentGlyphColor : theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
diff --git a/crates/qbz-qt/qml/views/local/LocalMultiSelectBar.qml b/crates/qbz-qt/qml/views/local/LocalMultiSelectBar.qml
index d53ef6c62..c19c049a5 100644
--- a/crates/qbz-qt/qml/views/local/LocalMultiSelectBar.qml
+++ b/crates/qbz-qt/qml/views/local/LocalMultiSelectBar.qml
@@ -71,7 +71,7 @@ Rectangle {
height: 16
anchors.centerIn: parent
tintName: btn.modelData.danger ? "warning"
- : btnArea.containsMouse ? "primary" : "secondary"
+ : btnArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: btnArea
diff --git a/crates/qbz-qt/qml/views/local/LocalToolbar.qml b/crates/qbz-qt/qml/views/local/LocalToolbar.qml
index 1107b2aa9..2964e0997 100644
--- a/crates/qbz-qt/qml/views/local/LocalToolbar.qml
+++ b/crates/qbz-qt/qml/views/local/LocalToolbar.qml
@@ -165,7 +165,13 @@ Row {
Text {
anchors.centerIn: parent
text: root.view.filterCount
- color: theme.accentText
+ // 9px bold on a 15px accent dot — the smallest on-accent
+ // text in the app, so the floor matters most here. NOT a
+ // departure from locallibrary/LocalLibraryView.slint:887
+ // (`Theme.accent-text`); the twin returns accent-text on
+ // 34 of the 35 palettes and only overrides rose-pine-dawn
+ // (2.56:1 -> 7.38:1).
+ color: theme.accentGlyphColor
font.pixelSize: 9
font.weight: theme.weightBold
}
diff --git a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
index d21b2832a..5becd937b 100644
--- a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
+++ b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
@@ -126,7 +126,7 @@ Item {
width: 16
height: 16
anchors.centerIn: parent
- tintName: moreArea.containsMouse ? "primary" : "muted"
+ tintName: moreArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: moreArea
diff --git a/crates/qbz-qt/qml/views/local/SelectCheck.qml b/crates/qbz-qt/qml/views/local/SelectCheck.qml
index da902ed8f..0a8c8af4f 100644
--- a/crates/qbz-qt/qml/views/local/SelectCheck.qml
+++ b/crates/qbz-qt/qml/views/local/SelectCheck.qml
@@ -33,7 +33,19 @@ Rectangle {
width: Math.round(root.diameter * 0.62)
height: Math.round(root.diameter * 0.62)
anchors.centerIn: parent
- tintName: "primary"
+ // DELIBERATE DIVERGENCE from primitives/SelectionCheckbox.slint:37,
+ // which hardcodes `tint: #ffffff` on this same accent fill. That
+ // white is under 2.6:1 on 16 of the 35 palettes, worst on
+ // high-contrast at 1.70:1, then ikari 1.74 and wcag-dark 1.82 —
+ // i.e. the two ACCESSIBILITY themes bracket the top of the list.
+ // (This site used to claim ikari 1.74 was "the worst case in the
+ // app"; it is second. The 8px glyph here does make it the smallest
+ // thing painted at those ratios, which is a different argument and
+ // the one worth keeping.) Slint's own QbzCheckbox.slint:24 uses
+ // accent-text for the equivalent glyph, so upstream contradicts
+ // ITSELF here; the selector settles it by measurement instead of by
+ // picking a side. See theme/QbzTheme.qml, "ON AN ACCENT FILL".
+ tintName: theme.accentGlyphTint
}
MouseArea {
id: checkArea