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