feat(qbz-slint): foundation tokens and login screen
M1 progress. Adds the foundation token layer (palette, semantic colors, spacing, radius, typography) bound to the QBZ dark theme, three primitives (QbzCheckbox, QbzPrimaryButton, QbzTextLink), and the LoginScreen feature component. The login screen mirrors the Svelte LoginView: centered card, logo, QBZ wordmark, Qobuz ToS checkbox, "Sign in via Browser" button, system-browser and offline links, and the legal disclaimer. Colors are lifted verbatim from src/app.css; strings use @tr() markers. Rust side: typed AppCommand enum; UI callbacks dispatch through it. M2 wires the command handler to AppRuntime. cc
This commit is contained in:
15 files changed
+452
-10
No files matched your search
Generated
+1
@@ -5371,6 +5371,7 @@ dependencies = [
|
||||
name = "qbz-slint"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
"slint",
|
||||
"slint-build",
|
||||
|
||||
@@ -13,6 +13,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
slint = "1"
|
||||
log = { workspace = true }
|
||||
env_logger = "0.11"
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1"
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Typed command vocabulary for the Slint POC.
|
||||
//!
|
||||
//! Slint UI callbacks carry no payload and no behavior. The Rust layer
|
||||
//! maps each callback to one of these typed commands. In M2 the command
|
||||
//! handler is wired to `AppRuntime`; for now it logs, which proves the
|
||||
//! `Slint callback -> typed command -> Rust` path the POC ADR (section 8)
|
||||
//! requires.
|
||||
|
||||
/// Commands the login screen can emit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppCommand {
|
||||
/// Begin OAuth sign-in. Per the 2026-05-18 decision the POC uses the
|
||||
/// external system-browser flow only (no in-app webview).
|
||||
SignInViaBrowser,
|
||||
/// Explicitly use the system browser for OAuth.
|
||||
UseSystemBrowser,
|
||||
/// Start an offline-only session with no Qobuz access.
|
||||
StartOffline,
|
||||
/// Open the Qobuz Terms of Service in the system browser.
|
||||
OpenTermsOfService,
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
/// Stable identifier, used for logging and later instrumentation.
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SignInViaBrowser => "sign_in_via_browser",
|
||||
Self::UseSystemBrowser => "use_system_browser",
|
||||
Self::StartOffline => "start_offline",
|
||||
Self::OpenTermsOfService => "open_terms_of_service",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_ids_are_distinct_and_stable() {
|
||||
let all = [
|
||||
AppCommand::SignInViaBrowser,
|
||||
AppCommand::UseSystemBrowser,
|
||||
AppCommand::StartOffline,
|
||||
AppCommand::OpenTermsOfService,
|
||||
];
|
||||
let ids: Vec<&str> = all.iter().map(AppCommand::id).collect();
|
||||
assert_eq!(ids.len(), 4);
|
||||
let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
|
||||
assert_eq!(unique.len(), 4, "command ids must be unique");
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,32 @@
|
||||
//! Lives only on the private `slint-poc` branch (ADR-007). The Slint UI tree
|
||||
//! is compiled from `ui/app.slint` by `build.rs`; `include_modules!` pulls in
|
||||
//! the generated Rust bindings.
|
||||
//!
|
||||
//! M1 status: foundation tokens + login screen. UI callbacks reach the
|
||||
//! typed-command layer; M2 wires that layer to `AppRuntime`.
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
mod commands;
|
||||
|
||||
use commands::AppCommand;
|
||||
|
||||
/// Central command sink. M2 replaces the body with real `AppRuntime`
|
||||
/// dispatch; today it records that the typed-command path works.
|
||||
fn dispatch(command: AppCommand) {
|
||||
log::info!("[qbz-slint] AppCommand::{} dispatched", command.id());
|
||||
}
|
||||
|
||||
fn main() -> Result<(), slint::PlatformError> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
|
||||
window.on_sign_in_via_browser(|| dispatch(AppCommand::SignInViaBrowser));
|
||||
window.on_use_system_browser(|| dispatch(AppCommand::UseSystemBrowser));
|
||||
window.on_start_offline(|| dispatch(AppCommand::StartOffline));
|
||||
window.on_open_tos(|| dispatch(AppCommand::OpenTermsOfService));
|
||||
|
||||
log::info!("[qbz-slint] window ready — login screen");
|
||||
window.run()
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
// QBZ Slint POC — UI entry point.
|
||||
//
|
||||
// This file stays thin: it imports the foundation, shell, and feature
|
||||
// modules and exports the single top-level window. No raw visual constants
|
||||
// or domain logic live here (see the POC ADR, sections 13 and 21).
|
||||
// Stays thin: imports the feature screens and exports the single
|
||||
// top-level window. No raw visual constants or domain logic here
|
||||
// (POC ADR, sections 13 and 21).
|
||||
|
||||
import { Theme } from "foundation/semantic-colors.slint";
|
||||
import { LoginScreen } from "login/LoginScreen.slint";
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
title: "QBZ";
|
||||
@@ -10,13 +13,26 @@ export component AppWindow inherits Window {
|
||||
preferred-height: 760px;
|
||||
min-width: 940px;
|
||||
min-height: 600px;
|
||||
background: Theme.surface-main;
|
||||
|
||||
// Placeholder content. Replaced by the AppShell in the next M1 step
|
||||
// (foundation tokens + sidebar / header / content / player bar).
|
||||
Text {
|
||||
text: "QBZ — Slint POC";
|
||||
font-size: 22px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
// Forwarded to the Rust layer, which maps them to typed AppCommands.
|
||||
callback sign-in-via-browser();
|
||||
callback use-system-browser();
|
||||
callback start-offline();
|
||||
callback open-tos();
|
||||
|
||||
LoginScreen {
|
||||
sign-in-via-browser => {
|
||||
root.sign-in-via-browser();
|
||||
}
|
||||
use-system-browser => {
|
||||
root.use-system-browser();
|
||||
}
|
||||
start-offline => {
|
||||
root.start-offline();
|
||||
}
|
||||
open-tos => {
|
||||
root.open-tos();
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,25 @@
|
||||
// Raw palette — the only place hex colors are allowed to be literals.
|
||||
// Mirrors the QBZ dark theme defined in `src/app.css`. Feature and shell
|
||||
// components must never reference this directly; they consume semantic
|
||||
// tokens from `semantic-colors.slint` instead.
|
||||
|
||||
export global Palette {
|
||||
out property <color> black-0: #0f0f0f; // --bg-primary
|
||||
out property <color> black-1: #1a1a1a; // --bg-secondary
|
||||
out property <color> black-2: #2a2a2a; // --bg-tertiary
|
||||
out property <color> hover-1: #1f1f1f; // --bg-hover
|
||||
|
||||
out property <color> white: #ffffff; // --text-primary
|
||||
out property <color> gray-c: #cccccc; // --text-secondary
|
||||
out property <color> gray-8: #888888; // --text-muted
|
||||
out property <color> gray-5: #555555; // --text-disabled
|
||||
|
||||
out property <color> blue: #4285f4; // --accent-primary
|
||||
out property <color> blue-hover: #5a9bf4;// --accent-hover
|
||||
out property <color> blue-active: #3275e4;// --accent-active
|
||||
|
||||
out property <color> danger: #ef4444;
|
||||
out property <color> warning: #fbbf24;
|
||||
|
||||
out property <color> shadow: #00000066; // rgba(0,0,0,0.4)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Corner radius scale.
|
||||
|
||||
export global Radius {
|
||||
out property <length> sm: 8px;
|
||||
out property <length> md: 12px;
|
||||
out property <length> lg: 16px;
|
||||
out property <length> pill: 999px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Semantic color tokens — the contract every component consumes.
|
||||
//
|
||||
// Currently bound to the QBZ dark theme. When theme switching lands
|
||||
// (later in M1), `Theme` becomes the surface that swaps palettes; the
|
||||
// token names stay stable so components never change.
|
||||
|
||||
import { Palette } from "palette.slint";
|
||||
|
||||
export global Theme {
|
||||
// Surfaces
|
||||
out property <color> surface-main: Palette.black-0;
|
||||
out property <color> surface-card: Palette.black-1;
|
||||
out property <color> surface-elevated: Palette.black-2;
|
||||
|
||||
// Text
|
||||
out property <color> text-primary: Palette.white;
|
||||
out property <color> text-secondary: Palette.gray-c;
|
||||
out property <color> text-muted: Palette.gray-8;
|
||||
out property <color> text-disabled: Palette.gray-5;
|
||||
|
||||
// Accent
|
||||
out property <color> accent: Palette.blue;
|
||||
out property <color> accent-hover: Palette.blue-hover;
|
||||
out property <color> accent-pressed: Palette.blue-active;
|
||||
out property <color> accent-text: Palette.white;
|
||||
|
||||
// Effects
|
||||
out property <color> card-shadow: Palette.shadow;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Spacing scale. Components use these instead of raw pixel values.
|
||||
|
||||
export global Spacing {
|
||||
out property <length> xs: 4px;
|
||||
out property <length> sm: 8px;
|
||||
out property <length> md: 16px;
|
||||
out property <length> lg: 20px;
|
||||
out property <length> xl: 32px;
|
||||
out property <length> card-padding: 52px;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Typography scale — font sizes and weights.
|
||||
|
||||
export global Typography {
|
||||
out property <length> legal: 12px;
|
||||
out property <length> link: 13px;
|
||||
out property <length> body: 14px;
|
||||
out property <length> subtitle: 14px;
|
||||
out property <length> button: 16px;
|
||||
out property <length> wordmark: 28px;
|
||||
|
||||
out property <int> regular: 400;
|
||||
out property <int> medium: 500;
|
||||
out property <int> semibold: 600;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// QBZ login screen — first feature screen of the Slint POC.
|
||||
//
|
||||
// Visual parity target: the current Svelte `LoginView.svelte` on the QBZ
|
||||
// dark theme — centered card, logo, wordmark, ToS checkbox, browser
|
||||
// sign-in button, secondary links, and the legal disclaimer.
|
||||
//
|
||||
// No domain logic here: the buttons emit callbacks that the Rust layer
|
||||
// turns into typed commands (see `src/commands.rs`).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
|
||||
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
|
||||
import { QbzTextLink } from "../primitives/QbzTextLink.slint";
|
||||
|
||||
export component LoginScreen inherits Rectangle {
|
||||
in-out property <bool> tos-accepted: true;
|
||||
|
||||
callback sign-in-via-browser();
|
||||
callback use-system-browser();
|
||||
callback start-offline();
|
||||
callback open-tos();
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
|
||||
Rectangle {
|
||||
width: 720px;
|
||||
background: Theme.surface-card;
|
||||
border-radius: Radius.lg;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: Theme.card-shadow;
|
||||
drop-shadow-offset-y: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Spacing.card-padding;
|
||||
spacing: 0px;
|
||||
|
||||
// --- Brand ------------------------------------------------
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Image {
|
||||
source: @image-url("../assets/qbz-logo.png");
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
image-fit: contain;
|
||||
}
|
||||
}
|
||||
Rectangle { height: Spacing.sm; }
|
||||
Text {
|
||||
text: @tr("QBZ");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.wordmark;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 8px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
Rectangle { height: 2px; }
|
||||
Text {
|
||||
text: @tr("QOBUZ™ PLAYER");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.subtitle;
|
||||
letter-spacing: 4px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle { height: Spacing.xl; }
|
||||
|
||||
// --- Terms of Service row --------------------------------
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
spacing: Spacing.sm;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzCheckbox {
|
||||
checked <=> root.tos-accepted;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: @tr("I have read the");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
tos-area := TouchArea {
|
||||
width: tos-text.preferred-width;
|
||||
height: tos-text.preferred-height;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.open-tos();
|
||||
}
|
||||
tos-text := Text {
|
||||
text: @tr("Qobuz™ Terms of Service");
|
||||
color: Theme.accent;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: Spacing.lg; }
|
||||
|
||||
// --- Sign in ---------------------------------------------
|
||||
QbzPrimaryButton {
|
||||
label: @tr("Sign in via Browser");
|
||||
enabled: root.tos-accepted;
|
||||
clicked => {
|
||||
root.sign-in-via-browser();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: Spacing.md; }
|
||||
|
||||
// --- Secondary links -------------------------------------
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzTextLink {
|
||||
label: @tr("Use your system browser instead");
|
||||
clicked => {
|
||||
root.use-system-browser();
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 10px; }
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzTextLink {
|
||||
label: @tr("Start offline (no access to Qobuz™ services)");
|
||||
clicked => {
|
||||
root.start-offline();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: Spacing.xl; }
|
||||
|
||||
// --- Legal disclaimer ------------------------------------
|
||||
Text {
|
||||
text: @tr("QBZ requires an active Qobuz™ subscription. Your credentials are sent directly to Qobuz™.")
|
||||
+ "\n"
|
||||
+ @tr("This application uses the Qobuz API but is not certified by Qobuz.")
|
||||
+ " "
|
||||
+ @tr("Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Small square checkbox primitive matching the QBZ dark theme.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
|
||||
export component QbzCheckbox inherits Rectangle {
|
||||
in-out property <bool> checked: false;
|
||||
callback toggled();
|
||||
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
background: root.checked ? Theme.accent : transparent;
|
||||
border-width: root.checked ? 0px : 1.5px;
|
||||
border-color: Theme.text-muted;
|
||||
|
||||
Text {
|
||||
text: "✓";
|
||||
visible: root.checked;
|
||||
color: Theme.accent-text;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.checked = !root.checked;
|
||||
root.toggled();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Full-width accent button primitive.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
|
||||
export component QbzPrimaryButton inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> enabled: true;
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
enabled: root.enabled;
|
||||
mouse-cursor: root.enabled ? pointer : default;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Underlined, muted text-link primitive. Turns accent-colored on hover.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
|
||||
export component QbzTextLink inherits Rectangle {
|
||||
in property <string> label;
|
||||
callback clicked();
|
||||
|
||||
width: txt.preferred-width;
|
||||
height: txt.preferred-height + 3px;
|
||||
|
||||
txt := Text {
|
||||
x: 0;
|
||||
y: 0;
|
||||
text: root.label;
|
||||
color: ta.has-hover ? Theme.accent : Theme.text-muted;
|
||||
font-size: Typography.link;
|
||||
}
|
||||
|
||||
// Underline.
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: txt.preferred-height + 1px;
|
||||
width: txt.preferred-width;
|
||||
height: 1px;
|
||||
background: ta.has-hover ? Theme.accent : Theme.text-muted;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user