From 16e6232220d0bc0d37a90a15eac3fbac16d26abc Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:29:11 -0600 Subject: [PATCH] =?UTF-8?q?refactor(myqbz):=20button=20audit=20=E2=80=94?= =?UTF-8?q?=20app=20primitives=20+=20fix=20glyph=20centering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-2 Slice 12c (user directive: use the app's circular controls, no invented buttons; fix the glyph-stuck-left bug). - Hero §3.6: deleted the local ActionCircle; the 4 hero controls are now the shared CircleAction (Play primary = filled white disc + black glyph; the loading spinner is now available = more 1:1). Added `enabled` to CircleAction (additive). - All MyQBZ modal footers (Create / Edit / Mix / AddTo / Discography) now use the shared QbzPrimaryButton (+ a new `destructive` variant for Delete) and a new shared SecondaryButton (promoted from AppearanceSettings' inline copy). QbzPrimaryButton auto-sizes as a footer chip while staying full-width in login (verified: login stretches it in a VerticalLayout; footers take preferred width). - Kept legitimately-local icon+label controls (grid "+ New" CTA, AddToMixtape picker chips, toolbar sort/filter/view-mode pills) but verified every glyph is centered. - Fixed 3 glyph-stuck-left bugs (DiscographyBuilder checkboxes used non-snapped centering -> the Math.round(.../1px)*1px crisp idiom). Backend crates untouched. cargo check -p qbz-slint green. cc --- .../ui/myqbz/AddToMixtapeModal.slint | 59 ++++++------- .../qbz-slint/ui/myqbz/CreateMyQbzModal.slint | 53 +++--------- .../ui/myqbz/DiscographyBuilderView.slint | 82 ++++++------------- .../ui/myqbz/MixtapeDetailView.slint | 56 ++----------- .../qbz-slint/ui/myqbz/MyQbzEditModal.slint | 70 +++++----------- crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint | 57 +++---------- .../ui/primitives/CircleAction.slint | 10 ++- .../ui/primitives/QbzPrimaryButton.slint | 29 +++++-- .../ui/primitives/SecondaryButton.slint | 50 +++++++++++ 9 files changed, 184 insertions(+), 282 deletions(-) create mode 100644 crates/qbz-slint/ui/primitives/SecondaryButton.slint diff --git a/crates/qbz-slint/ui/myqbz/AddToMixtapeModal.slint b/crates/qbz-slint/ui/myqbz/AddToMixtapeModal.slint index a0c0839df..f97450d80 100644 --- a/crates/qbz-slint/ui/myqbz/AddToMixtapeModal.slint +++ b/crates/qbz-slint/ui/myqbz/AddToMixtapeModal.slint @@ -21,6 +21,8 @@ import { Radius } from "../foundation/radius.slint"; import { MyQbzAddState, MyQbzAddActions, MyQbzAddRow } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; +import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint"; +import { SecondaryButton } from "../primitives/SecondaryButton.slint"; import { LineEdit } from "std-widgets.slint"; // A single labelled radio option in the create-panel Kind toggle. @@ -156,50 +158,45 @@ component AddPickRow inherits Rectangle { } } -// A small footer button (Cancel / Back / create-buttons). -component FooterButton inherits Rectangle { +// The icon+label "+ Mixtapes / + Collections" picker-footer chips. Kept as a +// local secondary pill (rule #3 of the button audit: legitimately icon+label, +// so it does NOT collapse to the icon-only CircleAction). The leading icon sits +// in a centered [icon · gap · label] group — never jammed at the left edge. +component FooterIconButton inherits Rectangle { in property label; - in property primary: false; - in property enabled-look: true; in property icon; - in property has-icon: false; callback clicked(); height: 38px; border-radius: Radius.sm; - opacity: root.enabled-look ? 1.0 : 0.5; - background: root.primary - ? (ta.has-hover ? #ffffffd6 : Theme.accent) - : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); - border-width: root.primary ? 0px : 1px; + background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + border-width: 1px; border-color: Theme.border-subtle; HorizontalLayout { - padding-left: root.primary ? 20px : 16px; - padding-right: root.primary ? 20px : 16px; + padding-left: 16px; + padding-right: 16px; spacing: 6px; alignment: center; - if root.has-icon: VerticalLayout { + VerticalLayout { alignment: center; QbzIcon { source: root.icon; width: 14px; height: 14px; - tint: root.primary ? #ffffff : Theme.text-primary; + tint: Theme.text-primary; } } Text { text: root.label; - color: root.primary ? #ffffff : Theme.text-primary; + color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.semibold; vertical-alignment: center; } } ta := TouchArea { - mouse-cursor: root.enabled-look ? pointer : default; + mouse-cursor: pointer; clicked => { - if (root.enabled-look) { - root.clicked(); - } + root.clicked(); } } } @@ -376,10 +373,9 @@ export component AddToMixtapeModal inherits Rectangle { HorizontalLayout { padding: 12px; spacing: 8px; - FooterButton { + FooterIconButton { horizontal-stretch: 1; label: @tr("Mixtapes"); - has-icon: true; icon: @image-url("../assets/icons/plus.svg"); clicked => { MyQbzAddActions.show-create("mixtape"); @@ -387,10 +383,9 @@ export component AddToMixtapeModal inherits Rectangle { } // Hidden for track/playlist payloads (collections hold // whole albums only). - if !MyQbzAddState.restrict-to-mixtape: FooterButton { + if !MyQbzAddState.restrict-to-mixtape: FooterIconButton { horizontal-stretch: 1; label: @tr("Collections"); - has-icon: true; icon: @image-url("../assets/icons/plus.svg"); clicked => { MyQbzAddActions.show-create("collection"); @@ -460,25 +455,25 @@ export component AddToMixtapeModal inherits Rectangle { } // Spacer pushes the footer down. Rectangle { vertical-stretch: 1; } - // Footer: Back + Create & Add. + // Footer: Back (secondary) + Create & Add (primary) — + // the app's shared labeled primitives. HorizontalLayout { alignment: end; spacing: 8px; - FooterButton { + SecondaryButton { label: @tr("Back"); - enabled-look: !MyQbzAddState.create-busy; + height: 38px; + enabled: !MyQbzAddState.create-busy; clicked => { MyQbzAddActions.create-back(); } } - FooterButton { + QbzPrimaryButton { label: @tr("Create & Add"); - primary: true; - enabled-look: root.can-create; + height: 38px; + enabled: root.can-create; clicked => { - if (root.can-create) { - MyQbzAddActions.create-and-add(); - } + MyQbzAddActions.create-and-add(); } } } diff --git a/crates/qbz-slint/ui/myqbz/CreateMyQbzModal.slint b/crates/qbz-slint/ui/myqbz/CreateMyQbzModal.slint index 053e4b2c9..6d81c397c 100644 --- a/crates/qbz-slint/ui/myqbz/CreateMyQbzModal.slint +++ b/crates/qbz-slint/ui/myqbz/CreateMyQbzModal.slint @@ -14,6 +14,8 @@ import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { MyQbzCreateState, MyQbzCreateActions } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint"; +import { SecondaryButton } from "../primitives/SecondaryButton.slint"; import { LineEdit } from "std-widgets.slint"; // A single labelled radio option in the Kind toggle. @@ -163,55 +165,26 @@ export component CreateMyQbzModal inherits Rectangle { } } - // Cancel + Create. + // Cancel + Create — the app's shared labeled primitives + // (auto-width, 38px footer chips). HorizontalLayout { alignment: end; spacing: 8px; // Cancel (secondary). - Rectangle { - width: cancel-label.preferred-width + 32px; + SecondaryButton { + label: @tr("Cancel"); height: 38px; - border-radius: Radius.sm; - background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; - border-width: 1px; - border-color: Theme.border-subtle; - cancel-label := Text { - text: @tr("Cancel"); - color: Theme.text-primary; - font-size: Typography.body; - font-weight: Typography.semibold; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - } - cancel-ta := TouchArea { - mouse-cursor: pointer; - clicked => { - MyQbzCreateActions.close(); - } + clicked => { + MyQbzCreateActions.close(); } } // Create (primary) — label reuses the empty-state CTA. - Rectangle { - width: create-label.preferred-width + 40px; + QbzPrimaryButton { + label: root.is-mixtape ? @tr("+ New Mixtape") : @tr("+ New Collection"); height: 38px; - border-radius: Radius.sm; - opacity: root.can-create ? 1.0 : 0.5; - background: create-ta.has-hover ? #ffffffd6 : Theme.accent; - create-label := Text { - text: root.is-mixtape ? @tr("+ New Mixtape") : @tr("+ New Collection"); - color: #ffffff; - font-size: Typography.body; - font-weight: Typography.semibold; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - } - create-ta := TouchArea { - mouse-cursor: root.can-create ? pointer : default; - clicked => { - if (root.can-create) { - MyQbzCreateActions.submit(); - } - } + enabled: root.can-create; + clicked => { + MyQbzCreateActions.submit(); } } } diff --git a/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint b/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint index f1ad5bb3d..245dcc790 100644 --- a/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint +++ b/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint @@ -14,6 +14,8 @@ import { Typography } from "../foundation/typography.slint"; import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; +import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint"; +import { SecondaryButton } from "../primitives/SecondaryButton.slint"; // ── Concrete dark-theme values (spec §0) ────────────────────────────────── // Resolved here as global consts so the whole view shares one source. @@ -314,8 +316,8 @@ component CandidateRow inherits Rectangle { source: @image-url("../assets/icons/check.svg"); width: 11px; height: 11px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: D.btn-primary-text; } TouchArea { @@ -718,8 +720,8 @@ export component DiscographyBuilderView inherits Rectangle { source: @image-url("../assets/icons/check.svg"); width: 11px; height: 11px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: D.btn-primary-text; } // Indeterminate dash. @@ -728,8 +730,8 @@ export component DiscographyBuilderView inherits Rectangle { width: 9px; height: 2px; background: D.accent; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } TouchArea { mouse-cursor: pointer; @@ -819,64 +821,30 @@ export component DiscographyBuilderView inherits Rectangle { font-size: 13px; } } - // .footer-actions. + // .footer-actions — the app's shared labeled primitives. HorizontalLayout { spacing: 8px; alignment: end; - // Cancel (secondary-btn). - Rectangle { - width: cancel-row.preferred-width + 32px; + property create-disabled: + DiscoBuilderState.creating + || DiscoBuilderState.selected-count == 0 + || DiscoBuilderState.collection-name == ""; + // Cancel (secondary). + SecondaryButton { + label: @tr("Cancel"); height: 36px; - background: cancel-ta.has-hover ? D.bg-hover : D.bg-secondary; - border-radius: 8px; - border-width: 1px; - border-color: D.bg-tertiary; - cancel-row := HorizontalLayout { - alignment: center; - Text { - text: @tr("Cancel"); - color: D.text-primary; - font-size: 13px; - font-weight: 600; - vertical-alignment: center; - } - } - cancel-ta := TouchArea { - mouse-cursor: pointer; - clicked => { - DiscoBuilderActions.back(); - } + clicked => { + DiscoBuilderActions.back(); } } - // Create (primary-btn). - create-btn := Rectangle { - property disabled: - DiscoBuilderState.creating - || DiscoBuilderState.selected-count == 0 - || DiscoBuilderState.collection-name == ""; - width: create-row.preferred-width + 40px; + // Create (primary). + QbzPrimaryButton { + label: DiscoBuilderState.creating + ? @tr("Loading...") : @tr("Create Collection"); height: 36px; - background: D.accent; - border-radius: 8px; - opacity: create-btn.disabled ? 0.45 : 1.0; - create-row := HorizontalLayout { - alignment: center; - Text { - text: DiscoBuilderState.creating - ? @tr("Loading...") : @tr("Create Collection"); - color: D.btn-primary-text; - font-size: 13px; - font-weight: 600; - vertical-alignment: center; - } - } - TouchArea { - mouse-cursor: create-btn.disabled ? default : pointer; - clicked => { - if (!create-btn.disabled) { - DiscoBuilderActions.create(); - } - } + enabled: !parent.create-disabled; + clicked => { + DiscoBuilderActions.create(); } } } diff --git a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint index 09afdd1a0..e45685c66 100644 --- a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint +++ b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint @@ -25,6 +25,7 @@ import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, TrackItem, NavState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { CircleAction } from "../primitives/CircleAction.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; @@ -32,46 +33,6 @@ import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { CollectionMosaic } from "CollectionMosaic.slint"; -// --- Hero action button (.action-btn-circle family) --------------------- -// Ghost circle (translucent fill + faint ring) homologated with the album / -// playlist transport rows; `.primary` keeps the same ghost look (the Tauri -// CTA fills accent but our transport row family is ghost everywhere). `enabled` -// dims + disables (item-count == 0). -component ActionCircle inherits Rectangle { - in property icon; - in property primary: false; - in property diameter: 44px; - in property enabled: true; - in property glyph: 19px; - callback clicked(); - - width: root.diameter; - height: root.diameter; - border-radius: root.diameter / 2; - opacity: root.enabled ? 1.0 : 0.4; - background: root.primary - ? (ac-ta.has-hover ? #ffffffd6 : #ffffff) - : (ac-ta.has-hover ? Theme.surface-hover : transparent); - border-width: root.primary ? 0px : 1px; - border-color: ac-ta.has-hover ? Theme.text-disabled : Theme.border-subtle; - QbzIcon { - source: root.icon; - width: root.glyph; - height: root.glyph; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - tint: root.primary ? #000000 : (ac-ta.has-hover ? Theme.text-primary : Theme.text-secondary); - } - ac-ta := TouchArea { - mouse-cursor: root.enabled ? pointer : default; - clicked => { - if (root.enabled) { - root.clicked(); - } - } - } -} - // --- One sort / type-filter dropdown row -------------------------------- component MenuOption inherits Rectangle { in property label; @@ -874,22 +835,21 @@ export component MixtapeDetailView inherits Rectangle { HorizontalLayout { spacing: 8px; alignment: start; - // (1) Play — primary. - ActionCircle { + // (1) Play — primary (filled white disc + black glyph; + // the shared CircleAction.primary, spec §3.6). + CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 44px; - glyph: 20px; enabled: MyQbzDetailState.item-count > 0; clicked => { MyQbzDetailActions.play-all(); } } // (2) Shuffle. VerticalLayout { alignment: center; - ActionCircle { + CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); diameter: 40px; - glyph: 18px; enabled: MyQbzDetailState.item-count > 0; clicked => { MyQbzDetailActions.shuffle(); } } @@ -897,10 +857,9 @@ export component MixtapeDetailView inherits Rectangle { // (3) DJ-mix (DjIcon) — STUB click (modal is a later slice). VerticalLayout { alignment: center; - ActionCircle { + CircleAction { icon: @image-url("../assets/icons/turntable.svg"); diameter: 40px; - glyph: 18px; enabled: MyQbzDetailState.item-count > 0; clicked => { MyQbzDetailActions.dj-mix(); } } @@ -911,10 +870,9 @@ export component MixtapeDetailView inherits Rectangle { overflow-anchor := Rectangle { width: 40px; height: 40px; - ActionCircle { + CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); diameter: 40px; - glyph: 18px; clicked => { overflow-menu.show(); } } overflow-menu := PopupWindow { diff --git a/crates/qbz-slint/ui/myqbz/MyQbzEditModal.slint b/crates/qbz-slint/ui/myqbz/MyQbzEditModal.slint index 48b7f3cd4..5e0377c61 100644 --- a/crates/qbz-slint/ui/myqbz/MyQbzEditModal.slint +++ b/crates/qbz-slint/ui/myqbz/MyQbzEditModal.slint @@ -16,6 +16,8 @@ import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { MyQbzEditState, MyQbzEditActions } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint"; +import { SecondaryButton } from "../primitives/SecondaryButton.slint"; import { LineEdit, TextEdit } from "std-widgets.slint"; export component MyQbzEditModal inherits Rectangle { @@ -96,67 +98,35 @@ export component MyQbzEditModal inherits Rectangle { wrap: word-wrap; } - // --- Footer (m-footer) ----------------------------------- + // --- Footer (m-footer) — shared labeled primitives ------- HorizontalLayout { alignment: end; spacing: 8px; // Cancel (secondary). - Rectangle { - width: cancel-label.preferred-width + 32px; + SecondaryButton { + label: @tr("Cancel"); height: 38px; - border-radius: Radius.sm; - background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; - border-width: 1px; - border-color: Theme.border-subtle; - cancel-label := Text { - text: @tr("Cancel"); - color: Theme.text-primary; - font-size: Typography.body; - font-weight: Typography.semibold; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - } - cancel-ta := TouchArea { - mouse-cursor: MyQbzEditState.busy ? default : pointer; - clicked => { - if (!MyQbzEditState.busy) { - MyQbzEditActions.close(); - } - } + enabled: !MyQbzEditState.busy; + clicked => { + MyQbzEditActions.close(); } } // Submit — primary (rename/description) or destructive - // (delete). - Rectangle { - width: submit-label.preferred-width + 40px; + // (delete) variant of the shared QbzPrimaryButton. + QbzPrimaryButton { + label: root.is-delete ? @tr("Delete") : @tr("Save"); height: 38px; - border-radius: Radius.sm; - opacity: root.can-submit ? 1.0 : 0.5; - background: root.is-delete - ? (submit-ta.has-hover ? #e57373d9 : #e57373) - : (submit-ta.has-hover ? #ffffffd6 : Theme.accent); - submit-label := Text { - text: root.is-delete ? @tr("Delete") : @tr("Save"); - color: #ffffff; - font-size: Typography.body; - font-weight: Typography.semibold; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - } - submit-ta := TouchArea { - mouse-cursor: root.can-submit ? pointer : default; - clicked => { - if (root.can-submit) { - if (root.is-rename) { - MyQbzEditActions.submit-rename(); - } else if (root.is-description) { - MyQbzEditActions.submit-description(); - } else { - MyQbzEditActions.confirm-delete(); - } - } + destructive: root.is-delete; + enabled: root.can-submit; + clicked => { + if (root.is-rename) { + MyQbzEditActions.submit-rename(); + } else if (root.is-description) { + MyQbzEditActions.submit-description(); + } else { + MyQbzEditActions.confirm-delete(); } } } diff --git a/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint b/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint index dab77028a..98ea6b0cb 100644 --- a/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint +++ b/crates/qbz-slint/ui/myqbz/MyQbzMixModal.slint @@ -23,6 +23,8 @@ import { Radius } from "../foundation/radius.slint"; import { MyQbzMixState, MyQbzMixActions } from "../state.slint"; import { QbzSlider } from "../primitives/QbzSlider.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; +import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint"; +import { SecondaryButton } from "../primitives/SecondaryButton.slint"; export component MyQbzMixModal inherits Rectangle { visible: MyQbzMixState.open; @@ -150,61 +152,28 @@ export component MyQbzMixModal inherits Rectangle { } } - // --- Footer (.tm-actions, right-aligned) ----------------- + // --- Footer (.tm-actions, right-aligned) — shared primitives HorizontalLayout { alignment: end; spacing: 12px; // Cancel (secondary). - Rectangle { - width: cancel-label.preferred-width + 32px; + SecondaryButton { + label: @tr("Cancel"); height: 38px; - border-radius: Radius.sm; - background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; - border-width: 1px; - border-color: Theme.border-subtle; - cancel-label := Text { - text: @tr("Cancel"); - color: Theme.text-primary; - font-size: Typography.body; - font-weight: Typography.semibold; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - } - cancel-ta := TouchArea { - mouse-cursor: MyQbzMixState.busy ? default : pointer; - clicked => { - if (!MyQbzMixState.busy) { - MyQbzMixActions.close(); - } - } + enabled: !MyQbzMixState.busy; + clicked => { + MyQbzMixActions.close(); } } // Shuffle / Add to queue (primary) — mixModal.confirm. - Rectangle { - width: confirm-label.preferred-width + 40px; + QbzPrimaryButton { + label: @tr("Add to queue"); height: 38px; - border-radius: Radius.sm; - opacity: root.can-shuffle ? 1.0 : 0.5; - background: confirm-ta.has-hover && root.can-shuffle - ? #ffffffd6 - : Theme.accent; - confirm-label := Text { - text: @tr("Add to queue"); - color: #ffffff; - font-size: Typography.body; - font-weight: Typography.semibold; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - } - confirm-ta := TouchArea { - mouse-cursor: root.can-shuffle ? pointer : default; - clicked => { - if (root.can-shuffle) { - MyQbzMixActions.shuffle(); - } - } + enabled: root.can-shuffle; + clicked => { + MyQbzMixActions.shuffle(); } } } diff --git a/crates/qbz-slint/ui/primitives/CircleAction.slint b/crates/qbz-slint/ui/primitives/CircleAction.slint index 4484dbf81..162185836 100644 --- a/crates/qbz-slint/ui/primitives/CircleAction.slint +++ b/crates/qbz-slint/ui/primitives/CircleAction.slint @@ -17,6 +17,9 @@ export component CircleAction inherits Rectangle { // While the album's first track is fetching, the icon swaps for a spinner. in property loading: false; in property diameter: 40px; + // When false the button dims and the click is gated (e.g. an empty + // collection's hero CTAs). Additive — existing callers default to enabled. + in property enabled: true; // Optional hover tooltip (empty = none), using the global TooltipOverlay. in property tooltip; callback clicked(); @@ -24,6 +27,7 @@ export component CircleAction inherits Rectangle { width: root.diameter; height: root.diameter; border-radius: root.diameter / 2; + opacity: root.enabled ? 1.0 : 0.4; // Overlay-matched contrast: solid white for the primary play, semi- // transparent white fill + bright 1.5px ring otherwise. background: root.primary @@ -52,9 +56,11 @@ export component CircleAction inherits Rectangle { } ta := TouchArea { - mouse-cursor: pointer; + mouse-cursor: root.enabled ? pointer : default; clicked => { - root.clicked(); + if (root.enabled) { + root.clicked(); + } } changed has-hover => { if (self.has-hover && root.tooltip != "") { diff --git a/crates/qbz-slint/ui/primitives/QbzPrimaryButton.slint b/crates/qbz-slint/ui/primitives/QbzPrimaryButton.slint index da507d6f2..ccaa160d9 100644 --- a/crates/qbz-slint/ui/primitives/QbzPrimaryButton.slint +++ b/crates/qbz-slint/ui/primitives/QbzPrimaryButton.slint @@ -7,22 +7,35 @@ import { Typography } from "../foundation/typography.slint"; export component QbzPrimaryButton inherits Rectangle { in property label; in property enabled: true; + // Destructive variant (e.g. the Delete confirm) — a red fill instead of the + // accent. Additive; existing callers default to the accent primary. + in property destructive: false; callback clicked(); height: 48px; border-radius: Radius.sm; opacity: root.enabled ? 1.0 : 0.5; - background: ta.has-hover && root.enabled ? Theme.accent-hover : Theme.accent; + background: root.destructive + ? (ta.has-hover && root.enabled ? #e57373d9 : #e57373) + : (ta.has-hover && root.enabled ? Theme.accent-hover : Theme.accent); animate background { duration: 150ms; } - Text { - text: root.label; - color: Theme.accent-text; - font-size: Typography.button; - font-weight: Typography.semibold; - horizontal-alignment: center; - vertical-alignment: center; + // A padded layout drives the preferred-width (label + 20px each side) so the + // button auto-sizes as a footer chip inside an `alignment: end` row, while + // still stretching full-width when the parent layout stretches it (login). + HorizontalLayout { + alignment: center; + padding-left: 20px; + padding-right: 20px; + Text { + text: root.label; + color: Theme.accent-text; + font-size: Typography.button; + font-weight: Typography.semibold; + horizontal-alignment: center; + vertical-alignment: center; + } } ta := TouchArea { diff --git a/crates/qbz-slint/ui/primitives/SecondaryButton.slint b/crates/qbz-slint/ui/primitives/SecondaryButton.slint new file mode 100644 index 000000000..9a0d762d1 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/SecondaryButton.slint @@ -0,0 +1,50 @@ +// Labeled SECONDARY button — the subdued sibling of QbzPrimaryButton, used for +// Cancel / Back actions. Promoted to a shared primitive from the inline copy in +// AppearanceSettings so the My QBZ modals + the Discography Builder footer read +// identically (the button audit, spec 12c). A bordered surface-elevated chip +// with a centered label; `enabled` dims + gates the click. +// +// Default 160px width matches AppearanceSettings; callers that need an +// auto-width chip (the right-aligned modal footers) set `width` explicitly. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Radius } from "../foundation/radius.slint"; +import { Typography } from "../foundation/typography.slint"; + +export component SecondaryButton inherits Rectangle { + in property label; + in property enabled: true; + callback clicked(); + + height: 34px; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated; + opacity: root.enabled ? 1.0 : 0.4; + + // Padded layout → preferred-width = label + 16px each side, so the button + // auto-sizes as a footer chip; AppearanceSettings sets `width: 160px` + // explicitly to keep its original fixed footprint. + HorizontalLayout { + alignment: center; + padding-left: 16px; + padding-right: 16px; + Text { + text: root.label; + color: Theme.text-secondary; + font-size: Typography.body; + font-weight: Typography.medium; + horizontal-alignment: center; + vertical-alignment: center; + } + } + ta := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } +}