feat(qbz-qt): phase 5.5 — visual polish pass audited against the Slint sources

Screen-by-screen audit with before/after screenshots: rounded
surface-main content panel with 8px bezel (the Slack-style frame),
real splash card, QbzPrimaryButton/QbzCheckbox/QbzTextLink replicas,
accent arc spinner replacing BusyIndicator, custom thin auto-hide
scrollbars (Home + Library grid/list), carousel edge fades, toolbar
pill offset, QbzSlider/SeekBar metrics, alpha-format re-audit of all
8-digit hex literals. Pure look — zero functionality changes.

km
This commit is contained in:
vicrodh committed 2026-07-27 15:19:24 -06:00
1 parent 1b81b289d1
commit c06297c915
21 files changed
+326 -60

No files matched your search

+2
View File
@@ -48,6 +48,8 @@ fn main() {
"qml/LibraryView.qml",
"qml/QbzTheme.qml",
"qml/QbzIcon.qml",
"qml/QbzScrollBar.qml",
"qml/QbzSpinner.qml",
],
qrc_files: &qrc_refs,
..Default::default()
+22 -5
View File
@@ -1,14 +1,25 @@
// App shell — the QML port of crates/qbz-ui/ui/shell/AppShell.slint's
// chrome: HeaderBar (top, 42px) / { Sidebar | content | queue column } /
// NowPlayingBarSmall (bottom). The recovery affordance and logout live in
// the header (offline badge flyout + app menu), like the Slint shell.
// chrome: HeaderBar (top, 42px) / { Sidebar | content frame | queue
// column } / NowPlayingBarSmall (bottom).
//
// The window chrome is surface-card throughout; the content area is a
// ROUNDED surface-main panel inset 8px left/right/bottom (0 top — it
// butts the header), the "Slack-style bezel on all four corners" of
// AppShell.slint:358-390. The recovery affordance and logout live in the
// header (offline badge flyout + app menu), like the Slint shell.
//
// POC-NOTE: the artwork-derived ambient background (AppearanceState
// "Ambient"/"Blurred art" modes) is not implemented — the Slint default
// is Off, which is what this static dark treatment matches.
import QtQuick
import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
// The window chrome is surface-card (header / sidebar / queue column /
// NPB all paint it too); the content panel contrasts in surface-main.
color: theme.surfaceCard
QbzTheme { id: theme }
@@ -58,14 +69,20 @@ Rectangle {
}
}
// Content frame — the view area between sidebar and queue column.
// Content frame — the rounded, inset surface-main panel (Radius.md,
// 8px gaps left/right/bottom, flush to the header).
Rectangle {
id: contentFrame
anchors.left: sidebar.right
anchors.right: queueColumn.left
anchors.top: header.bottom
anchors.bottom: npb.top
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.bottomMargin: 8
radius: theme.radiusMd
color: theme.surfaceMain
border.width: 0
clip: true
Loader {
+55 -10
View File
@@ -370,16 +370,47 @@ Rectangle {
onPageLeft: rail.contentX = Math.min(0, rail.contentX - step)
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
}
ListView {
id: rail
Item {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
delegate: AlbumCard { card: modelData }
ListView {
id: rail
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
delegate: AlbumCard { card: modelData }
}
// Cider-style edge fades (Carousel.slint): content dissolves
// into the page background at the scrolled edges.
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
opacity: rail.contentX > 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: theme.surfaceMain }
GradientStop { position: 1.0; color: "transparent" }
}
}
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
opacity: rail.contentX < maxScroll - 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: theme.surfaceMain }
}
}
}
}
@@ -688,7 +719,9 @@ Rectangle {
height: 56
Row {
x: 32
// Slint left-controls: x 32 + NavButtons (now a 0px
// placeholder) + 16px spacing -> the pill starts at 48.
x: 48
y: 25 - height / 2
height: tabPill.height
spacing: 16
@@ -805,10 +838,13 @@ Rectangle {
}
// --- Scrollable sections -----------------------------------------
Item {
width: parent.width
height: parent.height - 57
Flickable {
id: homeFlick
width: parent.width
height: parent.height - 57
height: parent.height
clip: true
contentWidth: width
contentHeight: homeContent.implicitHeight
@@ -988,5 +1024,14 @@ Rectangle {
}
}
}
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: homeFlick
}
}
}
}
+21 -3
View File
@@ -949,10 +949,10 @@ Rectangle {
height: parent.height - 57
clip: true
// Loading.
BusyIndicator {
// Loading (LoadingSpinner.slint: accent arc, 1s spin).
QbzSpinner {
anchors.centerIn: parent
running: QbzBridge.libraryLoading
size: 36
visible: QbzBridge.libraryLoading
}
@@ -1049,6 +1049,24 @@ Rectangle {
item: modelData
}
}
// Thin auto-hiding scrollbars (ListScrollbar replica).
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: grid
visible: grid.visible && grid.contentHeight > grid.height
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
visible: list.visible && list.contentHeight > list.height
}
}
}
+48 -20
View File
@@ -92,15 +92,16 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
radius: 4
color: root.tosAccepted ? theme.accent : "transparent"
border.color: root.tosAccepted ? theme.accent : theme.textMuted
border.width: 1
border.color: theme.textMuted
border.width: root.tosAccepted ? 0 : 1.5
Text {
QbzIcon {
anchors.centerIn: parent
visible: root.tosAccepted
text: "✓"
color: theme.textPrimary
font.pixelSize: 13
name: "check"
width: 12
height: 12
tintName: "primary"
}
MouseArea {
anchors.fill: parent
@@ -135,18 +136,19 @@ Rectangle {
Rectangle {
id: signInButton
width: parent.width
height: 44
height: 48
radius: theme.radiusSm
property bool canSignIn: root.tosAccepted && QbzBridge.loginPhase === 0
color: canSignIn ? (signInArea.containsMouse ? "#5a95f5" : theme.accent)
: theme.surfaceElevated
opacity: canSignIn ? 1.0 : 0.5
color: signInArea.containsMouse && canSignIn ? theme.accentHover : theme.accent
Behavior on color { ColorAnimation { duration: 150 } }
Text {
anchors.centerIn: parent
text: QbzBridge.tr("Sign in with your browser")
color: signInButton.canSignIn ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
color: theme.accentText
font.pixelSize: theme.fontButton
font.weight: theme.weightSemibold
}
MouseArea {
id: signInArea
@@ -185,13 +187,26 @@ Rectangle {
wrapMode: Text.WordWrap
}
Item { width: 1; height: 4 }
Text {
Item {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Cancel")
color: theme.accent
font.pixelSize: theme.fontBody
width: cancelText.implicitWidth
height: cancelText.implicitHeight + 3
Text {
id: cancelText
text: QbzBridge.tr("Cancel")
color: cancelArea.containsMouse ? theme.accent : theme.textMuted
font.pixelSize: theme.fontLink
}
Rectangle {
y: cancelText.implicitHeight + 1
width: cancelText.implicitWidth
height: 1
color: cancelArea.containsMouse ? theme.accent : theme.textMuted
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.cancelLogin()
}
@@ -335,13 +350,26 @@ Rectangle {
// Always visible (#553): without a previous session this
// opens the GUEST profile (user 0) — Local Library only,
// adopted by the account on the first real login.
Text {
Item {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Start offline (no access to Qobuz™ services)")
color: theme.accent
font.pixelSize: theme.fontBody
width: offlineText.implicitWidth
height: offlineText.implicitHeight + 3
Text {
id: offlineText
text: QbzBridge.tr("Start offline (no access to Qobuz™ services)")
color: offlineArea.containsMouse ? theme.accent : theme.textMuted
font.pixelSize: theme.fontLink
}
Rectangle {
y: offlineText.implicitHeight + 1
width: offlineText.implicitWidth
height: 1
color: offlineArea.containsMouse ? theme.accent : theme.textMuted
}
MouseArea {
id: offlineArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.startOffline()
}
+44 -13
View File
@@ -38,25 +38,56 @@ ApplicationWindow {
: (QbzBridge.screen === "shell" ? "AppShell.qml" : "")
}
// Splash: shown until the silent session restore resolves.
// Splash (SplashScreen.slint): the same 720px dark card as the login
// screen while the silent session restore resolves.
Rectangle {
anchors.fill: parent
color: "#0f0f0f"
visible: QbzBridge.screen === "splash"
Column {
Rectangle {
anchors.centerIn: parent
spacing: 16
Image {
anchors.horizontalCenter: parent.horizontalCenter
source: "assets/qbz-logo.png"
width: 120
height: 120
fillMode: Image.PreserveAspectFit
}
BusyIndicator {
anchors.horizontalCenter: parent.horizontalCenter
running: true
width: 720
height: splashColumn.height + 104
radius: 16
color: "#1a1a1a"
Column {
id: splashColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 52
spacing: 0
Image {
anchors.horizontalCenter: parent.horizontalCenter
source: "assets/qbz-logo.png"
width: 140
height: 140
fillMode: Image.PreserveAspectFit
}
Item { width: 1; height: 8 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "QBZ"
color: "#ffffff"
font.pixelSize: 29
font.weight: Font.DemiBold
font.letterSpacing: 8
}
Item { width: 1; height: 2 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "QOBUZ™ PLAYER"
color: "#888888"
font.pixelSize: 15
font.letterSpacing: 4
}
Item { width: 1; height: 32 }
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
size: 32
}
}
}
}
+9 -9
View File
@@ -108,14 +108,14 @@ Rectangle {
id: seekTrack
width: parent.width
height: 3
radius: 1.5
radius: 2
color: theme.surfaceElevated
// Buffered / cache line.
Rectangle {
width: parent.width * Math.min(Math.max(QbzBridge.npCacheProgress, 0), 1)
height: parent.height
radius: 1.5
radius: 2
color: theme.textMuted
opacity: 0.35
}
@@ -123,7 +123,7 @@ Rectangle {
Rectangle {
width: parent.width * Math.min(Math.max(QbzBridge.npProgress, 0), 1)
height: parent.height
radius: 1.5
radius: 2
color: theme.accent
}
// Hover thumb.
@@ -421,20 +421,20 @@ Rectangle {
Rectangle {
id: volTrack
width: parent.width
height: 3
radius: 1.5
height: 4
radius: 2
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Rectangle {
width: parent.width * QbzBridge.npVolume
height: parent.height
radius: 1.5
radius: 2
color: theme.accent
}
Rectangle {
width: 12
height: 12
radius: 6
width: 16
height: 16
radius: 8
color: theme.textPrimary
x: parent.width * QbzBridge.npVolume - width / 2
anchors.verticalCenter: parent.verticalCenter
+74
View File
@@ -0,0 +1,74 @@
// Custom vertical scrollbar — replica of primitives/ListScrollbar.slint.
// 14px gutter (not over the rows), 6px #ffffff12 track, 8px thumb
// (#ffffff44, widens to 10px / #ffffff77 on hover|drag), 48px min thumb,
// auto-hide 900ms after the last scroll. Attach as a sibling anchored to
// the right of a Flickable/GridView/ListView and set `target`.
import QtQuick
Item {
id: root
required property Flickable target
width: 14
visible: maxScroll > 0
readonly property real maxScroll: Math.max(0, target.contentHeight - target.height)
readonly property real thumbH: target.contentHeight > 0
? Math.max(48, target.height / target.contentHeight * height)
: height
readonly property real travel: Math.max(0, height - thumbH)
// Shown while scrolling, hovering, or dragging (auto-hide).
property bool scrollActive: false
readonly property bool shown: scrollActive || barArea.containsMouse || barArea.pressed
Connections {
target: root.target
function onContentYChanged() {
root.scrollActive = true
hideTimer.restart()
}
}
Timer {
id: hideTimer
interval: 900
onTriggered: root.scrollActive = false
}
// Track.
Rectangle {
width: 6
x: Math.round((parent.width - width) / 2)
height: parent.height
radius: 3
color: "#12ffffff"
opacity: root.shown ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 160 } }
}
// Thumb.
Rectangle {
width: barArea.containsMouse || barArea.pressed ? 10 : 8
x: Math.round((parent.width - width) / 2)
height: root.thumbH
y: root.maxScroll > 0 ? (root.target.contentY / root.maxScroll) * root.travel : 0
radius: 5
color: barArea.containsMouse || barArea.pressed ? "#77ffffff" : "#44ffffff"
opacity: root.shown ? 1.0 : 0.0
Behavior on width { NumberAnimation { duration: 100 } }
Behavior on opacity { NumberAnimation { duration: 160 } }
}
// Drag / click-to-position over the whole gutter.
MouseArea {
id: barArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: position(mouseY)
onPositionChanged: if (pressed) position(mouseY)
function position(my) {
if (root.travel <= 0) return
var frac = Math.min(1, Math.max(0, (my - root.thumbH / 2) / root.travel))
root.target.contentY = frac * root.maxScroll
}
}
}
+27
View File
@@ -0,0 +1,27 @@
// Loading spinner — replica of primitives/LoadingSpinner.slint: the
// Lucide loader-circle arc in accent, rotating 360deg/1000ms linear.
import QtQuick
Item {
id: root
property int size: 32
property bool spinning: true
width: size
height: size
QbzIcon {
name: "loader-circle"
width: root.size
height: root.size
tintName: "accent"
RotationAnimation on rotation {
running: root.spinning && root.visible
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
}
}
}
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 222 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 222 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 222 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 222 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 222 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 222 B