feat(qbz-slint): app shell frame

M1 progress. Adds the post-login shell: HeaderBar (nav tabs and
search), Sidebar (playlists list and user card), PlayerBar (transport,
now-playing, volume), and AppShell composing them with the content
region as a placeholder.

app.slint gains an AppScreen enum; clicking sign-in or start-offline
navigates login -> shell (no real auth yet — that is M2). New layout
foundation tokens (sidebar width, header/player-bar heights) plus
border and hover semantic colors.

cc
This commit is contained in:
vicrodh committed 2026-05-18 12:34:01 -06:00
1 parent d5be0029b6
commit 7535869e61
9 files changed
+464 -5

No files matched your search

+22 -4
View File
@@ -8,8 +8,9 @@
//! is compiled from `ui/app.slint` by `build.rs`; `include_modules!` pulls in //! is compiled from `ui/app.slint` by `build.rs`; `include_modules!` pulls in
//! the generated Rust bindings. //! the generated Rust bindings.
//! //!
//! M1 status: foundation tokens + login screen. UI callbacks reach the //! M1 status: foundation tokens, login screen, and the app shell frame
//! typed-command layer; M2 wires that layer to `AppRuntime`. //! (sidebar / header / content / player bar). UI callbacks reach the typed-
//! command layer; M2 wires that layer to `AppRuntime` and real auth.
slint::include_modules!(); slint::include_modules!();
@@ -28,9 +29,26 @@ fn main() -> Result<(), slint::PlatformError> {
let window = AppWindow::new()?; let window = AppWindow::new()?;
window.on_sign_in_via_browser(|| dispatch(AppCommand::SignInViaBrowser)); // Sign-in and offline both reveal the app shell. During M1 this happens
// without real authentication; M2 gates the transition on a real session.
let weak = window.as_weak();
window.on_sign_in_via_browser(move || {
dispatch(AppCommand::SignInViaBrowser);
if let Some(win) = weak.upgrade() {
win.set_screen(AppScreen::Shell);
}
});
window.on_use_system_browser(|| dispatch(AppCommand::UseSystemBrowser)); window.on_use_system_browser(|| dispatch(AppCommand::UseSystemBrowser));
window.on_start_offline(|| dispatch(AppCommand::StartOffline));
let weak = window.as_weak();
window.on_start_offline(move || {
dispatch(AppCommand::StartOffline);
if let Some(win) = weak.upgrade() {
win.set_screen(AppScreen::Shell);
}
});
window.on_open_tos(|| dispatch(AppCommand::OpenTermsOfService)); window.on_open_tos(|| dispatch(AppCommand::OpenTermsOfService));
log::info!("[qbz-slint] window ready — login screen"); log::info!("[qbz-slint] window ready — login screen");
+11 -1
View File
@@ -6,6 +6,12 @@
import { Theme } from "foundation/semantic-colors.slint"; import { Theme } from "foundation/semantic-colors.slint";
import { LoginScreen } from "login/LoginScreen.slint"; import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
// Which top-level screen is shown. During M1 the screen switches on a
// login-button click without real authentication; M2 ties the switch
// to a real session.
export enum AppScreen { login, shell }
export component AppWindow inherits Window { export component AppWindow inherits Window {
title: "QBZ"; title: "QBZ";
@@ -15,13 +21,15 @@ export component AppWindow inherits Window {
min-height: 600px; min-height: 600px;
background: Theme.surface-main; background: Theme.surface-main;
in-out property <AppScreen> screen: AppScreen.login;
// Forwarded to the Rust layer, which maps them to typed AppCommands. // Forwarded to the Rust layer, which maps them to typed AppCommands.
callback sign-in-via-browser(); callback sign-in-via-browser();
callback use-system-browser(); callback use-system-browser();
callback start-offline(); callback start-offline();
callback open-tos(); callback open-tos();
LoginScreen { if root.screen == AppScreen.login: LoginScreen {
sign-in-via-browser => { sign-in-via-browser => {
root.sign-in-via-browser(); root.sign-in-via-browser();
} }
@@ -35,4 +43,6 @@ export component AppWindow inherits Window {
root.open-tos(); root.open-tos();
} }
} }
if root.screen == AppScreen.shell: AppShell { }
} }
@@ -0,0 +1,8 @@
// Shell layout metrics.
export global Layout {
out property <length> sidebar-width: 280px;
out property <length> header-height: 48px;
out property <length> player-bar-height: 88px;
out property <length> hairline: 1px;
}
@@ -22,4 +22,7 @@ export global Palette {
out property <color> warning: #fbbf24; out property <color> warning: #fbbf24;
out property <color> shadow: #00000066; // rgba(0,0,0,0.4) out property <color> shadow: #00000066; // rgba(0,0,0,0.4)
out property <color> alpha-6: #ffffff10; // ~6% white — hover fills
out property <color> alpha-8: #ffffff14; // ~8% white — hairline borders
} }
@@ -24,6 +24,12 @@ export global Theme {
out property <color> accent-pressed: Palette.blue-active; out property <color> accent-pressed: Palette.blue-active;
out property <color> accent-text: Palette.white; out property <color> accent-text: Palette.white;
// Surfaces — interaction
out property <color> surface-hover: Palette.alpha-6;
// Borders
out property <color> border-subtle: Palette.alpha-8;
// Effects // Effects
out property <color> card-shadow: Palette.shadow; out property <color> card-shadow: Palette.shadow;
} }
+69
View File
@@ -0,0 +1,69 @@
// App shell — the post-login frame: header bar, sidebar, content
// region, and player bar.
//
// The content region is a placeholder; the Discover / Home view lands
// in the next increment. Hairlines separate the regions.
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Sidebar } from "Sidebar.slint";
import { HeaderBar } from "HeaderBar.slint";
import { PlayerBar } from "PlayerBar.slint";
export component AppShell inherits Rectangle {
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
HeaderBar { }
Rectangle {
height: Layout.hairline;
background: Theme.border-subtle;
}
HorizontalLayout {
vertical-stretch: 1;
spacing: 0px;
Sidebar { }
// Content region — placeholder until the Discover/Home view.
Rectangle {
horizontal-stretch: 1;
background: Theme.surface-main;
VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
VerticalLayout {
spacing: Spacing.sm;
Text {
text: "Discover — Home";
color: Theme.text-primary;
font-size: 22px;
font-weight: Typography.semibold;
horizontal-alignment: center;
}
Text {
text: "Content view lands in the next increment";
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
}
}
}
}
Rectangle {
height: Layout.hairline;
background: Theme.border-subtle;
}
PlayerBar { }
}
}
+74
View File
@@ -0,0 +1,74 @@
// Top header bar — primary navigation tabs and the search field.
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { LineEdit } from "std-widgets.slint";
component NavTab inherits Rectangle {
in property <string> label;
in property <bool> active: false;
callback clicked();
height: 32px;
border-radius: Radius.sm;
background: root.active ? Theme.surface-elevated
: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: Spacing.md;
padding-right: Spacing.md;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: Typography.body;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component HeaderBar inherits Rectangle {
height: Layout.header-height;
background: Theme.surface-main;
HorizontalLayout {
padding-left: Spacing.md;
padding-right: Spacing.md;
spacing: Spacing.xs;
VerticalLayout {
alignment: center;
HorizontalLayout {
spacing: Spacing.xs;
NavTab { label: "Discover"; active: true; }
NavTab { label: "Library"; }
NavTab { label: "Local Library"; }
NavTab { label: "My QBZ"; }
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
HorizontalLayout {
alignment: center;
Rectangle {
width: 360px;
search := LineEdit {
placeholder-text: "Search";
}
}
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
// Bottom now-playing / transport bar.
//
// Shell-frame increment: controls are laid out and hover-reactive but
// not yet wired to playback. Real playback state arrives in a later
// milestone.
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
component GlyphButton inherits Rectangle {
in property <string> glyph;
in property <length> glyph-size: 18px;
callback clicked();
width: 34px;
height: 34px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
Text {
text: root.glyph;
font-size: root.glyph-size;
color: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
horizontal-alignment: center;
vertical-alignment: center;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component PlayerBar inherits Rectangle {
height: Layout.player-bar-height;
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// Progress line across the top edge.
Rectangle {
height: 2px;
background: Theme.surface-elevated;
Rectangle {
x: 0;
width: parent.width * 0.32;
background: Theme.accent;
}
}
HorizontalLayout {
vertical-stretch: 1;
padding-left: Spacing.md;
padding-right: Spacing.md;
spacing: Spacing.md;
// Transport controls.
VerticalLayout {
alignment: center;
HorizontalLayout {
spacing: 2px;
GlyphButton { glyph: "⇄"; }
GlyphButton { glyph: "⏮"; }
GlyphButton { glyph: "▶"; glyph-size: 22px; }
GlyphButton { glyph: "⏭"; }
GlyphButton { glyph: "↻"; }
}
}
// Now-playing.
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
HorizontalLayout {
alignment: center;
spacing: Spacing.sm;
Rectangle {
width: 48px;
height: 48px;
border-radius: Radius.sm;
background: Theme.surface-elevated;
}
VerticalLayout {
alignment: center;
Text {
text: "Nothing playing";
color: Theme.text-primary;
font-size: Typography.body;
}
Text {
text: "QBZ Slint POC";
color: Theme.text-muted;
font-size: 12px;
}
}
}
}
// Right-side controls.
VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: end;
spacing: Spacing.sm;
GlyphButton { glyph: "♡"; }
GlyphButton { glyph: "⤢"; }
VerticalLayout {
alignment: center;
Rectangle {
width: 90px;
height: 4px;
border-radius: 2px;
background: Theme.surface-elevated;
Rectangle {
x: 0;
width: parent.width * 0.6;
border-radius: 2px;
background: Theme.accent;
}
}
}
}
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
// Left navigation sidebar — playlists list plus the user card.
//
// Shell-frame increment: the playlist rows are placeholder names for
// visual reference. Real playlist data is wired in a later milestone.
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { ScrollView } from "std-widgets.slint";
component PlaylistRow inherits Rectangle {
in property <string> name;
height: 34px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: Spacing.sm;
padding-right: Spacing.sm;
spacing: Spacing.sm;
Rectangle {
width: 16px;
height: 16px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
background: Theme.accent;
opacity: 0.55;
}
Text {
text: root.name;
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
Text {
text: "›";
color: Theme.text-muted;
font-size: 16px;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: pointer;
}
}
export component Sidebar inherits Rectangle {
width: Layout.sidebar-width;
background: Theme.surface-main;
VerticalLayout {
padding: Spacing.md;
spacing: Spacing.md;
HorizontalLayout {
Text {
text: "PLAYLISTS";
color: Theme.text-muted;
font-size: 11px;
letter-spacing: 1.5px;
vertical-alignment: center;
horizontal-stretch: 1;
}
Text {
text: "+";
color: Theme.text-muted;
font-size: 18px;
vertical-alignment: center;
}
}
ScrollView {
vertical-stretch: 1;
VerticalLayout {
spacing: 2px;
for name in [
"N.W.O.B.H.M.", "Beatles", "Progressive Metal",
"Progressive Rock", "Rock en español", "Español",
"Metal of the world", "Curated Metal", "Soundtracks",
"Rock Mix", "Anime", "Classic Punk", "grls",
"One Hit Wonder",
] : PlaylistRow {
name: name;
}
}
}
Rectangle {
height: 56px;
border-radius: Radius.md;
background: Theme.surface-elevated;
HorizontalLayout {
padding: 10px;
spacing: Spacing.sm;
Rectangle {
width: 36px;
height: 36px;
border-radius: Radius.pill;
background: Theme.accent;
Text {
text: "Q";
color: Theme.accent-text;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: "QBZ User";
color: Theme.text-primary;
font-size: Typography.body;
}
Text {
text: "Slint POC";
color: Theme.text-muted;
font-size: 11px;
}
}
Text {
text: "⚙";
color: Theme.text-muted;
font-size: 16px;
vertical-alignment: center;
}
}
}
}
}