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/LibraryView.qml",
"qml/QbzTheme.qml", "qml/QbzTheme.qml",
"qml/QbzIcon.qml", "qml/QbzIcon.qml",
"qml/QbzScrollBar.qml",
"qml/QbzSpinner.qml",
], ],
qrc_files: &qrc_refs, qrc_files: &qrc_refs,
..Default::default() ..Default::default()
+22 -5
View File
@@ -1,14 +1,25 @@
// App shell — the QML port of crates/qbz-ui/ui/shell/AppShell.slint's // App shell — the QML port of crates/qbz-ui/ui/shell/AppShell.slint's
// chrome: HeaderBar (top, 42px) / { Sidebar | content | queue column } / // chrome: HeaderBar (top, 42px) / { Sidebar | content frame | queue
// NowPlayingBarSmall (bottom). The recovery affordance and logout live in // column } / NowPlayingBarSmall (bottom).
// the header (offline badge flyout + app menu), like the Slint shell. //
// 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 QtQuick
import com.blitzfc.qbz import com.blitzfc.qbz
Rectangle { Rectangle {
id: root 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 } 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 { Rectangle {
id: contentFrame id: contentFrame
anchors.left: sidebar.right anchors.left: sidebar.right
anchors.right: queueColumn.left anchors.right: queueColumn.left
anchors.top: header.bottom anchors.top: header.bottom
anchors.bottom: npb.top anchors.bottom: npb.top
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.bottomMargin: 8
radius: theme.radiusMd
color: theme.surfaceMain color: theme.surfaceMain
border.width: 0
clip: true clip: true
Loader { Loader {
+55 -10
View File
@@ -370,16 +370,47 @@ Rectangle {
onPageLeft: rail.contentX = Math.min(0, rail.contentX - step) onPageLeft: rail.contentX = Math.min(0, rail.contentX - step)
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step) onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
} }
ListView { Item {
id: rail
width: parent.width width: parent.width
height: 246 height: 246
orientation: ListView.Horizontal ListView {
spacing: 32 id: rail
clip: true anchors.fill: parent
boundsBehavior: Flickable.StopAtBounds orientation: ListView.Horizontal
model: sectionData.items spacing: 32
delegate: AlbumCard { card: modelData } 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 height: 56
Row { 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 y: 25 - height / 2
height: tabPill.height height: tabPill.height
spacing: 16 spacing: 16
@@ -805,10 +838,13 @@ Rectangle {
} }
// --- Scrollable sections ----------------------------------------- // --- Scrollable sections -----------------------------------------
Item {
width: parent.width
height: parent.height - 57
Flickable { Flickable {
id: homeFlick id: homeFlick
width: parent.width width: parent.width
height: parent.height - 57 height: parent.height
clip: true clip: true
contentWidth: width contentWidth: width
contentHeight: homeContent.implicitHeight 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 height: parent.height - 57
clip: true clip: true
// Loading. // Loading (LoadingSpinner.slint: accent arc, 1s spin).
BusyIndicator { QbzSpinner {
anchors.centerIn: parent anchors.centerIn: parent
running: QbzBridge.libraryLoading size: 36
visible: QbzBridge.libraryLoading visible: QbzBridge.libraryLoading
} }
@@ -1049,6 +1049,24 @@ Rectangle {
item: modelData 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 anchors.verticalCenter: parent.verticalCenter
radius: 4 radius: 4
color: root.tosAccepted ? theme.accent : "transparent" color: root.tosAccepted ? theme.accent : "transparent"
border.color: root.tosAccepted ? theme.accent : theme.textMuted border.color: theme.textMuted
border.width: 1 border.width: root.tosAccepted ? 0 : 1.5
Text { QbzIcon {
anchors.centerIn: parent anchors.centerIn: parent
visible: root.tosAccepted visible: root.tosAccepted
text: "✓" name: "check"
color: theme.textPrimary width: 12
font.pixelSize: 13 height: 12
tintName: "primary"
} }
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
@@ -135,18 +136,19 @@ Rectangle {
Rectangle { Rectangle {
id: signInButton id: signInButton
width: parent.width width: parent.width
height: 44 height: 48
radius: theme.radiusSm radius: theme.radiusSm
property bool canSignIn: root.tosAccepted && QbzBridge.loginPhase === 0 property bool canSignIn: root.tosAccepted && QbzBridge.loginPhase === 0
color: canSignIn ? (signInArea.containsMouse ? "#5a95f5" : theme.accent) opacity: canSignIn ? 1.0 : 0.5
: theme.surfaceElevated color: signInArea.containsMouse && canSignIn ? theme.accentHover : theme.accent
Behavior on color { ColorAnimation { duration: 150 } }
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: QbzBridge.tr("Sign in with your browser") text: QbzBridge.tr("Sign in with your browser")
color: signInButton.canSignIn ? theme.textPrimary : theme.textMuted color: theme.accentText
font.pixelSize: theme.fontBody font.pixelSize: theme.fontButton
font.weight: theme.weightMedium font.weight: theme.weightSemibold
} }
MouseArea { MouseArea {
id: signInArea id: signInArea
@@ -185,13 +187,26 @@ Rectangle {
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
Item { width: 1; height: 4 } Item { width: 1; height: 4 }
Text { Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Cancel") width: cancelText.implicitWidth
color: theme.accent height: cancelText.implicitHeight + 3
font.pixelSize: theme.fontBody 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 { MouseArea {
id: cancelArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.cancelLogin() onClicked: QbzBridge.cancelLogin()
} }
@@ -335,13 +350,26 @@ Rectangle {
// Always visible (#553): without a previous session this // Always visible (#553): without a previous session this
// opens the GUEST profile (user 0) — Local Library only, // opens the GUEST profile (user 0) — Local Library only,
// adopted by the account on the first real login. // adopted by the account on the first real login.
Text { Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Start offline (no access to Qobuz™ services)") width: offlineText.implicitWidth
color: theme.accent height: offlineText.implicitHeight + 3
font.pixelSize: theme.fontBody 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 { MouseArea {
id: offlineArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.startOffline() onClicked: QbzBridge.startOffline()
} }
+44 -13
View File
@@ -38,25 +38,56 @@ ApplicationWindow {
: (QbzBridge.screen === "shell" ? "AppShell.qml" : "") : (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 { Rectangle {
anchors.fill: parent anchors.fill: parent
color: "#0f0f0f" color: "#0f0f0f"
visible: QbzBridge.screen === "splash" visible: QbzBridge.screen === "splash"
Column { Rectangle {
anchors.centerIn: parent anchors.centerIn: parent
spacing: 16 width: 720
Image { height: splashColumn.height + 104
anchors.horizontalCenter: parent.horizontalCenter radius: 16
source: "assets/qbz-logo.png" color: "#1a1a1a"
width: 120
height: 120 Column {
fillMode: Image.PreserveAspectFit id: splashColumn
} anchors.left: parent.left
BusyIndicator { anchors.right: parent.right
anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top
running: true 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 id: seekTrack
width: parent.width width: parent.width
height: 3 height: 3
radius: 1.5 radius: 2
color: theme.surfaceElevated color: theme.surfaceElevated
// Buffered / cache line. // Buffered / cache line.
Rectangle { Rectangle {
width: parent.width * Math.min(Math.max(QbzBridge.npCacheProgress, 0), 1) width: parent.width * Math.min(Math.max(QbzBridge.npCacheProgress, 0), 1)
height: parent.height height: parent.height
radius: 1.5 radius: 2
color: theme.textMuted color: theme.textMuted
opacity: 0.35 opacity: 0.35
} }
@@ -123,7 +123,7 @@ Rectangle {
Rectangle { Rectangle {
width: parent.width * Math.min(Math.max(QbzBridge.npProgress, 0), 1) width: parent.width * Math.min(Math.max(QbzBridge.npProgress, 0), 1)
height: parent.height height: parent.height
radius: 1.5 radius: 2
color: theme.accent color: theme.accent
} }
// Hover thumb. // Hover thumb.
@@ -421,20 +421,20 @@ Rectangle {
Rectangle { Rectangle {
id: volTrack id: volTrack
width: parent.width width: parent.width
height: 3 height: 4
radius: 1.5 radius: 2
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated color: theme.surfaceElevated
Rectangle { Rectangle {
width: parent.width * QbzBridge.npVolume width: parent.width * QbzBridge.npVolume
height: parent.height height: parent.height
radius: 1.5 radius: 2
color: theme.accent color: theme.accent
} }
Rectangle { Rectangle {
width: 12 width: 16
height: 12 height: 16
radius: 6 radius: 8
color: theme.textPrimary color: theme.textPrimary
x: parent.width * QbzBridge.npVolume - width / 2 x: parent.width * QbzBridge.npVolume - width / 2
anchors.verticalCenter: parent.verticalCenter 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