fix(qt): glyph colours follow the theme, on light palettes and on accent fills

Two defects with one cause: a tint name that means a frozen hex.

"primary" is the legacy alias of literal #ffffff, not Theme.text-primary,
so every control passing it drew a white glyph. On the eleven light
themes that is white-on-light -- the header back/forward chevrons, the
sidebar toggle and the carousel arrows the owner marked. They now pass
the theme-following names, which icon_tint_qt bakes at runtime.

The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that
predate the runtime tinter collapse into those names, and the qrc floor
underneath them becomes polarity-aware: dir_for() returns "" whenever
the cache dir is unavailable, a bake fails or the theme document has not
been published, and that path used to resolve textPrimary straight back
to the white bake -- reproducing the bug it was fixing, app-wide, in the
degraded state.

On accent fills the fix is a deviation from Slint, approved by the owner
after measurement. A fixed white glyph on Theme.accent falls under 2.6:1
on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and
wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast.
Slint hardcodes #ffffff there (CircleAction.slint:72 and three others),
so the bug is upstream. accentText alone is not the answer either: it is
picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn,
worse than the black it replaces. So QbzTheme picks the best-contrast
tint by real sRGB luminance against the live accent, and the labels
beside those glyphs use its colour twin -- BrowseGenreButton and
LibraryView were drawing a white glyph next to an accentText label
inside the same pill.

Rounds out the two parity items the same files owe: QbzNavButton gains
the ambient arm from Carousel.slint (wired at the six carousel sites,
not left dead), and QbzCircleAction rounds its glyph to whole pixels the
way CircleAction.slint does.

cc
This commit is contained in:
vicrodh committed 2026-07-29 10:30:43 -06:00
1 parent 8af04c5272
commit 81c43a114e
52 files changed
+841 -153

No files matched your search

+16 -2
View File
@@ -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
}
@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" fill="#4285f4" width="128" height="128" viewBox="0 0 32 32" id="icon"><defs><style>.cls-1{fill:none;}</style></defs><title>MP3</title><path d="M28,9H22v2h6v4H24v2h4v4H22v2h6a2,2,0,0,0,2-2V11A2,2,0,0,0,28,9Z"/><path d="M14,23H12V9h6a2,2,0,0,1,2,2v5a2,2,0,0,1-2,2H14Zm0-7h4V11H14Z"/><polygon points="8 9 6.49 14 6 15.98 5.54 14 4 9 2 9 2 23 4 23 4 15 3.84 13 4.42 15 6 19.63 7.58 15 8.16 13 8 15 8 23 10 23 10 9 8 9"/><rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/></svg>

After

Width:  |  Height:  |  Size: 658 B

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="128" width="128" version="1.1" id="_x32_" viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#cccccc;}
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M508.797,215.893c-9.977-62.966-42.222-117.038-87.362-155.217C376.304,22.48,318.172,0,256.298,0 c-13.335,0-26.842,1.043-40.405,3.195c-62.966,9.985-117.038,42.23-155.217,87.37C22.48,135.689,0,193.829,0,255.703 c0,13.334,1.043,26.841,3.204,40.404c9.976,62.966,42.222,117.039,87.362,155.218C135.697,489.52,193.829,512,255.703,512 c13.334,0,26.841-1.043,40.404-3.204c62.966-9.976,117.039-42.222,155.218-87.361C489.52,376.304,512,318.172,512,256.298 C512,242.963,510.957,229.456,508.797,215.893z M425.274,399.387c-33.166,39.165-79.838,67.025-134.514,75.714 c-11.794,1.866-23.5,2.771-35.057,2.771c-53.56,0-103.924-19.456-143.09-52.598c-39.165-33.166-67.024-79.838-75.714-134.514 c-1.866-11.794-2.771-23.5-2.771-35.057c0-53.56,19.448-103.924,52.598-143.09c33.166-39.173,79.838-67.025,134.514-75.714 c11.794-1.866,23.499-2.771,35.057-2.771c53.56,0,103.924,19.448,143.089,52.598c39.165,33.166,67.025,79.838,75.714,134.514 c1.866,11.794,2.771,23.499,2.771,35.057C477.872,309.858,458.416,360.222,425.274,399.387z"/>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" fill="#000000" width="128" height="128" viewBox="0 0 32 32" id="icon"><defs><style>.cls-1{fill:none;}</style></defs><title>MP3</title><path d="M28,9H22v2h6v4H24v2h4v4H22v2h6a2,2,0,0,0,2-2V11A2,2,0,0,0,28,9Z"/><path d="M14,23H12V9h6a2,2,0,0,1,2,2v5a2,2,0,0,1-2,2H14Zm0-7h4V11H14Z"/><polygon points="8 9 6.49 14 6 15.98 5.54 14 4 9 2 9 2 23 4 23 4 15 3.84 13 4.42 15 6 19.63 7.58 15 8.16 13 8 15 8 23 10 23 10 9 8 9"/><rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/></svg>

After

Width:  |  Height:  |  Size: 658 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.93 2H5.02C3 2 2 3 2 5.02V8.94C2 11 3 12 5.02 11.95H8.94C11 12 12 11 11.95 8.93V5.02C12 3 11 2 8.93 2ZM9.01 9.76C8.33 9.76 7.67 9.5 7.12 9.04C6.5 9.49 5.75 9.76 4.94 9.76C4.53 9.76 4.19 9.42 4.19 9.01C4.19 8.6 4.53 8.26 4.94 8.26C5.96 8.26 6.81 7.56 7.12 6.6H4.94C4.53 6.6 4.19 6.26 4.19 5.85C4.19 5.44 4.53 5.1 4.94 5.1H6.23C6.27 4.72 6.58 4.42 6.97 4.42C7.36 4.42 7.67 4.72 7.71 5.1H7.97C7.98 5.1 7.99 5.1 7.99 5.1H8.01H9C9.41 5.1 9.75 5.44 9.75 5.85C9.75 6.26 9.42 6.6 9 6.6H8.67C8.58 7.08 8.39 7.53 8.14 7.94C8.41 8.14 8.7 8.26 9.01 8.26C9.42 8.26 9.76 8.6 9.76 9.01C9.76 9.42 9.42 9.76 9.01 9.76Z" fill="#000000"/>
<path d="M9 22.75C4.73 22.75 1.25 19.27 1.25 15C1.25 14.59 1.59 14.25 2 14.25C2.41 14.25 2.75 14.59 2.75 15C2.75 17.96 4.81 20.44 7.58 21.09L7.31 20.64C7.1 20.28 7.21 19.82 7.57 19.61C7.92 19.4 8.39 19.51 8.6 19.87L9.65 21.62C9.79 21.85 9.79 22.14 9.66 22.37C9.52 22.6 9.27 22.75 9 22.75Z" fill="#000000"/>
<path d="M21.9985 9.75C21.5885 9.75 21.2485 9.41 21.2485 9C21.2485 6.04 19.1885 3.56 16.4185 2.91L16.6885 3.36C16.8985 3.72 16.7885 4.18 16.4285 4.39C16.0785 4.6 15.6085 4.49 15.3985 4.13L14.3485 2.38C14.2085 2.15 14.2085 1.86 14.3385 1.63C14.4785 1.4 14.7285 1.25 14.9985 1.25C19.2685 1.25 22.7485 4.73 22.7485 9C22.7485 9.41 22.4085 9.75 21.9985 9.75Z" fill="#000000"/>
<path d="M16.9198 11.8516C14.1198 11.8516 11.8398 14.1216 11.8398 16.9316C11.8398 19.7316 14.1098 22.0116 16.9198 22.0116C19.7198 22.0116 21.9998 19.7416 21.9998 16.9316C21.9998 14.1216 19.7298 11.8516 16.9198 11.8516ZM19.3998 19.3416C19.0298 19.5216 18.5798 19.3816 18.3898 19.0016L18.2198 18.6616H15.6298L15.4598 19.0016C15.3298 19.2616 15.0598 19.4116 14.7898 19.4116C14.6798 19.4116 14.5598 19.3816 14.4598 19.3316C14.0898 19.1416 13.9398 18.6916 14.1198 18.3216L16.2598 14.0516C16.3898 13.8016 16.6498 13.6416 16.9298 13.6416C17.2098 13.6416 17.4698 13.8016 17.5998 14.0616L19.7398 18.3316C19.9198 18.7016 19.7698 19.1516 19.3998 19.3416Z" fill="#000000"/>
<path d="M16.3789 17.1603H17.4689L16.9189 16.0703L16.3789 17.1603Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" fill="#cccccc" width="128" height="128" viewBox="0 0 32 32" id="icon"><defs><style>.cls-1{fill:none;}</style></defs><title>MP3</title><path d="M28,9H22v2h6v4H24v2h4v4H22v2h6a2,2,0,0,0,2-2V11A2,2,0,0,0,28,9Z"/><path d="M14,23H12V9h6a2,2,0,0,1,2,2v5a2,2,0,0,1-2,2H14Zm0-7h4V11H14Z"/><polygon points="8 9 6.49 14 6 15.98 5.54 14 4 9 2 9 2 23 4 23 4 15 3.84 13 4.42 15 6 19.63 7.58 15 8.16 13 8 15 8 23 10 23 10 9 8 9"/><rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/></svg>

After

Width:  |  Height:  |  Size: 658 B

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" fill="#fbbf24" width="128" height="128" viewBox="0 0 32 32" id="icon"><defs><style>.cls-1{fill:none;}</style></defs><title>MP3</title><path d="M28,9H22v2h6v4H24v2h4v4H22v2h6a2,2,0,0,0,2-2V11A2,2,0,0,0,28,9Z"/><path d="M14,23H12V9h6a2,2,0,0,1,2,2v5a2,2,0,0,1-2,2H14Zm0-7h4V11H14Z"/><polygon points="8 9 6.49 14 6 15.98 5.54 14 4 9 2 9 2 23 4 23 4 15 3.84 13 4.42 15 6 19.63 7.58 15 8.16 13 8 15 8 23 10 23 10 9 8 9"/><rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/></svg>

After

Width:  |  Height:  |  Size: 658 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.93 2H5.02C3 2 2 3 2 5.02V8.94C2 11 3 12 5.02 11.95H8.94C11 12 12 11 11.95 8.93V5.02C12 3 11 2 8.93 2ZM9.01 9.76C8.33 9.76 7.67 9.5 7.12 9.04C6.5 9.49 5.75 9.76 4.94 9.76C4.53 9.76 4.19 9.42 4.19 9.01C4.19 8.6 4.53 8.26 4.94 8.26C5.96 8.26 6.81 7.56 7.12 6.6H4.94C4.53 6.6 4.19 6.26 4.19 5.85C4.19 5.44 4.53 5.1 4.94 5.1H6.23C6.27 4.72 6.58 4.42 6.97 4.42C7.36 4.42 7.67 4.72 7.71 5.1H7.97C7.98 5.1 7.99 5.1 7.99 5.1H8.01H9C9.41 5.1 9.75 5.44 9.75 5.85C9.75 6.26 9.42 6.6 9 6.6H8.67C8.58 7.08 8.39 7.53 8.14 7.94C8.41 8.14 8.7 8.26 9.01 8.26C9.42 8.26 9.76 8.6 9.76 9.01C9.76 9.42 9.42 9.76 9.01 9.76Z" fill="#fbbf24"/>
<path d="M9 22.75C4.73 22.75 1.25 19.27 1.25 15C1.25 14.59 1.59 14.25 2 14.25C2.41 14.25 2.75 14.59 2.75 15C2.75 17.96 4.81 20.44 7.58 21.09L7.31 20.64C7.1 20.28 7.21 19.82 7.57 19.61C7.92 19.4 8.39 19.51 8.6 19.87L9.65 21.62C9.79 21.85 9.79 22.14 9.66 22.37C9.52 22.6 9.27 22.75 9 22.75Z" fill="#fbbf24"/>
<path d="M21.9985 9.75C21.5885 9.75 21.2485 9.41 21.2485 9C21.2485 6.04 19.1885 3.56 16.4185 2.91L16.6885 3.36C16.8985 3.72 16.7885 4.18 16.4285 4.39C16.0785 4.6 15.6085 4.49 15.3985 4.13L14.3485 2.38C14.2085 2.15 14.2085 1.86 14.3385 1.63C14.4785 1.4 14.7285 1.25 14.9985 1.25C19.2685 1.25 22.7485 4.73 22.7485 9C22.7485 9.41 22.4085 9.75 21.9985 9.75Z" fill="#fbbf24"/>
<path d="M16.9198 11.8516C14.1198 11.8516 11.8398 14.1216 11.8398 16.9316C11.8398 19.7316 14.1098 22.0116 16.9198 22.0116C19.7198 22.0116 21.9998 19.7416 21.9998 16.9316C21.9998 14.1216 19.7298 11.8516 16.9198 11.8516ZM19.3998 19.3416C19.0298 19.5216 18.5798 19.3816 18.3898 19.0016L18.2198 18.6616H15.6298L15.4598 19.0016C15.3298 19.2616 15.0598 19.4116 14.7898 19.4116C14.6798 19.4116 14.5598 19.3816 14.4598 19.3316C14.0898 19.1416 13.9398 18.6916 14.1198 18.3216L16.2598 14.0516C16.3898 13.8016 16.6498 13.6416 16.9298 13.6416C17.2098 13.6416 17.4698 13.8016 17.5998 14.0616L19.7398 18.3316C19.9198 18.7016 19.7698 19.1516 19.3998 19.3416Z" fill="#fbbf24"/>
<path d="M16.3789 17.1603H17.4689L16.9189 16.0703L16.3789 17.1603Z" fill="#fbbf24"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+4 -2
View File
@@ -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"
}
}
}
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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"
}
}
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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 {
@@ -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
+4 -1
View File
@@ -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
@@ -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 {
@@ -115,7 +115,7 @@ Item {
name: "x"
width: 17
height: 17
tintName: cfgCloseArea.containsMouse ? "primary" : "muted"
tintName: cfgCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: cfgCloseArea
@@ -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 {
+13 -2
View File
@@ -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
}
+59 -21
View File
@@ -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
+17 -16
View File
@@ -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/<tint>/) — "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/<tint>/
// 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
+2 -2
View File
@@ -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
+101 -3
View File
@@ -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
@@ -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()
}
@@ -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/<tint>/), 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
+9 -10
View File
@@ -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.
+27 -2
View File
@@ -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
+6 -5
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+7 -7
View File
@@ -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.
@@ -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
+4 -4
View File
@@ -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
+16 -8
View File
@@ -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
+111 -13
View File
@@ -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
}
+235
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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 {
+10 -8
View File
@@ -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
+1 -1
View File
@@ -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
+20 -5
View File
@@ -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).
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+9 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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
}
@@ -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
@@ -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
}
@@ -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
@@ -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
}
@@ -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
+13 -1
View File
@@ -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