From c993e5395aaa94d276e9a784f68feed3072ddb80 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 14:47:22 +0700 Subject: [PATCH 01/22] fix(hero): clamp intro brand height to viewport width. The show-off brand size must respect the wide lockup aspect ratio so the title does not overflow and clip at welcome window dimensions. --- src/app/hero/layout.rs | 26 ++++++++++++++++++++++++++ src/app/hero/mod.rs | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index da395f8..5f67c4b 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -55,6 +55,20 @@ pub fn responsive_brand_height(viewport: WindowViewport) -> f32 { .min(BRAND_HERO_MAX / BRAND_ASPECT) } +/// Large centered brand height during the show-off phase. +/// +/// Unlike the old wireframe cube (square), the brand lockup is very wide +/// (`BRAND_ASPECT`), so height must be derived from available width. +pub fn show_off_brand_height(viewport: WindowViewport) -> f32 { + let hero = responsive_brand_height(viewport); + let width_limit = (viewport.width - WELCOME_EDGE_INSET_H * 2.0) / BRAND_ASPECT; + let available_height = (viewport.height - WELCOME_HEADER_BAND - WELCOME_ACTION_BAND).max(0.0); + // Prominent intro size that still fits the viewport; morphs down to `hero`. + width_limit + .min(available_height * 0.4) + .max(hero) +} + /// Center of the docked brand: `[toggle-left] [brand lockup]` (layout A). pub fn docked_brand_center(viewport: WindowViewport) -> (f32, f32) { let title_h = TITLE_BAR_HEIGHT.as_f32(); @@ -91,4 +105,16 @@ mod tests { assert_eq!(responsive_hero_size(440.0, 360.0), BRAND_HERO_MIN); assert_eq!(responsive_hero_size(1200.0, 900.0), BRAND_HERO_MAX); } + + #[test] + fn show_off_brand_fits_within_viewport_width() { + let viewport = WindowViewport { + width: 960.0, + height: 740.0, + }; + let height = show_off_brand_height(viewport); + let width = brand_width(height); + assert!(width <= viewport.width - WELCOME_EDGE_INSET_H * 2.0 + 1.0); + assert!(height >= responsive_brand_height(viewport)); + } } diff --git a/src/app/hero/mod.rs b/src/app/hero/mod.rs index 2b45db8..e253628 100644 --- a/src/app/hero/mod.rs +++ b/src/app/hero/mod.rs @@ -9,6 +9,7 @@ pub use brand::{ }; pub use layout::{ BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, docked_brand_center, - responsive_brand_height, responsive_hero_size, title_bar_left_padding, welcome_brand_center, + responsive_brand_height, responsive_hero_size, show_off_brand_height, + title_bar_left_padding, welcome_brand_center, }; pub use transition::{HERO_TRANSITION_DURATION, HeroTransition}; From 1f9405ae9b02103362ad984d3184be073d497629 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 14:47:26 +0700 Subject: [PATCH 02/22] feat(welcome): add chrome reveal animation state. Track intro timing so the welcome screen can ease in header, copy, and CTA chrome while the brand settles into its resting hero size. --- src/app/welcome/ui_state.rs | 63 ++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index 68d3983..851fce5 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -1,18 +1,52 @@ -//! Interactive welcome UI state (keyboard focus). +//! Interactive welcome UI state (keyboard focus, intro animation). + +use std::time::{Duration, Instant}; use gpui::{App, FocusHandle, Window}; +/// Brand morph + header, copy, and CTA fade-in on welcome load. +pub const CHROME_REVEAL_DURATION: Duration = Duration::from_millis(800); + pub struct WelcomeUiState { focus_claimed: bool, + started_at: Instant, } impl WelcomeUiState { pub fn new() -> Self { Self { focus_claimed: false, + started_at: Instant::now(), } } + /// Returns true while the intro animation is active. + pub fn tick(&mut self, now: Instant) -> bool { + self.intro_animating(now) + } + + pub fn intro_animating(&self, now: Instant) -> bool { + now.saturating_duration_since(self.started_at) < CHROME_REVEAL_DURATION + } + + pub fn reveal_progress(&self, now: Instant) -> f32 { + let elapsed = now.saturating_duration_since(self.started_at).as_secs_f32(); + let duration = CHROME_REVEAL_DURATION.as_secs_f32(); + if duration <= 0.0 { + return 1.0; + } + let t = (elapsed / duration).clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) + } + + pub fn chrome_opacity(&self, now: Instant) -> f32 { + self.reveal_progress(now) + } + + pub fn accepts_enter(&self) -> bool { + true + } + /// Requests keyboard focus once per welcome session. pub fn ensure_initial_focus( &mut self, @@ -30,3 +64,30 @@ impl WelcomeUiState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chrome_opacity_eases_in_from_start() { + let start = Instant::now(); + let ui = WelcomeUiState { + focus_claimed: false, + started_at: start, + }; + assert!((ui.chrome_opacity(start) - 0.0).abs() < 1e-3); + assert!(ui.chrome_opacity(start + CHROME_REVEAL_DURATION) >= 0.99); + } + + #[test] + fn intro_animating_only_during_reveal() { + let start = Instant::now(); + let ui = WelcomeUiState { + focus_claimed: false, + started_at: start, + }; + assert!(ui.intro_animating(start)); + assert!(!ui.intro_animating(start + CHROME_REVEAL_DURATION)); + } +} From 8dfe7ac9ccd9c441dba1b1ad4c3f10871d29cf25 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 14:47:29 +0700 Subject: [PATCH 03/22] feat(welcome): morph brand and fade in chrome on load. Start the intro transformation immediately with a large-to-hero brand scale and opacity reveal for header, copy, and enter CTA. --- src/app/welcome/view.rs | 63 ++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index 5244e69..71ba731 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -8,9 +8,10 @@ use gpui::{ use gpui_component::button::{Button, ButtonVariants as _}; use std::cell::Cell; use std::rc::Rc; +use std::time::Instant; use crate::app::gpui_callbacks::WindowAppHandler; -use crate::app::hero::{opencore_brand_image, responsive_brand_height}; +use crate::app::hero::{opencore_brand_image, responsive_brand_height, show_off_brand_height}; use crate::app::viewport::WindowViewport; use crate::shared::theme::{ BackgroundToken, ForegroundToken, OpenCoreTheme, SpacingToken, TypeRole, @@ -43,6 +44,7 @@ pub struct WelcomeCallbacks { /// Focusable shell for welcome keyboard input (Enter to complete). pub fn welcome_interactive_root( focus_handle: &FocusHandle, + accepts_enter: bool, on_enter: WindowAppHandler, content: impl IntoElement, ) -> impl IntoElement { @@ -74,7 +76,7 @@ pub fn welcome_interactive_root( .tab_index(0) .track_focus(focus_handle) .on_key_down(move |event: &KeyDownEvent, window, cx| { - if is_enter_keystroke(event) { + if accepts_enter && is_enter_keystroke(event) { on_enter(window, cx); } }) @@ -97,6 +99,7 @@ pub fn welcome_interactive_root( pub fn welcome_screen( theme: OpenCoreTheme, ui: &WelcomeUiState, + now: Instant, callbacks: WelcomeCallbacks, persistence_error: Option<&str>, viewport: WindowViewport, @@ -104,6 +107,9 @@ pub fn welcome_screen( ) -> impl IntoElement { let background = theme.surface(BackgroundToken::Primary); let hero_height = responsive_brand_height(viewport); + let show_off_height = show_off_brand_height(viewport); + let chrome_opacity = ui.chrome_opacity(now); + let reveal_progress = ui.reveal_progress(now); div() .size_full() @@ -114,14 +120,20 @@ pub fn welcome_screen( .opacity(content_opacity) .child(main_column( theme, - ui, callbacks, persistence_error, hero_height, + show_off_height, + chrome_opacity, + reveal_progress, )), ) } +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + fn is_enter_keystroke(event: &KeyDownEvent) -> bool { let key = event.keystroke.key.as_str(); matches!(key, "enter" | "return") && !event.is_held && !event.keystroke.modifiers.modified() @@ -129,11 +141,15 @@ fn is_enter_keystroke(event: &KeyDownEvent) -> bool { fn main_column( theme: OpenCoreTheme, - _ui: &WelcomeUiState, callbacks: WelcomeCallbacks, persistence_error: Option<&str>, hero_height: f32, + show_off_height: f32, + chrome_opacity: f32, + reveal_progress: f32, ) -> impl IntoElement { + let brand_height = lerp(show_off_height, hero_height, reveal_progress); + let mut centered_content = div() .w_full() .flex_1() @@ -141,7 +157,8 @@ fn main_column( .flex_col() .items_center() .justify_center() - .child(hero_block(theme, hero_height)); + .child(hero_brand_standalone(theme, brand_height)) + .child(hero_copy(theme, chrome_opacity)); if let Some(message) = persistence_error { let muted = theme.foreground(ForegroundToken::Muted); @@ -150,6 +167,7 @@ fn main_column( centered_content = centered_content.child( div() .w_full() + .opacity(chrome_opacity) .text_center() .text_size(px(TypeRole::MonoSm.size())) .font_family(mono) @@ -161,7 +179,7 @@ fn main_column( centered_content = centered_content .child(div().h(px(60.0))) - .child(action_row(theme, callbacks.clone())); + .child(action_row(theme, callbacks.clone(), chrome_opacity)); div() .size_full() @@ -170,7 +188,11 @@ fn main_column( .pt(px(EDGE_INSET_TOP)) .pb(px(EDGE_INSET_BOTTOM)) .px(px(EDGE_INSET_H)) - .child(header_row(theme, callbacks.clone())) + .child( + div() + .opacity(chrome_opacity) + .child(header_row(theme, callbacks.clone())), + ) .child(div().h(px(8.))) .child(centered_content) } @@ -224,13 +246,8 @@ fn hero_glow(theme: OpenCoreTheme) -> impl IntoElement { ]) } -fn hero_block(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { - let primary = theme.foreground(ForegroundToken::Primary); - let secondary = theme.foreground(ForegroundToken::Secondary); - let grotesk = SharedString::from("Space Grotesk"); - let spacing = theme.spacing; - - let hero_brand = div() +fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { + div() .relative() .w_full() .h(px(hero_height + 40.0)) @@ -238,7 +255,14 @@ fn hero_block(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { .items_center() .justify_center() .child(hero_glow(theme)) - .child(opencore_brand_image(theme, hero_height, 1.0)); + .child(opencore_brand_image(theme, hero_height, 1.0)) +} + +fn hero_copy(theme: OpenCoreTheme, chrome_opacity: f32) -> impl IntoElement { + let primary = theme.foreground(ForegroundToken::Primary); + let secondary = theme.foreground(ForegroundToken::Secondary); + let grotesk = SharedString::from("Space Grotesk"); + let spacing = theme.spacing; div() .w_full() @@ -251,7 +275,7 @@ fn hero_block(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { .flex() .flex_col() .items_center() - .child(hero_brand) + .opacity(chrome_opacity) .child(div().h(px(spacing.lg as f32))) .child( div() @@ -277,11 +301,16 @@ fn hero_block(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { ) } -fn action_row(theme: OpenCoreTheme, callbacks: WelcomeCallbacks) -> impl IntoElement { +fn action_row( + theme: OpenCoreTheme, + callbacks: WelcomeCallbacks, + chrome_opacity: f32, +) -> impl IntoElement { let spacing = theme.spacing; let on_enter = callbacks.on_enter; div() .w_full() + .opacity(chrome_opacity) .flex() .items_center() .justify_center() From 8d2399bbdda05c90c10a66b8ca5e1a08c867605e Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 14:47:32 +0700 Subject: [PATCH 04/22] feat(welcome): drive intro animation from desktop render loop. Request animation frames while the chrome reveal is active and pass intro timing into the welcome view on each render. --- src/app/desktop.rs | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index a2ec3d8..d71c3c2 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -191,7 +191,9 @@ impl OpenCoreApp { } fn ensure_welcome_focus(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(ui) = self.welcome_ui.as_mut() { + if let Some(ui) = self.welcome_ui.as_mut() + && ui.accepts_enter() + { ui.ensure_initial_focus(window, &self.focus_handle, cx); } } @@ -312,6 +314,13 @@ impl OpenCoreApp { window: &mut Window, cx: &mut Context, ) { + if self + .welcome_ui + .as_ref() + .is_some_and(|ui| !ui.accepts_enter()) + { + return; + } match reduce_welcome(command) { WelcomeOutcome::Pending => {} WelcomeOutcome::Completed => self.begin_hero_transition(window, cx), @@ -529,9 +538,14 @@ impl Render for OpenCoreApp { .hero_transition .map(|tx| tx.linear_progress(now)) .unwrap_or(0.0); + let welcome_intro_animating = self + .welcome_ui + .as_mut() + .is_some_and(|ui| ui.tick(now)); if should_request_animation_frame( self.hero_transition.as_ref(), self.theme_transition.as_ref(), + welcome_intro_animating, now, ) { window.request_animation_frame(); @@ -549,8 +563,9 @@ impl Render for OpenCoreApp { let content = match self.state.active_screen { ActiveScreen::Welcome => { - let _ = self.welcome_ui.get_or_insert_with(WelcomeUiState::new); - let ui = self.welcome_ui.as_ref().expect("inserted"); + let ui = self.welcome_ui.get_or_insert_with(WelcomeUiState::new); + ui.ensure_initial_focus(window, &self.focus_handle, cx); + let accepts_enter = ui.accepts_enter(); let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); @@ -561,10 +576,12 @@ impl Render for OpenCoreApp { .min_h_0() .child(welcome_interactive_root( &self.focus_handle, + accepts_enter, on_enter, welcome_screen( theme, ui, + now, callbacks, persistence_error, WindowViewport::from_window(window), @@ -631,9 +648,11 @@ impl Render for OpenCoreApp { fn should_request_animation_frame( hero_transition: Option<&HeroTransition>, theme_transition: Option<&ThemeTransition>, + welcome_intro_animating: bool, now: Instant, ) -> bool { - hero_transition.is_some_and(|tx| tx.is_active(now)) + welcome_intro_animating + || hero_transition.is_some_and(|tx| tx.is_active(now)) || theme_transition.is_some_and(|tx| tx.is_active(now)) } @@ -741,7 +760,7 @@ mod animation_gate_tests { #[test] fn hero_animation_gate_follows_active_transition() { let now = Instant::now(); - assert!(!should_request_animation_frame(None, None, now)); + assert!(!should_request_animation_frame(None, None, false, now)); let tx = HeroTransition::start( now, WindowViewport { @@ -750,14 +769,21 @@ mod animation_gate_tests { }, 52.0, ); - assert!(should_request_animation_frame(Some(&tx), None, now)); + assert!(should_request_animation_frame(Some(&tx), None, false, now)); assert!(!should_request_animation_frame( Some(&tx), None, + false, now + super::super::hero::HERO_TRANSITION_DURATION )); } + #[test] + fn welcome_intro_requests_animation_frames() { + let now = Instant::now(); + assert!(should_request_animation_frame(None, None, true, now)); + } + #[test] fn frame_gate_follows_theme_transition() { let now = Instant::now(); @@ -766,13 +792,14 @@ mod animation_gate_tests { crate::shared::theme::ThemeMode::Light, now, ); - assert!(should_request_animation_frame(None, Some(&tx), now)); + assert!(should_request_animation_frame(None, Some(&tx), false, now)); assert!(!should_request_animation_frame( None, Some(&tx), + false, now + crate::shared::theme::THEME_TRANSITION_DURATION )); - assert!(!should_request_animation_frame(None, None, now)); + assert!(!should_request_animation_frame(None, None, false, now)); } } From c10d678524920a6d637e37297bd6ee9f55e57a70 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:19:15 +0700 Subject: [PATCH 05/22] fix(welcome): defer intro clock until first render tick. Start chrome reveal timing on the first welcome render tick so the animation is not partially consumed during app construction. --- src/app/welcome/ui_state.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index 851fce5..ad6140a 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -9,28 +9,37 @@ pub const CHROME_REVEAL_DURATION: Duration = Duration::from_millis(800); pub struct WelcomeUiState { focus_claimed: bool, - started_at: Instant, + started_at: Option, } impl WelcomeUiState { pub fn new() -> Self { Self { focus_claimed: false, - started_at: Instant::now(), + started_at: None, } } - /// Returns true while the intro animation is active. + /// Advances intro timing and returns true while the reveal animation is active. pub fn tick(&mut self, now: Instant) -> bool { + self.started_at.get_or_insert(now); self.intro_animating(now) } pub fn intro_animating(&self, now: Instant) -> bool { - now.saturating_duration_since(self.started_at) < CHROME_REVEAL_DURATION + match self.started_at { + None => true, + Some(started_at) => { + now.saturating_duration_since(started_at) < CHROME_REVEAL_DURATION + } + } } pub fn reveal_progress(&self, now: Instant) -> f32 { - let elapsed = now.saturating_duration_since(self.started_at).as_secs_f32(); + let Some(started_at) = self.started_at else { + return 0.0; + }; + let elapsed = now.saturating_duration_since(started_at).as_secs_f32(); let duration = CHROME_REVEAL_DURATION.as_secs_f32(); if duration <= 0.0 { return 1.0; @@ -74,7 +83,7 @@ mod tests { let start = Instant::now(); let ui = WelcomeUiState { focus_claimed: false, - started_at: start, + started_at: Some(start), }; assert!((ui.chrome_opacity(start) - 0.0).abs() < 1e-3); assert!(ui.chrome_opacity(start + CHROME_REVEAL_DURATION) >= 0.99); @@ -85,7 +94,7 @@ mod tests { let start = Instant::now(); let ui = WelcomeUiState { focus_claimed: false, - started_at: start, + started_at: Some(start), }; assert!(ui.intro_animating(start)); assert!(!ui.intro_animating(start + CHROME_REVEAL_DURATION)); From e6053fb6f96a04e04d8d524310fc75a75b573133 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:19:49 +0700 Subject: [PATCH 06/22] fix(welcome): block Enter until chrome reveal finishes. Wire accepts_enter to intro animation state so keyboard and command handlers ignore completion until the brand settles at resting size. --- src/app/desktop.rs | 7 ++++--- src/app/welcome/ui_state.rs | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index d71c3c2..4e0aabd 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -191,8 +191,9 @@ impl OpenCoreApp { } fn ensure_welcome_focus(&mut self, window: &mut Window, cx: &mut Context) { + let now = Instant::now(); if let Some(ui) = self.welcome_ui.as_mut() - && ui.accepts_enter() + && ui.accepts_enter(now) { ui.ensure_initial_focus(window, &self.focus_handle, cx); } @@ -317,7 +318,7 @@ impl OpenCoreApp { if self .welcome_ui .as_ref() - .is_some_and(|ui| !ui.accepts_enter()) + .is_some_and(|ui| !ui.accepts_enter(Instant::now())) { return; } @@ -565,7 +566,7 @@ impl Render for OpenCoreApp { ActiveScreen::Welcome => { let ui = self.welcome_ui.get_or_insert_with(WelcomeUiState::new); ui.ensure_initial_focus(window, &self.focus_handle, cx); - let accepts_enter = ui.accepts_enter(); + let accepts_enter = ui.accepts_enter(now); let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index ad6140a..068746f 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -52,8 +52,8 @@ impl WelcomeUiState { self.reveal_progress(now) } - pub fn accepts_enter(&self) -> bool { - true + pub fn accepts_enter(&self, now: Instant) -> bool { + !self.intro_animating(now) } /// Requests keyboard focus once per welcome session. From 065cab8b07ad8f0ad9340721aee463edf45a659a Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:20:25 +0700 Subject: [PATCH 07/22] fix(welcome): disable chrome controls during intro reveal. Disable the Enter CTA and theme toggle while the brand morph is active and guard theme toggling in the desktop handler. --- src/app/desktop.rs | 7 +++++++ src/app/shell/workspace.rs | 2 +- src/app/welcome/theme_toggle.rs | 8 +++++++- src/app/welcome/view.rs | 27 +++++++++++++++++++++++---- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index 4e0aabd..a8284aa 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -330,6 +330,13 @@ impl OpenCoreApp { fn toggle_theme(&mut self, cx: &mut Context) { let now = Instant::now(); + if self + .welcome_ui + .as_ref() + .is_some_and(|ui| !ui.accepts_enter(now)) + { + return; + } let from = self.state.theme_mode(); let next = from.toggle(); match self.state.set_theme_mode(self.store.as_ref(), next) { diff --git a/src/app/shell/workspace.rs b/src/app/shell/workspace.rs index f886a99..21256ea 100644 --- a/src/app/shell/workspace.rs +++ b/src/app/shell/workspace.rs @@ -242,7 +242,7 @@ impl Render for ShellWorkspace { cx, )) .child(shell_title_brand(theme, self.brand_opacity)) - .child(theme_toggle_button(theme, on_toggle_theme)), + .child(theme_toggle_button(theme, on_toggle_theme, true)), ) .trailing( h_flex() diff --git a/src/app/welcome/theme_toggle.rs b/src/app/welcome/theme_toggle.rs index b52f3ea..9590d8e 100644 --- a/src/app/welcome/theme_toggle.rs +++ b/src/app/welcome/theme_toggle.rs @@ -3,11 +3,16 @@ use gpui::IntoElement; use gpui_component::IconName; use gpui_component::button::Button; +use gpui_component::Disableable; use crate::app::gpui_callbacks::WindowAppHandler; use crate::shared::theme::{OpenCoreTheme, ThemeMode}; -pub fn theme_toggle_button(theme: OpenCoreTheme, on_press: WindowAppHandler) -> impl IntoElement { +pub fn theme_toggle_button( + theme: OpenCoreTheme, + on_press: WindowAppHandler, + enabled: bool, +) -> impl IntoElement { let (icon, label) = match theme.mode { ThemeMode::Dark => (IconName::Sun, "Light"), ThemeMode::Light => (IconName::Moon, "Dark"), @@ -16,5 +21,6 @@ pub fn theme_toggle_button(theme: OpenCoreTheme, on_press: WindowAppHandler) -> .outline() .icon(icon) .label(label) + .disabled(!enabled) .on_click(move |_, window, cx| on_press(window, cx)) } diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index 71ba731..eb45d02 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -6,6 +6,7 @@ use gpui::{ ParentElement, SharedString, Styled, Window, WindowControlArea, div, px, relative, }; use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::Disableable; use std::cell::Cell; use std::rc::Rc; use std::time::Instant; @@ -110,6 +111,7 @@ pub fn welcome_screen( let show_off_height = show_off_brand_height(viewport); let chrome_opacity = ui.chrome_opacity(now); let reveal_progress = ui.reveal_progress(now); + let chrome_interactive = ui.accepts_enter(now); div() .size_full() @@ -126,6 +128,7 @@ pub fn welcome_screen( show_off_height, chrome_opacity, reveal_progress, + chrome_interactive, )), ) } @@ -147,6 +150,7 @@ fn main_column( show_off_height: f32, chrome_opacity: f32, reveal_progress: f32, + chrome_interactive: bool, ) -> impl IntoElement { let brand_height = lerp(show_off_height, hero_height, reveal_progress); @@ -179,7 +183,12 @@ fn main_column( centered_content = centered_content .child(div().h(px(60.0))) - .child(action_row(theme, callbacks.clone(), chrome_opacity)); + .child(action_row( + theme, + callbacks.clone(), + chrome_opacity, + chrome_interactive, + )); div() .size_full() @@ -191,13 +200,17 @@ fn main_column( .child( div() .opacity(chrome_opacity) - .child(header_row(theme, callbacks.clone())), + .child(header_row(theme, callbacks.clone(), chrome_interactive)), ) .child(div().h(px(8.))) .child(centered_content) } -fn header_row(theme: OpenCoreTheme, callbacks: WelcomeCallbacks) -> impl IntoElement { +fn header_row( + theme: OpenCoreTheme, + callbacks: WelcomeCallbacks, + chrome_interactive: bool, +) -> impl IntoElement { let primary = theme.foreground(ForegroundToken::Primary); let muted = theme.foreground(ForegroundToken::Muted); let mono = SharedString::from("Space Mono"); @@ -228,7 +241,11 @@ fn header_row(theme: OpenCoreTheme, callbacks: WelcomeCallbacks) -> impl IntoEle ), ) .child(div().flex_grow(1.)) - .child(theme_toggle_button(theme, callbacks.on_toggle_theme)) + .child(theme_toggle_button( + theme, + callbacks.on_toggle_theme, + chrome_interactive, + )) } fn hero_glow(theme: OpenCoreTheme) -> impl IntoElement { @@ -305,6 +322,7 @@ fn action_row( theme: OpenCoreTheme, callbacks: WelcomeCallbacks, chrome_opacity: f32, + chrome_interactive: bool, ) -> impl IntoElement { let spacing = theme.spacing; let on_enter = callbacks.on_enter; @@ -320,6 +338,7 @@ fn action_row( .primary() .label("Enter OpenCore") .h(px(ENTER_BUTTON_HEIGHT)) + .disabled(!chrome_interactive) .on_click(move |_, window, cx| { on_enter(window, cx); }), From 5d3ffdceb08cd52980ea56e346a3af495d6d4ee8 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:20:56 +0700 Subject: [PATCH 08/22] test(welcome): cover intro gating and brand morph endpoints. Add tests for deferred intro clock, accepts_enter blocking, narrow viewport clamping, and show-off brand settling at resting height. --- src/app/hero/layout.rs | 24 ++++++++++++++++++++++++ src/app/welcome/ui_state.rs | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index 5f67c4b..5499c12 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -117,4 +117,28 @@ mod tests { assert!(width <= viewport.width - WELCOME_EDGE_INSET_H * 2.0 + 1.0); assert!(height >= responsive_brand_height(viewport)); } + + #[test] + fn show_off_brand_fits_narrow_viewport() { + let viewport = WindowViewport { + width: 440.0, + height: 360.0, + }; + let height = show_off_brand_height(viewport); + let width = brand_width(height); + assert!(width <= viewport.width - WELCOME_EDGE_INSET_H * 2.0 + 1.0); + assert!(height >= responsive_brand_height(viewport)); + } + + #[test] + fn show_off_brand_settles_to_resting_height() { + let viewport = WindowViewport { + width: 960.0, + height: 740.0, + }; + let hero = responsive_brand_height(viewport); + let show_off = show_off_brand_height(viewport); + let settled = show_off + (hero - show_off); + assert!((settled - hero).abs() < 1e-3); + } } diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index 068746f..63d3295 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -99,4 +99,24 @@ mod tests { assert!(ui.intro_animating(start)); assert!(!ui.intro_animating(start + CHROME_REVEAL_DURATION)); } + + #[test] + fn intro_clock_starts_on_first_tick() { + let start = Instant::now(); + let mut ui = WelcomeUiState::new(); + assert!((ui.reveal_progress(start) - 0.0).abs() < 1e-3); + ui.tick(start); + assert!((ui.reveal_progress(start) - 0.0).abs() < 1e-3); + assert!((ui.reveal_progress(start + CHROME_REVEAL_DURATION) - 1.0).abs() < 1e-2); + } + + #[test] + fn accepts_enter_blocked_until_reveal_finishes() { + let start = Instant::now(); + let mut ui = WelcomeUiState::new(); + assert!(!ui.accepts_enter(start)); + ui.tick(start); + assert!(!ui.accepts_enter(start)); + assert!(ui.accepts_enter(start + CHROME_REVEAL_DURATION)); + } } From 28af8b1f99392054ff6fb5c1597641c8ba7f8bcb Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:23:40 +0700 Subject: [PATCH 09/22] fix(welcome): keep Enter CTA enabled during intro reveal. Rely on accepts_enter command gating instead of disabling the button so the CTA keeps its normal appearance while intro is active. --- src/app/welcome/view.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index eb45d02..7cb2d70 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -6,7 +6,6 @@ use gpui::{ ParentElement, SharedString, Styled, Window, WindowControlArea, div, px, relative, }; use gpui_component::button::{Button, ButtonVariants as _}; -use gpui_component::Disableable; use std::cell::Cell; use std::rc::Rc; use std::time::Instant; @@ -183,12 +182,7 @@ fn main_column( centered_content = centered_content .child(div().h(px(60.0))) - .child(action_row( - theme, - callbacks.clone(), - chrome_opacity, - chrome_interactive, - )); + .child(action_row(theme, callbacks.clone(), chrome_opacity)); div() .size_full() @@ -322,7 +316,6 @@ fn action_row( theme: OpenCoreTheme, callbacks: WelcomeCallbacks, chrome_opacity: f32, - chrome_interactive: bool, ) -> impl IntoElement { let spacing = theme.spacing; let on_enter = callbacks.on_enter; @@ -338,7 +331,6 @@ fn action_row( .primary() .label("Enter OpenCore") .h(px(ENTER_BUTTON_HEIGHT)) - .disabled(!chrome_interactive) .on_click(move |_, window, cx| { on_enter(window, cx); }), From a45d7930c1eae467dd9b530908346e2d833bee12 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:27:41 +0700 Subject: [PATCH 10/22] fix(welcome): keep theme toggle enabled during intro reveal. Remove disabled styling from the theme button and rely on handler-side gating while the chrome reveal animation is active. --- src/app/shell/workspace.rs | 2 +- src/app/welcome/theme_toggle.rs | 8 +------- src/app/welcome/view.rs | 17 +++-------------- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/app/shell/workspace.rs b/src/app/shell/workspace.rs index 21256ea..f886a99 100644 --- a/src/app/shell/workspace.rs +++ b/src/app/shell/workspace.rs @@ -242,7 +242,7 @@ impl Render for ShellWorkspace { cx, )) .child(shell_title_brand(theme, self.brand_opacity)) - .child(theme_toggle_button(theme, on_toggle_theme, true)), + .child(theme_toggle_button(theme, on_toggle_theme)), ) .trailing( h_flex() diff --git a/src/app/welcome/theme_toggle.rs b/src/app/welcome/theme_toggle.rs index 9590d8e..b52f3ea 100644 --- a/src/app/welcome/theme_toggle.rs +++ b/src/app/welcome/theme_toggle.rs @@ -3,16 +3,11 @@ use gpui::IntoElement; use gpui_component::IconName; use gpui_component::button::Button; -use gpui_component::Disableable; use crate::app::gpui_callbacks::WindowAppHandler; use crate::shared::theme::{OpenCoreTheme, ThemeMode}; -pub fn theme_toggle_button( - theme: OpenCoreTheme, - on_press: WindowAppHandler, - enabled: bool, -) -> impl IntoElement { +pub fn theme_toggle_button(theme: OpenCoreTheme, on_press: WindowAppHandler) -> impl IntoElement { let (icon, label) = match theme.mode { ThemeMode::Dark => (IconName::Sun, "Light"), ThemeMode::Light => (IconName::Moon, "Dark"), @@ -21,6 +16,5 @@ pub fn theme_toggle_button( .outline() .icon(icon) .label(label) - .disabled(!enabled) .on_click(move |_, window, cx| on_press(window, cx)) } diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index 7cb2d70..71ba731 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -110,7 +110,6 @@ pub fn welcome_screen( let show_off_height = show_off_brand_height(viewport); let chrome_opacity = ui.chrome_opacity(now); let reveal_progress = ui.reveal_progress(now); - let chrome_interactive = ui.accepts_enter(now); div() .size_full() @@ -127,7 +126,6 @@ pub fn welcome_screen( show_off_height, chrome_opacity, reveal_progress, - chrome_interactive, )), ) } @@ -149,7 +147,6 @@ fn main_column( show_off_height: f32, chrome_opacity: f32, reveal_progress: f32, - chrome_interactive: bool, ) -> impl IntoElement { let brand_height = lerp(show_off_height, hero_height, reveal_progress); @@ -194,17 +191,13 @@ fn main_column( .child( div() .opacity(chrome_opacity) - .child(header_row(theme, callbacks.clone(), chrome_interactive)), + .child(header_row(theme, callbacks.clone())), ) .child(div().h(px(8.))) .child(centered_content) } -fn header_row( - theme: OpenCoreTheme, - callbacks: WelcomeCallbacks, - chrome_interactive: bool, -) -> impl IntoElement { +fn header_row(theme: OpenCoreTheme, callbacks: WelcomeCallbacks) -> impl IntoElement { let primary = theme.foreground(ForegroundToken::Primary); let muted = theme.foreground(ForegroundToken::Muted); let mono = SharedString::from("Space Mono"); @@ -235,11 +228,7 @@ fn header_row( ), ) .child(div().flex_grow(1.)) - .child(theme_toggle_button( - theme, - callbacks.on_toggle_theme, - chrome_interactive, - )) + .child(theme_toggle_button(theme, callbacks.on_toggle_theme)) } fn hero_glow(theme: OpenCoreTheme) -> impl IntoElement { From 9b18495abebd18e0c6bd493ca2e2d7caef0f8e25 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:36:00 +0700 Subject: [PATCH 11/22] fix(hero): align welcome brand center with rendered layout. Model the welcome flex stack (titlebar, header, brand frame, copy, and CTA) so the hero transition overlay starts at the on-screen brand center. Share layout constants between the view and layout math. --- src/app/hero/layout.rs | 93 ++++++++++++++++++++++++++++++++++---- src/app/hero/mod.rs | 4 +- src/app/hero/transition.rs | 2 +- src/app/welcome/view.rs | 30 ++++++------ 4 files changed, 103 insertions(+), 26 deletions(-) diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index 5499c12..8446045 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -3,6 +3,7 @@ use super::brand::{BRAND_ASPECT, brand_width}; use crate::app::state::{HOME_WINDOW_HEIGHT, HOME_WINDOW_WIDTH}; use crate::app::viewport::WindowViewport; +use crate::shared::theme::TypeRole; use gpui_component::TITLE_BAR_HEIGHT; pub const BRAND_HERO_MIN: f32 = 220.0; @@ -14,9 +15,20 @@ pub const BRAND_SHELL_HEIGHT: f32 = 18.0; pub const SHELL_TOGGLE_WIDTH: f32 = 28.0; pub const SHELL_TITLE_GAP: f32 = 4.0; -const WELCOME_EDGE_INSET_H: f32 = 16.0; -const WELCOME_HEADER_BAND: f32 = 46.0; +pub const WELCOME_EDGE_INSET_H: f32 = 16.0; +pub const WELCOME_TITLEBAR_HEIGHT: f32 = 38.0; +pub const WELCOME_EDGE_INSET_TOP: f32 = 4.0; +pub const WELCOME_EDGE_INSET_BOTTOM: f32 = 20.0; +pub const WELCOME_HEADER_GAP: f32 = 8.0; +pub const WELCOME_HERO_BRAND_FRAME_EXTRA: f32 = 40.0; +pub const WELCOME_ACTION_SPACER: f32 = 60.0; +pub const WELCOME_ENTER_BUTTON_HEIGHT: f32 = 48.0; +pub const WELCOME_ACTION_BOTTOM_PADDING: f32 = 8.0; +pub const WELCOME_COPY_MAX_WIDTH: f32 = 680.0; + const WELCOME_ACTION_BAND: f32 = 260.0; +const WELCOME_COPY_BODY: &str = "OpenCore combines chat, terminal, editing, and Rust-native performance in one permissioned desktop environment. To leave the crowded cloud, polluted by leaks and unconsciousness, to return to a workspace that stays on your machine."; +const WELCOME_MONO_CHAR_WIDTH: f32 = 7.15; /// macOS traffic-light inset matches gpui-component `TITLE_BAR_LEFT_PADDING`. pub fn title_bar_left_padding() -> f32 { @@ -37,13 +49,50 @@ pub fn responsive_hero_size(available_width: f32, available_height: f32) -> f32 width_limit.min(height_limit).min(BRAND_HERO_MAX) } +/// Height of the welcome header row (title + subtitle). +pub fn welcome_header_row_height() -> f32 { + TypeRole::LabelMd.size() * TypeRole::LabelMd.line_height() + + 2.0 + + TypeRole::MonoSm.size() * TypeRole::MonoSm.line_height() +} + +/// Height of the static hero copy block below the brand. +pub fn welcome_copy_block_height(viewport: WindowViewport) -> f32 { + let text_width = WELCOME_COPY_MAX_WIDTH + .min((viewport.width - WELCOME_EDGE_INSET_H * 2.0).max(1.0)); + let chars_per_line = (text_width / WELCOME_MONO_CHAR_WIDTH).floor().max(1.0) as usize; + let line_count = WELCOME_COPY_BODY.len().div_ceil(chars_per_line); + let body_height = + TypeRole::MonoSm.size() * TypeRole::MonoSm.line_height() * line_count as f32; + 24.0 + + TypeRole::DisplayMd.size() * TypeRole::DisplayMd.line_height() + + 8.0 + + body_height +} + +/// Total height of the centered welcome stack below the header. +pub fn welcome_center_stack_height(viewport: WindowViewport, brand_height: f32) -> f32 { + (brand_height + WELCOME_HERO_BRAND_FRAME_EXTRA) + + welcome_copy_block_height(viewport) + + WELCOME_ACTION_SPACER + + WELCOME_ENTER_BUTTON_HEIGHT + + WELCOME_ACTION_BOTTOM_PADDING +} + /// Center of the large welcome brand in window coordinates. -pub fn welcome_brand_center(viewport: WindowViewport) -> (f32, f32) { - let hero_height = responsive_brand_height(viewport); - let content_top = WELCOME_HEADER_BAND; - let content_height = (viewport.height - content_top - WELCOME_ACTION_BAND).max(hero_height); - let center_y = content_top + content_height * 0.5; - (viewport.width * 0.5, center_y) +pub fn welcome_brand_center(viewport: WindowViewport, brand_height: f32) -> (f32, f32) { + let centered_region_top = WELCOME_TITLEBAR_HEIGHT + + WELCOME_EDGE_INSET_TOP + + welcome_header_row_height() + + WELCOME_HEADER_GAP; + let centered_region_bottom = viewport.height - WELCOME_EDGE_INSET_BOTTOM; + let centered_region_height = (centered_region_bottom - centered_region_top).max(0.0); + let stack_height = welcome_center_stack_height(viewport, brand_height); + let stack_top = + centered_region_top + (centered_region_height - stack_height).max(0.0) * 0.5; + let brand_center_y = + stack_top + (brand_height + WELCOME_HERO_BRAND_FRAME_EXTRA) * 0.5; + (viewport.width * 0.5, brand_center_y) } /// Responsive welcome brand height. @@ -62,7 +111,8 @@ pub fn responsive_brand_height(viewport: WindowViewport) -> f32 { pub fn show_off_brand_height(viewport: WindowViewport) -> f32 { let hero = responsive_brand_height(viewport); let width_limit = (viewport.width - WELCOME_EDGE_INSET_H * 2.0) / BRAND_ASPECT; - let available_height = (viewport.height - WELCOME_HEADER_BAND - WELCOME_ACTION_BAND).max(0.0); + let available_height = + (viewport.height - welcome_header_row_height() - WELCOME_ACTION_BAND).max(0.0); // Prominent intro size that still fits the viewport; morphs down to `hero`. width_limit .min(available_height * 0.4) @@ -141,4 +191,29 @@ mod tests { let settled = show_off + (hero - show_off); assert!((settled - hero).abs() < 1e-3); } + + #[test] + fn welcome_brand_center_matches_centered_stack_layout() { + let viewport = WindowViewport { + width: 960.0, + height: 740.0, + }; + let brand_height = responsive_brand_height(viewport); + let (_, center_y) = welcome_brand_center(viewport, brand_height); + let stack_top = WELCOME_TITLEBAR_HEIGHT + + WELCOME_EDGE_INSET_TOP + + welcome_header_row_height() + + WELCOME_HEADER_GAP + + ((viewport.height + - WELCOME_EDGE_INSET_BOTTOM + - (WELCOME_TITLEBAR_HEIGHT + + WELCOME_EDGE_INSET_TOP + + welcome_header_row_height() + + WELCOME_HEADER_GAP)) + - welcome_center_stack_height(viewport, brand_height)) + .max(0.0) + * 0.5; + let expected_y = stack_top + (brand_height + WELCOME_HERO_BRAND_FRAME_EXTRA) * 0.5; + assert!((center_y - expected_y).abs() < 1e-3); + } } diff --git a/src/app/hero/mod.rs b/src/app/hero/mod.rs index e253628..b388bd5 100644 --- a/src/app/hero/mod.rs +++ b/src/app/hero/mod.rs @@ -10,6 +10,8 @@ pub use brand::{ pub use layout::{ BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, docked_brand_center, responsive_brand_height, responsive_hero_size, show_off_brand_height, - title_bar_left_padding, welcome_brand_center, + title_bar_left_padding, welcome_brand_center, WELCOME_ACTION_SPACER, + WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, + WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, }; pub use transition::{HERO_TRANSITION_DURATION, HeroTransition}; diff --git a/src/app/hero/transition.rs b/src/app/hero/transition.rs index 89247b1..ec16934 100644 --- a/src/app/hero/transition.rs +++ b/src/app/hero/transition.rs @@ -23,7 +23,7 @@ pub struct HeroTransition { impl HeroTransition { pub fn start(now: Instant, welcome_viewport: WindowViewport, hero_size: f32) -> Self { - let start_center = welcome_brand_center(welcome_viewport); + let start_center = welcome_brand_center(welcome_viewport, hero_size); let end_center = docked_brand_center(home_transition_viewport()); Self { started_at: now, diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index 71ba731..e5280c8 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -11,7 +11,12 @@ use std::rc::Rc; use std::time::Instant; use crate::app::gpui_callbacks::WindowAppHandler; -use crate::app::hero::{opencore_brand_image, responsive_brand_height, show_off_brand_height}; +use crate::app::hero::{ + opencore_brand_image, responsive_brand_height, show_off_brand_height, + WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, + WELCOME_EDGE_INSET_TOP, WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, + WELCOME_TITLEBAR_HEIGHT, +}; use crate::app::viewport::WindowViewport; use crate::shared::theme::{ BackgroundToken, ForegroundToken, OpenCoreTheme, SpacingToken, TypeRole, @@ -24,12 +29,7 @@ const HERO_MAX_WIDTH: f32 = 680.0; const HERO_GLOW_INSET_H: f32 = 44.0; const HERO_GLOW_INSET_TOP: f32 = 46.0; const HERO_GLOW_INSET_BOTTOM: f32 = 34.0; -const EDGE_INSET_H: f32 = 16.0; -const EDGE_INSET_TOP: f32 = 4.0; -const EDGE_INSET_BOTTOM: f32 = 20.0; -const ENTER_BUTTON_HEIGHT: f32 = 48.0; const TITLEBAR_CONTROLS_INSET: f32 = 88.0; -const TITLEBAR_HEIGHT: f32 = 38.0; fn welcome_drag_should_start(pointer_down: bool, pointer_moved: bool) -> bool { pointer_down && pointer_moved @@ -80,14 +80,14 @@ pub fn welcome_interactive_root( on_enter(window, cx); } }) - .child(div().size_full().pt(px(TITLEBAR_HEIGHT)).child(content)) + .child(div().size_full().pt(px(WELCOME_TITLEBAR_HEIGHT)).child(content)) .child( div() .absolute() .top_0() .left(px(TITLEBAR_CONTROLS_INSET)) .right_0() - .h(px(TITLEBAR_HEIGHT)) + .h(px(WELCOME_TITLEBAR_HEIGHT)) .window_control_area(WindowControlArea::Drag) .on_mouse_down(MouseButton::Left, on_drag_down) .on_mouse_up(MouseButton::Left, on_drag_up) @@ -178,16 +178,16 @@ fn main_column( } centered_content = centered_content - .child(div().h(px(60.0))) + .child(div().h(px(WELCOME_ACTION_SPACER))) .child(action_row(theme, callbacks.clone(), chrome_opacity)); div() .size_full() .flex() .flex_col() - .pt(px(EDGE_INSET_TOP)) - .pb(px(EDGE_INSET_BOTTOM)) - .px(px(EDGE_INSET_H)) + .pt(px(WELCOME_EDGE_INSET_TOP)) + .pb(px(WELCOME_EDGE_INSET_BOTTOM)) + .px(px(WELCOME_EDGE_INSET_H)) .child( div() .opacity(chrome_opacity) @@ -250,7 +250,7 @@ fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32) -> impl IntoEle div() .relative() .w_full() - .h(px(hero_height + 40.0)) + .h(px(hero_height + WELCOME_HERO_BRAND_FRAME_EXTRA)) .flex() .items_center() .justify_center() @@ -319,7 +319,7 @@ fn action_row( Button::new("enter-opencore") .primary() .label("Enter OpenCore") - .h(px(ENTER_BUTTON_HEIGHT)) + .h(px(WELCOME_ENTER_BUTTON_HEIGHT)) .on_click(move |_, window, cx| { on_enter(window, cx); }), @@ -356,6 +356,6 @@ mod tests { fn welcome_hero_layout_constants() { assert_eq!(HERO_MAX_WIDTH, 680.0); assert_eq!(HERO_GLOW_INSET_H, 44.0); - assert_eq!(ENTER_BUTTON_HEIGHT, 48.0); + assert_eq!(WELCOME_ENTER_BUTTON_HEIGHT, 48.0); } } From a71aeb7e600f1a87af6f951fc2f0834be96bf52e Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:36:00 +0700 Subject: [PATCH 12/22] fix(welcome): start hero transition after home window resize. Capture the morph start position from the post-resize viewport so the transition overlay matches the welcome brand after the window grows. --- src/app/desktop.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index a8284aa..dee2284 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -295,11 +295,19 @@ impl OpenCoreApp { } fn begin_hero_transition(&mut self, window: &mut Window, cx: &mut Context) { - let viewport = WindowViewport::from_window(window); - match self.start_hero_transition(viewport) { + if self.hero_transition.is_some() { + return; + } + match self.state.persist_welcome_completion(self.store.as_ref()) { Ok(()) => { self.persistence_error = None; self.finish_screen_transition(window, cx); + let viewport = WindowViewport::from_window(window); + let now = Instant::now(); + let hero_height = responsive_brand_height(viewport); + self.hero_transition = + Some(HeroTransition::start(now, viewport, hero_height)); + cx.notify(); } Err(error) => { self.hero_transition = None; From f1fc45a13acf6a6851e81fab8bfd2727b79014f6 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:45:09 +0700 Subject: [PATCH 13/22] fix(welcome): hide static brand during hero transition morph. Only the transition overlay renders the lockup while morphing so the welcome and animated heroes cannot double up when positions differ. --- src/app/desktop.rs | 7 ++++++- src/app/welcome/view.rs | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index dee2284..c71a713 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -301,7 +301,6 @@ impl OpenCoreApp { match self.state.persist_welcome_completion(self.store.as_ref()) { Ok(()) => { self.persistence_error = None; - self.finish_screen_transition(window, cx); let viewport = WindowViewport::from_window(window); let now = Instant::now(); let hero_height = responsive_brand_height(viewport); @@ -546,6 +545,7 @@ impl Render for OpenCoreApp { let now = Instant::now(); self.settle_theme_transition(now); if self.settle_hero_transition(now) { + self.finish_screen_transition(window, cx); cx.notify(); } let theme = self.visual_theme(now); @@ -582,6 +582,10 @@ impl Render for OpenCoreApp { let ui = self.welcome_ui.get_or_insert_with(WelcomeUiState::new); ui.ensure_initial_focus(window, &self.focus_handle, cx); let accepts_enter = ui.accepts_enter(now); + let hide_welcome_brand = self + .hero_transition + .as_ref() + .is_some_and(|tx| tx.is_active(now)); let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); @@ -602,6 +606,7 @@ impl Render for OpenCoreApp { persistence_error, WindowViewport::from_window(window), welcome_content_opacity, + hide_welcome_brand, ), )) } diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index e5280c8..c83c01e 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -104,6 +104,7 @@ pub fn welcome_screen( persistence_error: Option<&str>, viewport: WindowViewport, content_opacity: f32, + hide_brand: bool, ) -> impl IntoElement { let background = theme.surface(BackgroundToken::Primary); let hero_height = responsive_brand_height(viewport); @@ -126,6 +127,7 @@ pub fn welcome_screen( show_off_height, chrome_opacity, reveal_progress, + hide_brand, )), ) } @@ -147,8 +149,10 @@ fn main_column( show_off_height: f32, chrome_opacity: f32, reveal_progress: f32, + hide_brand: bool, ) -> impl IntoElement { let brand_height = lerp(show_off_height, hero_height, reveal_progress); + let brand_opacity = if hide_brand { 0.0 } else { 1.0 }; let mut centered_content = div() .w_full() @@ -157,7 +161,7 @@ fn main_column( .flex_col() .items_center() .justify_center() - .child(hero_brand_standalone(theme, brand_height)) + .child(hero_brand_standalone(theme, brand_height, brand_opacity)) .child(hero_copy(theme, chrome_opacity)); if let Some(message) = persistence_error { @@ -246,7 +250,7 @@ fn hero_glow(theme: OpenCoreTheme) -> impl IntoElement { ]) } -fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { +fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32, opacity: f32) -> impl IntoElement { div() .relative() .w_full() @@ -254,6 +258,7 @@ fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32) -> impl IntoEle .flex() .items_center() .justify_center() + .opacity(opacity) .child(hero_glow(theme)) .child(opencore_brand_image(theme, hero_height, 1.0)) } From 58b51f52f1ccea48a0f6a10d0a932781a98eb80b Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:45:09 +0700 Subject: [PATCH 14/22] fix(welcome): defer home resize until hero transition completes. Capture the morph start from the current welcome viewport and keep the window size stable until the brand finishes moving to the title bar. --- src/app/hero/layout.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index 8446045..9ff02be 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -27,8 +27,9 @@ pub const WELCOME_ACTION_BOTTOM_PADDING: f32 = 8.0; pub const WELCOME_COPY_MAX_WIDTH: f32 = 680.0; const WELCOME_ACTION_BAND: f32 = 260.0; +const WELCOME_THEME_TOGGLE_HEIGHT: f32 = 32.0; const WELCOME_COPY_BODY: &str = "OpenCore combines chat, terminal, editing, and Rust-native performance in one permissioned desktop environment. To leave the crowded cloud, polluted by leaks and unconsciousness, to return to a workspace that stays on your machine."; -const WELCOME_MONO_CHAR_WIDTH: f32 = 7.15; +const WELCOME_BODY_CHAR_WIDTH: f32 = 5.85; /// macOS traffic-light inset matches gpui-component `TITLE_BAR_LEFT_PADDING`. pub fn title_bar_left_padding() -> f32 { @@ -51,16 +52,17 @@ pub fn responsive_hero_size(available_width: f32, available_height: f32) -> f32 /// Height of the welcome header row (title + subtitle). pub fn welcome_header_row_height() -> f32 { - TypeRole::LabelMd.size() * TypeRole::LabelMd.line_height() + let text_column = TypeRole::LabelMd.size() * TypeRole::LabelMd.line_height() + 2.0 - + TypeRole::MonoSm.size() * TypeRole::MonoSm.line_height() + + TypeRole::MonoSm.size() * TypeRole::MonoSm.line_height(); + text_column.max(WELCOME_THEME_TOGGLE_HEIGHT) } /// Height of the static hero copy block below the brand. pub fn welcome_copy_block_height(viewport: WindowViewport) -> f32 { let text_width = WELCOME_COPY_MAX_WIDTH .min((viewport.width - WELCOME_EDGE_INSET_H * 2.0).max(1.0)); - let chars_per_line = (text_width / WELCOME_MONO_CHAR_WIDTH).floor().max(1.0) as usize; + let chars_per_line = (text_width / WELCOME_BODY_CHAR_WIDTH).floor().max(1.0) as usize; let line_count = WELCOME_COPY_BODY.len().div_ceil(chars_per_line); let body_height = TypeRole::MonoSm.size() * TypeRole::MonoSm.line_height() * line_count as f32; From ccbe899eb808868c63aaaa68db5a5161825df54e Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:51:55 +0700 Subject: [PATCH 15/22] fix(hero): track rendered brand center for transition start. Sample the welcome brand image bounds each frame and capture that center and height when the hero morph begins, instead of approximating layout. --- src/app/desktop.rs | 31 ++++++++++++++++++++++++++--- src/app/gpui_callbacks.rs | 3 +++ src/app/hero/transition.rs | 34 +++++++++++++++++++++++++++++--- src/app/welcome/view.rs | 40 ++++++++++++++++++++++++++++++++++---- 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index c71a713..4bb9ea1 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -17,6 +17,7 @@ use gpui::{TitlebarOptions, point}; use gpui_component::Root; use gpui_component::dock::DockAreaState; +use crate::app::gpui_callbacks::BrandLayoutTracker; use crate::shared::preferences::{FilePreferencesStore, PreferencesError, PreferencesStore}; use crate::shared::theme::{OpenCoreTheme, ThemeTransition, apply_nothing_theme}; @@ -102,6 +103,7 @@ pub struct OpenCoreApp { _window_closed_subscription: gpui::Subscription, theme_transition: Option, hero_transition: Option, + welcome_brand_layout: Option<(f32, f32, f32)>, persistence_error: Option, #[cfg(debug_assertions)] dev_reset_state: DevResetState, @@ -153,6 +155,7 @@ impl OpenCoreApp { _window_closed_subscription: window_closed_subscription, theme_transition: None, hero_transition: None, + welcome_brand_layout: None, persistence_error: None, #[cfg(debug_assertions)] dev_reset_state: DevResetState::default(), @@ -284,7 +287,12 @@ impl OpenCoreApp { } let now = Instant::now(); let hero_height = responsive_brand_height(viewport); - self.hero_transition = Some(HeroTransition::start(now, viewport, hero_height)); + self.hero_transition = Some(HeroTransition::start( + now, + viewport, + hero_height, + self.welcome_brand_layout, + )); match self.state.persist_welcome_completion(self.store.as_ref()) { Ok(()) => Ok(()), Err(error) => { @@ -304,8 +312,12 @@ impl OpenCoreApp { let viewport = WindowViewport::from_window(window); let now = Instant::now(); let hero_height = responsive_brand_height(viewport); - self.hero_transition = - Some(HeroTransition::start(now, viewport, hero_height)); + self.hero_transition = Some(HeroTransition::start( + now, + viewport, + hero_height, + self.welcome_brand_layout, + )); cx.notify(); } Err(error) => { @@ -589,6 +601,17 @@ impl Render for OpenCoreApp { let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); + let track_brand_layout: Option = + if hide_welcome_brand { + None + } else { + let view = cx.entity().downgrade(); + Some(Rc::new(move |center_x, center_y, height, cx| { + let _ = view.update(cx, |app, _| { + app.welcome_brand_layout = Some((center_x, center_y, height)); + }); + })) + }; div() .size_full() @@ -607,6 +630,7 @@ impl Render for OpenCoreApp { WindowViewport::from_window(window), welcome_content_opacity, hide_welcome_brand, + track_brand_layout, ), )) } @@ -789,6 +813,7 @@ mod animation_gate_tests { height: 740.0, }, 52.0, + None, ); assert!(should_request_animation_frame(Some(&tx), None, false, now)); assert!(!should_request_animation_frame( diff --git a/src/app/gpui_callbacks.rs b/src/app/gpui_callbacks.rs index 57b42c0..d64300b 100644 --- a/src/app/gpui_callbacks.rs +++ b/src/app/gpui_callbacks.rs @@ -5,3 +5,6 @@ use std::rc::Rc; use gpui::{App, Window}; pub type WindowAppHandler = Rc; + +/// Reports the welcome brand image center and height in window coordinates. +pub type BrandLayoutTracker = Rc; diff --git a/src/app/hero/transition.rs b/src/app/hero/transition.rs index ec16934..9d25061 100644 --- a/src/app/hero/transition.rs +++ b/src/app/hero/transition.rs @@ -22,13 +22,21 @@ pub struct HeroTransition { } impl HeroTransition { - pub fn start(now: Instant, welcome_viewport: WindowViewport, hero_size: f32) -> Self { - let start_center = welcome_brand_center(welcome_viewport, hero_size); + pub fn start( + now: Instant, + welcome_viewport: WindowViewport, + hero_size: f32, + tracked_layout: Option<(f32, f32, f32)>, + ) -> Self { + let (start_center, start_size) = match tracked_layout { + Some((center_x, center_y, height)) => ((center_x, center_y), height), + None => (welcome_brand_center(welcome_viewport, hero_size), hero_size), + }; let end_center = docked_brand_center(home_transition_viewport()); Self { started_at: now, start_center, - start_size: hero_size, + start_size, end_center, end_size: BRAND_SHELL_HEIGHT, } @@ -106,6 +114,7 @@ mod tests { height: 740.0, }, 220.0, + None, ); let (sx, sy, ss) = tx.layout_at(now); assert!((sx - tx.start_center.0).abs() < 1e-3); @@ -118,4 +127,23 @@ mod tests { assert!((ey - tx.end_center.1).abs() < 1e-3); assert!((es - BRAND_SHELL_HEIGHT).abs() < 1e-3); } + + #[test] + fn transition_uses_tracked_start_layout() { + let now = Instant::now(); + let tracked = (480.0, 290.0, 76.0); + let tx = HeroTransition::start( + now, + WindowViewport { + width: 960.0, + height: 740.0, + }, + 220.0, + Some(tracked), + ); + let (sx, sy, ss) = tx.layout_at(now); + assert!((sx - tracked.0).abs() < 1e-3); + assert!((sy - tracked.1).abs() < 1e-3); + assert!((ss - tracked.2).abs() < 1e-3); + } } diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index c83c01e..9b36bdc 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -10,7 +10,7 @@ use std::cell::Cell; use std::rc::Rc; use std::time::Instant; -use crate::app::gpui_callbacks::WindowAppHandler; +use crate::app::gpui_callbacks::{BrandLayoutTracker, WindowAppHandler}; use crate::app::hero::{ opencore_brand_image, responsive_brand_height, show_off_brand_height, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, @@ -105,6 +105,7 @@ pub fn welcome_screen( viewport: WindowViewport, content_opacity: f32, hide_brand: bool, + track_brand_layout: Option, ) -> impl IntoElement { let background = theme.surface(BackgroundToken::Primary); let hero_height = responsive_brand_height(viewport); @@ -128,6 +129,7 @@ pub fn welcome_screen( chrome_opacity, reveal_progress, hide_brand, + track_brand_layout, )), ) } @@ -150,6 +152,7 @@ fn main_column( chrome_opacity: f32, reveal_progress: f32, hide_brand: bool, + track_brand_layout: Option, ) -> impl IntoElement { let brand_height = lerp(show_off_height, hero_height, reveal_progress); let brand_opacity = if hide_brand { 0.0 } else { 1.0 }; @@ -161,7 +164,12 @@ fn main_column( .flex_col() .items_center() .justify_center() - .child(hero_brand_standalone(theme, brand_height, brand_opacity)) + .child(hero_brand_standalone( + theme, + brand_height, + brand_opacity, + track_brand_layout, + )) .child(hero_copy(theme, chrome_opacity)); if let Some(message) = persistence_error { @@ -250,7 +258,31 @@ fn hero_glow(theme: OpenCoreTheme) -> impl IntoElement { ]) } -fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32, opacity: f32) -> impl IntoElement { +fn hero_brand_standalone( + theme: OpenCoreTheme, + hero_height: f32, + opacity: f32, + track_brand_layout: Option, +) -> impl IntoElement { + let brand_image = opencore_brand_image(theme, hero_height, 1.0); + let tracked_brand = if let Some(track) = track_brand_layout { + div() + .on_children_prepainted(move |children_bounds, _window, cx| { + if let Some(bounds) = children_bounds.first() { + let center = bounds.center(); + track( + center.x.as_f32(), + center.y.as_f32(), + bounds.size.height.as_f32(), + cx, + ); + } + }) + .child(brand_image) + } else { + div().child(brand_image) + }; + div() .relative() .w_full() @@ -260,7 +292,7 @@ fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32, opacity: f32) - .justify_center() .opacity(opacity) .child(hero_glow(theme)) - .child(opencore_brand_image(theme, hero_height, 1.0)) + .child(tracked_brand) } fn hero_copy(theme: OpenCoreTheme, chrome_opacity: f32) -> impl IntoElement { From f5c4bdf3f0cb6d9403c0402726fe9829d1a48abb Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 15:59:09 +0700 Subject: [PATCH 16/22] refactor(welcome): remove hero morph transition to home. Enter now persists onboarding, resizes the window, and routes directly to the shell without the brand overlay animation. --- src/app/desktop.rs | 272 ++----------------------------------- src/app/gpui_callbacks.rs | 3 - src/app/hero/layout.rs | 67 --------- src/app/hero/mod.rs | 8 +- src/app/hero/transition.rs | 149 -------------------- src/app/state.rs | 2 +- src/app/welcome/view.rs | 69 ++-------- 7 files changed, 28 insertions(+), 542 deletions(-) delete mode 100644 src/app/hero/transition.rs diff --git a/src/app/desktop.rs b/src/app/desktop.rs index 4bb9ea1..cba3f85 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -17,14 +17,12 @@ use gpui::{TitlebarOptions, point}; use gpui_component::Root; use gpui_component::dock::DockAreaState; -use crate::app::gpui_callbacks::BrandLayoutTracker; use crate::shared::preferences::{FilePreferencesStore, PreferencesError, PreferencesStore}; use crate::shared::theme::{OpenCoreTheme, ThemeTransition, apply_nothing_theme}; use super::AppError; #[cfg(debug_assertions)] use super::dev_reset::{DevResetCallbacks, DevResetState, dev_reset_fab}; -use super::hero::{HeroTransition, brand_width, opencore_brand_image, responsive_brand_height}; use super::shell::{DockSaveFn, ShellWorkspace, register_shell_panels}; use super::state::{ActiveScreen, AppState}; use super::viewport::WindowViewport; @@ -102,8 +100,6 @@ pub struct OpenCoreApp { _shutdown_subscription: gpui::Subscription, _window_closed_subscription: gpui::Subscription, theme_transition: Option, - hero_transition: Option, - welcome_brand_layout: Option<(f32, f32, f32)>, persistence_error: Option, #[cfg(debug_assertions)] dev_reset_state: DevResetState, @@ -154,8 +150,6 @@ impl OpenCoreApp { _shutdown_subscription: shutdown_subscription, _window_closed_subscription: window_closed_subscription, theme_transition: None, - hero_transition: None, - welcome_brand_layout: None, persistence_error: None, #[cfg(debug_assertions)] dev_reset_state: DevResetState::default(), @@ -269,59 +263,15 @@ impl OpenCoreApp { shell } - fn settle_hero_transition(&mut self, now: Instant) -> bool { - if self.hero_transition.is_some_and(|tx| !tx.is_active(now)) { - self.hero_transition = None; - if self.state.active_screen == ActiveScreen::Welcome { - self.state.finish_welcome_transition(); - self.welcome_ui = None; - return true; - } - } - false - } - - fn start_hero_transition(&mut self, viewport: WindowViewport) -> Result<(), PreferencesError> { - if self.hero_transition.is_some() { - return Ok(()); - } - let now = Instant::now(); - let hero_height = responsive_brand_height(viewport); - self.hero_transition = Some(HeroTransition::start( - now, - viewport, - hero_height, - self.welcome_brand_layout, - )); - match self.state.persist_welcome_completion(self.store.as_ref()) { - Ok(()) => Ok(()), - Err(error) => { - self.hero_transition = None; - Err(error) - } - } - } - - fn begin_hero_transition(&mut self, window: &mut Window, cx: &mut Context) { - if self.hero_transition.is_some() { - return; - } - match self.state.persist_welcome_completion(self.store.as_ref()) { + fn complete_welcome(&mut self, window: &mut Window, cx: &mut Context) { + match self.state.complete_welcome(self.store.as_ref()) { Ok(()) => { self.persistence_error = None; - let viewport = WindowViewport::from_window(window); - let now = Instant::now(); - let hero_height = responsive_brand_height(viewport); - self.hero_transition = Some(HeroTransition::start( - now, - viewport, - hero_height, - self.welcome_brand_layout, - )); + self.welcome_ui = None; + self.finish_screen_transition(window, cx); cx.notify(); } Err(error) => { - self.hero_transition = None; self.record_persistence_error("persist welcome completion", error); cx.notify(); } @@ -343,7 +293,7 @@ impl OpenCoreApp { } match reduce_welcome(command) { WelcomeOutcome::Pending => {} - WelcomeOutcome::Completed => self.begin_hero_transition(window, cx), + WelcomeOutcome::Completed => self.complete_welcome(window, cx), } } @@ -402,7 +352,6 @@ impl OpenCoreApp { self.pending_shell_save.borrow_mut().clear(); self.shell = None; self.welcome_ui = Some(WelcomeUiState::new()); - self.hero_transition = None; self.persistence_error = None; Ok(()) } @@ -556,22 +505,13 @@ impl Render for OpenCoreApp { let now = Instant::now(); self.settle_theme_transition(now); - if self.settle_hero_transition(now) { - self.finish_screen_transition(window, cx); - cx.notify(); - } let theme = self.visual_theme(now); - let transition_progress = self - .hero_transition - .map(|tx| tx.linear_progress(now)) - .unwrap_or(0.0); let welcome_intro_animating = self .welcome_ui .as_mut() .is_some_and(|ui| ui.tick(now)); if should_request_animation_frame( - self.hero_transition.as_ref(), self.theme_transition.as_ref(), welcome_intro_animating, now, @@ -579,39 +519,14 @@ impl Render for OpenCoreApp { window.request_animation_frame(); } - let welcome_content_opacity = self - .hero_transition - .map(|_| HeroTransition::content_opacity(transition_progress)) - .unwrap_or(1.0); - let shell_brand_opacity = HeroTransition::shell_brand_opacity( - self.hero_transition - .map(|_| transition_progress) - .unwrap_or(1.0), - ); - let content = match self.state.active_screen { ActiveScreen::Welcome => { let ui = self.welcome_ui.get_or_insert_with(WelcomeUiState::new); ui.ensure_initial_focus(window, &self.focus_handle, cx); let accepts_enter = ui.accepts_enter(now); - let hide_welcome_brand = self - .hero_transition - .as_ref() - .is_some_and(|tx| tx.is_active(now)); let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); - let track_brand_layout: Option = - if hide_welcome_brand { - None - } else { - let view = cx.entity().downgrade(); - Some(Rc::new(move |center_x, center_y, height, cx| { - let _ = view.update(cx, |app, _| { - app.welcome_brand_layout = Some((center_x, center_y, height)); - }); - })) - }; div() .size_full() @@ -628,9 +543,6 @@ impl Render for OpenCoreApp { callbacks, persistence_error, WindowViewport::from_window(window), - welcome_content_opacity, - hide_welcome_brand, - track_brand_layout, ), )) } @@ -638,27 +550,13 @@ impl Render for OpenCoreApp { let shell = self.ensure_shell(window, cx); shell.update(cx, |shell, cx| { shell.set_theme(theme, cx); - shell.set_brand_chrome(shell_brand_opacity, cx); + shell.set_brand_chrome(1.0, cx); }); div().size_full().min_w_0().min_h_0().child(shell) } }; - let mut root = div().size_full().relative().child(content); - - if let Some(transition) = self.hero_transition - && transition.is_active(now) - { - let (center_x, center_y, height) = transition.layout_at(now); - let width = brand_width(height); - root = root.child( - div() - .absolute() - .left(px(center_x - width * 0.5)) - .top(px(center_y - height * 0.5)) - .child(opencore_brand_image(theme, height, 1.0)), - ); - } + let root = div().size_full().relative().child(content); #[cfg(debug_assertions)] { @@ -691,14 +589,11 @@ impl Render for OpenCoreApp { } fn should_request_animation_frame( - hero_transition: Option<&HeroTransition>, theme_transition: Option<&ThemeTransition>, welcome_intro_animating: bool, now: Instant, ) -> bool { - welcome_intro_animating - || hero_transition.is_some_and(|tx| tx.is_active(now)) - || theme_transition.is_some_and(|tx| tx.is_active(now)) + welcome_intro_animating || theme_transition.is_some_and(|tx| tx.is_active(now)) } fn window_bounds_for_state(state: &AppState, cx: &App) -> WindowBounds { @@ -802,32 +697,10 @@ mod tests { mod animation_gate_tests { use super::*; - #[test] - fn hero_animation_gate_follows_active_transition() { - let now = Instant::now(); - assert!(!should_request_animation_frame(None, None, false, now)); - let tx = HeroTransition::start( - now, - WindowViewport { - width: 960.0, - height: 740.0, - }, - 52.0, - None, - ); - assert!(should_request_animation_frame(Some(&tx), None, false, now)); - assert!(!should_request_animation_frame( - Some(&tx), - None, - false, - now + super::super::hero::HERO_TRANSITION_DURATION - )); - } - #[test] fn welcome_intro_requests_animation_frames() { let now = Instant::now(); - assert!(should_request_animation_frame(None, None, true, now)); + assert!(should_request_animation_frame(None, true, now)); } #[test] @@ -838,14 +711,13 @@ mod animation_gate_tests { crate::shared::theme::ThemeMode::Light, now, ); - assert!(should_request_animation_frame(None, Some(&tx), false, now)); + assert!(should_request_animation_frame(Some(&tx), false, now)); assert!(!should_request_animation_frame( - None, Some(&tx), false, now + crate::shared::theme::THEME_TRANSITION_DURATION )); - assert!(!should_request_animation_frame(None, None, false, now)); + assert!(!should_request_animation_frame(None, false, now)); } } @@ -977,128 +849,6 @@ mod dock_layout_persistence_tests { } } -#[cfg(test)] -mod hero_transition_tests { - use super::*; - use crate::shared::preferences::AppPreferences; - use gpui::{AppContext, TestAppContext}; - use std::time::Duration; - use tempfile::TempDir; - - const WELCOME_VIEWPORT: WindowViewport = WindowViewport { - width: 960.0, - height: 740.0, - }; - - #[gpui::test] - fn enter_sets_hero_transition_and_defers_home_routing(cx: &mut TestAppContext) { - let dir = TempDir::new().expect("temp dir"); - let store = Arc::new(FilePreferencesStore::at( - dir.path().join("preferences.json"), - )); - let app = cx.new(|cx| { - OpenCoreApp::new( - AppState::from_preferences(AppPreferences::default()), - store.clone(), - cx, - ) - }); - - app.update(cx, |app, cx| { - app.start_hero_transition(WELCOME_VIEWPORT) - .expect("start hero transition"); - cx.notify(); - }); - - cx.read_entity(&app, |app, _| { - assert!(app.hero_transition.is_some()); - assert_eq!(app.state.active_screen, ActiveScreen::Welcome); - assert!(app.state.preferences.onboarding_completed); - assert!(app.welcome_ui.is_some()); - }); - let saved = store.load().expect("load"); - assert!(saved.onboarding_completed); - - let done = Instant::now() + super::super::hero::HERO_TRANSITION_DURATION; - app.update(cx, |app, cx| { - assert!(app.settle_hero_transition(done)); - cx.notify(); - }); - - cx.read_entity(&app, |app, _| { - assert!(app.hero_transition.is_none()); - assert_eq!(app.state.active_screen, ActiveScreen::Home); - assert!(app.welcome_ui.is_none()); - }); - } - - #[gpui::test] - fn persistence_failure_clears_hero_transition_without_routing_home(cx: &mut TestAppContext) { - let dir = TempDir::new().expect("temp dir"); - let store = Arc::new(FilePreferencesStore::at(dir.path())); - let app = cx.new(|cx| { - OpenCoreApp::new( - AppState::from_preferences(AppPreferences::default()), - store, - cx, - ) - }); - - app.update(cx, |app, cx| { - assert!(app.start_hero_transition(WELCOME_VIEWPORT).is_err()); - cx.notify(); - }); - - cx.read_entity(&app, |app, _| { - assert!(app.hero_transition.is_none()); - assert_eq!(app.state.active_screen, ActiveScreen::Welcome); - assert!(!app.state.preferences.onboarding_completed); - }); - } - - #[gpui::test] - fn double_enter_does_not_restart_hero_transition(cx: &mut TestAppContext) { - let dir = TempDir::new().expect("temp dir"); - let store = Arc::new(FilePreferencesStore::at( - dir.path().join("preferences.json"), - )); - let app = cx.new(|cx| { - OpenCoreApp::new( - AppState::from_preferences(AppPreferences::default()), - store, - cx, - ) - }); - - app.update(cx, |app, cx| { - app.start_hero_transition(WELCOME_VIEWPORT) - .expect("first enter"); - cx.notify(); - }); - cx.executor().advance_clock(Duration::from_millis(400)); - - let progress_before_second_enter = cx.read_entity(&app, |app, _| { - app.hero_transition - .map(|tx| tx.linear_progress(Instant::now())) - .expect("hero transition active") - }); - - app.update(cx, |app, cx| { - app.start_hero_transition(WELCOME_VIEWPORT) - .expect("second enter is ignored"); - cx.notify(); - }); - cx.executor().advance_clock(Duration::from_millis(100)); - - let progress_after_second_enter = cx.read_entity(&app, |app, _| { - app.hero_transition - .map(|tx| tx.linear_progress(Instant::now())) - .expect("hero transition still active") - }); - assert!(progress_after_second_enter > progress_before_second_enter); - } -} - #[cfg(all(test, debug_assertions))] mod reset_tests { use super::*; diff --git a/src/app/gpui_callbacks.rs b/src/app/gpui_callbacks.rs index d64300b..57b42c0 100644 --- a/src/app/gpui_callbacks.rs +++ b/src/app/gpui_callbacks.rs @@ -5,6 +5,3 @@ use std::rc::Rc; use gpui::{App, Window}; pub type WindowAppHandler = Rc; - -/// Reports the welcome brand image center and height in window coordinates. -pub type BrandLayoutTracker = Rc; diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index 9ff02be..02b0fc9 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -24,12 +24,9 @@ pub const WELCOME_HERO_BRAND_FRAME_EXTRA: f32 = 40.0; pub const WELCOME_ACTION_SPACER: f32 = 60.0; pub const WELCOME_ENTER_BUTTON_HEIGHT: f32 = 48.0; pub const WELCOME_ACTION_BOTTOM_PADDING: f32 = 8.0; -pub const WELCOME_COPY_MAX_WIDTH: f32 = 680.0; const WELCOME_ACTION_BAND: f32 = 260.0; const WELCOME_THEME_TOGGLE_HEIGHT: f32 = 32.0; -const WELCOME_COPY_BODY: &str = "OpenCore combines chat, terminal, editing, and Rust-native performance in one permissioned desktop environment. To leave the crowded cloud, polluted by leaks and unconsciousness, to return to a workspace that stays on your machine."; -const WELCOME_BODY_CHAR_WIDTH: f32 = 5.85; /// macOS traffic-light inset matches gpui-component `TITLE_BAR_LEFT_PADDING`. pub fn title_bar_left_padding() -> f32 { @@ -58,45 +55,6 @@ pub fn welcome_header_row_height() -> f32 { text_column.max(WELCOME_THEME_TOGGLE_HEIGHT) } -/// Height of the static hero copy block below the brand. -pub fn welcome_copy_block_height(viewport: WindowViewport) -> f32 { - let text_width = WELCOME_COPY_MAX_WIDTH - .min((viewport.width - WELCOME_EDGE_INSET_H * 2.0).max(1.0)); - let chars_per_line = (text_width / WELCOME_BODY_CHAR_WIDTH).floor().max(1.0) as usize; - let line_count = WELCOME_COPY_BODY.len().div_ceil(chars_per_line); - let body_height = - TypeRole::MonoSm.size() * TypeRole::MonoSm.line_height() * line_count as f32; - 24.0 - + TypeRole::DisplayMd.size() * TypeRole::DisplayMd.line_height() - + 8.0 - + body_height -} - -/// Total height of the centered welcome stack below the header. -pub fn welcome_center_stack_height(viewport: WindowViewport, brand_height: f32) -> f32 { - (brand_height + WELCOME_HERO_BRAND_FRAME_EXTRA) - + welcome_copy_block_height(viewport) - + WELCOME_ACTION_SPACER - + WELCOME_ENTER_BUTTON_HEIGHT - + WELCOME_ACTION_BOTTOM_PADDING -} - -/// Center of the large welcome brand in window coordinates. -pub fn welcome_brand_center(viewport: WindowViewport, brand_height: f32) -> (f32, f32) { - let centered_region_top = WELCOME_TITLEBAR_HEIGHT - + WELCOME_EDGE_INSET_TOP - + welcome_header_row_height() - + WELCOME_HEADER_GAP; - let centered_region_bottom = viewport.height - WELCOME_EDGE_INSET_BOTTOM; - let centered_region_height = (centered_region_bottom - centered_region_top).max(0.0); - let stack_height = welcome_center_stack_height(viewport, brand_height); - let stack_top = - centered_region_top + (centered_region_height - stack_height).max(0.0) * 0.5; - let brand_center_y = - stack_top + (brand_height + WELCOME_HERO_BRAND_FRAME_EXTRA) * 0.5; - (viewport.width * 0.5, brand_center_y) -} - /// Responsive welcome brand height. pub fn responsive_brand_height(viewport: WindowViewport) -> f32 { let square_limit = responsive_hero_size(viewport.width, viewport.height); @@ -193,29 +151,4 @@ mod tests { let settled = show_off + (hero - show_off); assert!((settled - hero).abs() < 1e-3); } - - #[test] - fn welcome_brand_center_matches_centered_stack_layout() { - let viewport = WindowViewport { - width: 960.0, - height: 740.0, - }; - let brand_height = responsive_brand_height(viewport); - let (_, center_y) = welcome_brand_center(viewport, brand_height); - let stack_top = WELCOME_TITLEBAR_HEIGHT - + WELCOME_EDGE_INSET_TOP - + welcome_header_row_height() - + WELCOME_HEADER_GAP - + ((viewport.height - - WELCOME_EDGE_INSET_BOTTOM - - (WELCOME_TITLEBAR_HEIGHT - + WELCOME_EDGE_INSET_TOP - + welcome_header_row_height() - + WELCOME_HEADER_GAP)) - - welcome_center_stack_height(viewport, brand_height)) - .max(0.0) - * 0.5; - let expected_y = stack_top + (brand_height + WELCOME_HERO_BRAND_FRAME_EXTRA) * 0.5; - assert!((center_y - expected_y).abs() < 1e-3); - } } diff --git a/src/app/hero/mod.rs b/src/app/hero/mod.rs index b388bd5..c130287 100644 --- a/src/app/hero/mod.rs +++ b/src/app/hero/mod.rs @@ -2,7 +2,6 @@ mod brand; mod layout; -mod transition; pub use brand::{ BRAND_ASPECT, BRAND_IMAGE, BRAND_IMAGE_INVERSE, brand_width, opencore_brand_image, @@ -10,8 +9,7 @@ pub use brand::{ pub use layout::{ BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, docked_brand_center, responsive_brand_height, responsive_hero_size, show_off_brand_height, - title_bar_left_padding, welcome_brand_center, WELCOME_ACTION_SPACER, - WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, - WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, + title_bar_left_padding, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, + WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, WELCOME_ENTER_BUTTON_HEIGHT, + WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, }; -pub use transition::{HERO_TRANSITION_DURATION, HeroTransition}; diff --git a/src/app/hero/transition.rs b/src/app/hero/transition.rs deleted file mode 100644 index 9d25061..0000000 --- a/src/app/hero/transition.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Big-to-small brand hero transition (iOS onboarding port). - -use std::time::{Duration, Instant}; - -use super::layout::{ - BRAND_SHELL_HEIGHT, docked_brand_center, home_transition_viewport, welcome_brand_center, -}; -use crate::app::viewport::WindowViewport; - -/// Hero morph duration — matches iOS `smooth(duration: 1.02)`. -pub const HERO_TRANSITION_DURATION: Duration = Duration::from_millis(1020); - -const MORPH_START: f32 = 0.54; - -#[derive(Debug, Clone, Copy)] -pub struct HeroTransition { - started_at: Instant, - start_center: (f32, f32), - start_size: f32, - end_center: (f32, f32), - end_size: f32, -} - -impl HeroTransition { - pub fn start( - now: Instant, - welcome_viewport: WindowViewport, - hero_size: f32, - tracked_layout: Option<(f32, f32, f32)>, - ) -> Self { - let (start_center, start_size) = match tracked_layout { - Some((center_x, center_y, height)) => ((center_x, center_y), height), - None => (welcome_brand_center(welcome_viewport, hero_size), hero_size), - }; - let end_center = docked_brand_center(home_transition_viewport()); - Self { - started_at: now, - start_center, - start_size, - end_center, - end_size: BRAND_SHELL_HEIGHT, - } - } - - pub fn linear_progress(&self, now: Instant) -> f32 { - let total = HERO_TRANSITION_DURATION.as_secs_f32(); - if total <= 0.0 { - return 1.0; - } - (now.saturating_duration_since(self.started_at).as_secs_f32() / total).clamp(0.0, 1.0) - } - - pub fn is_active(&self, now: Instant) -> bool { - self.linear_progress(now) < 1.0 - } - - pub fn morph_progress(transition: f32) -> f32 { - let span = (1.0 - MORPH_START).max(0.001); - let raw = ((transition - MORPH_START) / span).clamp(0.0, 1.0); - 1.0 - (1.0 - raw).powi(3) - } - - /// Window-space center and size at `now`. - pub fn layout_at(&self, now: Instant) -> (f32, f32, f32) { - let transition = self.linear_progress(now); - let morph = Self::morph_progress(transition); - let cx = lerp(self.start_center.0, self.end_center.0, morph); - let cy = lerp(self.start_center.1, self.end_center.1, morph); - let size = lerp(self.start_size, self.end_size, morph); - (cx, cy, size) - } - - /// Fades welcome chrome out during the first third of the transition. - pub fn content_opacity(transition: f32) -> f32 { - (1.0 - (transition / 0.35).clamp(0.0, 1.0)).max(0.0) - } - - pub fn shell_brand_opacity(transition: f32) -> f32 { - if transition >= 1.0 { - 1.0 - } else { - ((transition - 0.72) / 0.28).clamp(0.0, 1.0) - } - } -} - -fn lerp(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn content_opacity_visible_before_transition() { - assert!((HeroTransition::content_opacity(0.0) - 1.0).abs() < 1e-3); - assert!(HeroTransition::content_opacity(1.0) < 0.01); - } - - #[test] - fn morph_reaches_completion_at_end() { - assert!(HeroTransition::morph_progress(0.38) < 0.01); - assert!(HeroTransition::morph_progress(1.0) >= 0.99); - } - - #[test] - fn transition_endpoints() { - let now = Instant::now(); - let tx = HeroTransition::start( - now, - WindowViewport { - width: 960.0, - height: 740.0, - }, - 220.0, - None, - ); - let (sx, sy, ss) = tx.layout_at(now); - assert!((sx - tx.start_center.0).abs() < 1e-3); - assert!((sy - tx.start_center.1).abs() < 1e-3); - assert!((ss - 220.0).abs() < 1e-3); - - let done = now + HERO_TRANSITION_DURATION; - let (ex, ey, es) = tx.layout_at(done); - assert!((ex - tx.end_center.0).abs() < 1e-3); - assert!((ey - tx.end_center.1).abs() < 1e-3); - assert!((es - BRAND_SHELL_HEIGHT).abs() < 1e-3); - } - - #[test] - fn transition_uses_tracked_start_layout() { - let now = Instant::now(); - let tracked = (480.0, 290.0, 76.0); - let tx = HeroTransition::start( - now, - WindowViewport { - width: 960.0, - height: 740.0, - }, - 220.0, - Some(tracked), - ); - let (sx, sy, ss) = tx.layout_at(now); - assert!((sx - tracked.0).abs() < 1e-3); - assert!((sy - tracked.1).abs() < 1e-3); - assert!((ss - tracked.2).abs() < 1e-3); - } -} diff --git a/src/app/state.rs b/src/app/state.rs index c430d93..f3a3007 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -104,7 +104,7 @@ impl AppState { Ok(()) } - /// Routes to home after the welcome hero transition finishes. + /// Routes to home after welcome completes. pub fn finish_welcome_transition(&mut self) { self.active_screen = ActiveScreen::Home; } diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index 9b36bdc..b1c5450 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -10,7 +10,7 @@ use std::cell::Cell; use std::rc::Rc; use std::time::Instant; -use crate::app::gpui_callbacks::{BrandLayoutTracker, WindowAppHandler}; +use crate::app::gpui_callbacks::WindowAppHandler; use crate::app::hero::{ opencore_brand_image, responsive_brand_height, show_off_brand_height, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, @@ -103,9 +103,6 @@ pub fn welcome_screen( callbacks: WelcomeCallbacks, persistence_error: Option<&str>, viewport: WindowViewport, - content_opacity: f32, - hide_brand: bool, - track_brand_layout: Option, ) -> impl IntoElement { let background = theme.surface(BackgroundToken::Primary); let hero_height = responsive_brand_height(viewport); @@ -116,22 +113,15 @@ pub fn welcome_screen( div() .size_full() .bg(background) - .child( - div() - .size_full() - .opacity(content_opacity) - .child(main_column( - theme, - callbacks, - persistence_error, - hero_height, - show_off_height, - chrome_opacity, - reveal_progress, - hide_brand, - track_brand_layout, - )), - ) + .child(main_column( + theme, + callbacks, + persistence_error, + hero_height, + show_off_height, + chrome_opacity, + reveal_progress, + )) } fn lerp(a: f32, b: f32, t: f32) -> f32 { @@ -151,11 +141,8 @@ fn main_column( show_off_height: f32, chrome_opacity: f32, reveal_progress: f32, - hide_brand: bool, - track_brand_layout: Option, ) -> impl IntoElement { let brand_height = lerp(show_off_height, hero_height, reveal_progress); - let brand_opacity = if hide_brand { 0.0 } else { 1.0 }; let mut centered_content = div() .w_full() @@ -164,12 +151,7 @@ fn main_column( .flex_col() .items_center() .justify_center() - .child(hero_brand_standalone( - theme, - brand_height, - brand_opacity, - track_brand_layout, - )) + .child(hero_brand_standalone(theme, brand_height)) .child(hero_copy(theme, chrome_opacity)); if let Some(message) = persistence_error { @@ -258,31 +240,7 @@ fn hero_glow(theme: OpenCoreTheme) -> impl IntoElement { ]) } -fn hero_brand_standalone( - theme: OpenCoreTheme, - hero_height: f32, - opacity: f32, - track_brand_layout: Option, -) -> impl IntoElement { - let brand_image = opencore_brand_image(theme, hero_height, 1.0); - let tracked_brand = if let Some(track) = track_brand_layout { - div() - .on_children_prepainted(move |children_bounds, _window, cx| { - if let Some(bounds) = children_bounds.first() { - let center = bounds.center(); - track( - center.x.as_f32(), - center.y.as_f32(), - bounds.size.height.as_f32(), - cx, - ); - } - }) - .child(brand_image) - } else { - div().child(brand_image) - }; - +fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { div() .relative() .w_full() @@ -290,9 +248,8 @@ fn hero_brand_standalone( .flex() .items_center() .justify_center() - .opacity(opacity) .child(hero_glow(theme)) - .child(tracked_brand) + .child(opencore_brand_image(theme, hero_height, 1.0)) } fn hero_copy(theme: OpenCoreTheme, chrome_opacity: f32) -> impl IntoElement { From be27317772230a04c56d499bbbf988463c2c107e Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 16:08:07 +0700 Subject: [PATCH 17/22] change(theme): default theme mode to light. --- src/shared/theme/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/theme/mod.rs b/src/shared/theme/mod.rs index d5d2b01..56f3ceb 100644 --- a/src/shared/theme/mod.rs +++ b/src/shared/theme/mod.rs @@ -21,8 +21,8 @@ pub const WARNING_AMBER: u32 = 0xD4_A8_43; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ThemeMode { - Light, #[default] + Light, Dark, } @@ -379,8 +379,8 @@ mod tests { } #[test] - fn default_theme_mode_is_dark() { - assert_eq!(ThemeMode::default(), ThemeMode::Dark); + fn default_theme_mode_is_light() { + assert_eq!(ThemeMode::default(), ThemeMode::Light); } #[test] From 3acb0812f6d5140572b953727eea9ddfb9eb4a03 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 16:08:10 +0700 Subject: [PATCH 18/22] test(preferences): expect light as default theme mode. --- src/shared/preferences/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/preferences/mod.rs b/src/shared/preferences/mod.rs index f775211..69de2b9 100644 --- a/src/shared/preferences/mod.rs +++ b/src/shared/preferences/mod.rs @@ -172,7 +172,7 @@ mod tests { fn app_preferences_default_serializes_to_prd_schema() { let json = serde_json::to_string(&AppPreferences::default()).expect("serialize"); let value: serde_json::Value = serde_json::from_str(&json).expect("parse"); - assert_eq!(value["theme_mode"], "dark"); + assert_eq!(value["theme_mode"], "light"); assert_eq!(value["onboarding_completed"], false); assert_eq!(value.get("shell"), None); assert_eq!(value.get("dock_layout"), None); @@ -181,7 +181,7 @@ mod tests { #[test] fn app_preferences_default_matches_schema() { let prefs = AppPreferences::default(); - assert_eq!(prefs.theme_mode, ThemeMode::Dark); + assert_eq!(prefs.theme_mode, ThemeMode::Light); assert!(!prefs.onboarding_completed); assert!(prefs.dock_layout.is_none()); } @@ -202,7 +202,7 @@ mod tests { fn app_preferences_deserializes_with_missing_fields() { let restored: AppPreferences = serde_json::from_str(r#"{"onboarding_completed":true}"#).expect("deserialize"); - assert_eq!(restored.theme_mode, ThemeMode::Dark); + assert_eq!(restored.theme_mode, ThemeMode::Light); assert!(restored.onboarding_completed); } From cb1c97f6453c54644dd1a3bd786b4182bba34385 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 16:33:49 +0700 Subject: [PATCH 19/22] fix(welcome): harden intro reveal and left-align description. Consolidate welcome completion into a single state transition, remove dead hero layout helpers, and tighten intro input/focus gating. --- src/app/desktop.rs | 58 +++++++++++++++++++++++-------------- src/app/hero/layout.rs | 54 +++++++++++++++++++++++++++------- src/app/hero/mod.rs | 2 +- src/app/mod.rs | 15 ---------- src/app/state.rs | 32 ++++++-------------- src/app/welcome/ui_state.rs | 10 ++----- src/app/welcome/view.rs | 31 ++++++++------------ 7 files changed, 105 insertions(+), 97 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index cba3f85..6e33c2a 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -187,11 +187,18 @@ impl OpenCoreApp { cx.notify(); } + fn welcome_input_enabled(&self, now: Instant) -> bool { + self.welcome_ui + .as_ref() + .is_none_or(|ui| ui.accepts_enter(now)) + } + fn ensure_welcome_focus(&mut self, window: &mut Window, cx: &mut Context) { let now = Instant::now(); - if let Some(ui) = self.welcome_ui.as_mut() - && ui.accepts_enter(now) - { + if !self.welcome_input_enabled(now) { + return; + } + if let Some(ui) = self.welcome_ui.as_mut() { ui.ensure_initial_focus(window, &self.focus_handle, cx); } } @@ -269,7 +276,6 @@ impl OpenCoreApp { self.persistence_error = None; self.welcome_ui = None; self.finish_screen_transition(window, cx); - cx.notify(); } Err(error) => { self.record_persistence_error("persist welcome completion", error); @@ -284,11 +290,7 @@ impl OpenCoreApp { window: &mut Window, cx: &mut Context, ) { - if self - .welcome_ui - .as_ref() - .is_some_and(|ui| !ui.accepts_enter(Instant::now())) - { + if !self.welcome_input_enabled(Instant::now()) { return; } match reduce_welcome(command) { @@ -299,11 +301,7 @@ impl OpenCoreApp { fn toggle_theme(&mut self, cx: &mut Context) { let now = Instant::now(); - if self - .welcome_ui - .as_ref() - .is_some_and(|ui| !ui.accepts_enter(now)) - { + if !self.welcome_input_enabled(now) { return; } let from = self.state.theme_mode(); @@ -507,10 +505,13 @@ impl Render for OpenCoreApp { self.settle_theme_transition(now); let theme = self.visual_theme(now); - let welcome_intro_animating = self - .welcome_ui - .as_mut() - .is_some_and(|ui| ui.tick(now)); + let welcome_intro_animating = if self.state.active_screen == ActiveScreen::Welcome { + self.welcome_ui + .get_or_insert_with(WelcomeUiState::new) + .tick(now) + } else { + false + }; if should_request_animation_frame( self.theme_transition.as_ref(), welcome_intro_animating, @@ -521,9 +522,18 @@ impl Render for OpenCoreApp { let content = match self.state.active_screen { ActiveScreen::Welcome => { - let ui = self.welcome_ui.get_or_insert_with(WelcomeUiState::new); - ui.ensure_initial_focus(window, &self.focus_handle, cx); - let accepts_enter = ui.accepts_enter(now); + let accepts_enter = self + .welcome_ui + .as_ref() + .expect("welcome ui initialized for intro tick") + .accepts_enter(now); + let ui = self + .welcome_ui + .as_mut() + .expect("welcome ui initialized for intro tick"); + if accepts_enter { + ui.ensure_initial_focus(window, &self.focus_handle, cx); + } let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); @@ -703,6 +713,12 @@ mod animation_gate_tests { assert!(should_request_animation_frame(None, true, now)); } + #[test] + fn welcome_intro_stops_requesting_frames() { + let now = Instant::now(); + assert!(!should_request_animation_frame(None, false, now)); + } + #[test] fn frame_gate_follows_theme_transition() { let now = Instant::now(); diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index 02b0fc9..87a3876 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -1,7 +1,6 @@ //! Hero layout math — welcome center, docked title-bar slot (layout A). use super::brand::{BRAND_ASPECT, brand_width}; -use crate::app::state::{HOME_WINDOW_HEIGHT, HOME_WINDOW_WIDTH}; use crate::app::viewport::WindowViewport; use crate::shared::theme::TypeRole; use gpui_component::TITLE_BAR_HEIGHT; @@ -19,11 +18,9 @@ pub const WELCOME_EDGE_INSET_H: f32 = 16.0; pub const WELCOME_TITLEBAR_HEIGHT: f32 = 38.0; pub const WELCOME_EDGE_INSET_TOP: f32 = 4.0; pub const WELCOME_EDGE_INSET_BOTTOM: f32 = 20.0; -pub const WELCOME_HEADER_GAP: f32 = 8.0; pub const WELCOME_HERO_BRAND_FRAME_EXTRA: f32 = 40.0; pub const WELCOME_ACTION_SPACER: f32 = 60.0; pub const WELCOME_ENTER_BUTTON_HEIGHT: f32 = 48.0; -pub const WELCOME_ACTION_BOTTOM_PADDING: f32 = 8.0; const WELCOME_ACTION_BAND: f32 = 260.0; const WELCOME_THEME_TOGGLE_HEIGHT: f32 = 32.0; @@ -88,12 +85,9 @@ pub fn docked_brand_center(viewport: WindowViewport) -> (f32, f32) { (x, title_h * 0.5) } -/// Viewport used to compute the docked slot after welcome completes. -pub fn home_transition_viewport() -> WindowViewport { - WindowViewport { - width: HOME_WINDOW_WIDTH as f32, - height: HOME_WINDOW_HEIGHT as f32, - } +/// Linear interpolation between two values. +pub fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t } #[cfg(test)] @@ -102,7 +96,11 @@ mod tests { #[test] fn docked_brand_sits_after_left_toggle() { - let (x, y) = docked_brand_center(home_transition_viewport()); + let viewport = WindowViewport { + width: 1280.0, + height: 800.0, + }; + let (x, y) = docked_brand_center(viewport); let width = brand_width(BRAND_SHELL_HEIGHT); let expected_x = title_bar_left_padding() + SHELL_TOGGLE_WIDTH + SHELL_TITLE_GAP + width * 0.5; @@ -148,7 +146,41 @@ mod tests { }; let hero = responsive_brand_height(viewport); let show_off = show_off_brand_height(viewport); - let settled = show_off + (hero - show_off); + let settled = lerp_f32(show_off, hero, 1.0); assert!((settled - hero).abs() < 1e-3); } + + #[test] + fn show_off_brand_fits_within_viewport_height() { + let viewport = WindowViewport { + width: 960.0, + height: 740.0, + }; + let height = show_off_brand_height(viewport); + let available = (viewport.height + - WELCOME_EDGE_INSET_TOP + - WELCOME_EDGE_INSET_BOTTOM + - welcome_header_row_height() + - 8.0 + - WELCOME_ACTION_BAND) + .max(0.0); + assert!(height + WELCOME_HERO_BRAND_FRAME_EXTRA <= available + 1.0); + } + + #[test] + fn show_off_brand_fits_short_viewport_height() { + let viewport = WindowViewport { + width: 960.0, + height: 500.0, + }; + let height = show_off_brand_height(viewport); + let available = (viewport.height + - WELCOME_EDGE_INSET_TOP + - WELCOME_EDGE_INSET_BOTTOM + - welcome_header_row_height() + - 8.0 + - WELCOME_ACTION_BAND) + .max(0.0); + assert!(height + WELCOME_HERO_BRAND_FRAME_EXTRA <= available + 1.0); + } } diff --git a/src/app/hero/mod.rs b/src/app/hero/mod.rs index c130287..196cf49 100644 --- a/src/app/hero/mod.rs +++ b/src/app/hero/mod.rs @@ -7,7 +7,7 @@ pub use brand::{ BRAND_ASPECT, BRAND_IMAGE, BRAND_IMAGE_INVERSE, brand_width, opencore_brand_image, }; pub use layout::{ - BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, docked_brand_center, + lerp_f32, BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, docked_brand_center, responsive_brand_height, responsive_hero_size, show_off_brand_height, title_bar_left_padding, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, WELCOME_ENTER_BUTTON_HEIGHT, diff --git a/src/app/mod.rs b/src/app/mod.rs index 1c3a802..edc92f0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -102,21 +102,6 @@ mod tests { assert_eq!(state.active_screen, ActiveScreen::Welcome); } - #[test] - fn persist_welcome_completion_defers_screen_routing() { - let store = InMemoryPreferencesStore::new(); - let mut state = AppState::from_preferences(AppPreferences::default()); - state - .persist_welcome_completion(&store) - .expect("persist welcome completion"); - - assert!(state.preferences.onboarding_completed); - assert_eq!(state.active_screen, ActiveScreen::Welcome); - let intent = state.pending_window_resize.expect("resize intent recorded"); - assert_eq!(intent.width, HOME_WINDOW_WIDTH); - assert_eq!(intent.height, HOME_WINDOW_HEIGHT); - } - #[test] fn completing_onboarding_persists_and_routes_to_home() { let store = InMemoryPreferencesStore::new(); diff --git a/src/app/state.rs b/src/app/state.rs index f3a3007..2a4b378 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -88,27 +88,6 @@ impl AppState { Ok(()) } - /// Persists onboarding completion and queues the home resize without routing. - pub fn persist_welcome_completion( - &mut self, - store: &S, - ) -> Result<(), PreferencesError> { - let mut updated = self.preferences.clone(); - updated.onboarding_completed = true; - store.save(&updated)?; - self.preferences = updated; - self.pending_window_resize = Some(WindowResizeIntent { - width: HOME_WINDOW_WIDTH, - height: HOME_WINDOW_HEIGHT, - }); - Ok(()) - } - - /// Routes to home after welcome completes. - pub fn finish_welcome_transition(&mut self) { - self.active_screen = ActiveScreen::Home; - } - /// Applies a reducer outcome: persist and route when completed. pub fn apply_welcome_outcome( &mut self, @@ -118,8 +97,15 @@ impl AppState { match outcome { WelcomeOutcome::Pending => {} WelcomeOutcome::Completed => { - self.persist_welcome_completion(store)?; - self.finish_welcome_transition(); + let mut updated = self.preferences.clone(); + updated.onboarding_completed = true; + store.save(&updated)?; + self.preferences = updated; + self.pending_window_resize = Some(WindowResizeIntent { + width: HOME_WINDOW_WIDTH, + height: HOME_WINDOW_HEIGHT, + }); + self.active_screen = ActiveScreen::Home; } } Ok(()) diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index 63d3295..6981920 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -48,10 +48,6 @@ impl WelcomeUiState { 1.0 - (1.0 - t).powi(3) } - pub fn chrome_opacity(&self, now: Instant) -> f32 { - self.reveal_progress(now) - } - pub fn accepts_enter(&self, now: Instant) -> bool { !self.intro_animating(now) } @@ -79,14 +75,14 @@ mod tests { use super::*; #[test] - fn chrome_opacity_eases_in_from_start() { + fn reveal_progress_eases_in_from_start() { let start = Instant::now(); let ui = WelcomeUiState { focus_claimed: false, started_at: Some(start), }; - assert!((ui.chrome_opacity(start) - 0.0).abs() < 1e-3); - assert!(ui.chrome_opacity(start + CHROME_REVEAL_DURATION) >= 0.99); + assert!((ui.reveal_progress(start) - 0.0).abs() < 1e-3); + assert!(ui.reveal_progress(start + CHROME_REVEAL_DURATION) >= 0.99); } #[test] diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index b1c5450..e32df58 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -12,7 +12,7 @@ use std::time::Instant; use crate::app::gpui_callbacks::WindowAppHandler; use crate::app::hero::{ - opencore_brand_image, responsive_brand_height, show_off_brand_height, + lerp_f32, opencore_brand_image, responsive_brand_height, show_off_brand_height, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, @@ -107,7 +107,6 @@ pub fn welcome_screen( let background = theme.surface(BackgroundToken::Primary); let hero_height = responsive_brand_height(viewport); let show_off_height = show_off_brand_height(viewport); - let chrome_opacity = ui.chrome_opacity(now); let reveal_progress = ui.reveal_progress(now); div() @@ -119,15 +118,10 @@ pub fn welcome_screen( persistence_error, hero_height, show_off_height, - chrome_opacity, reveal_progress, )) } -fn lerp(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t -} - fn is_enter_keystroke(event: &KeyDownEvent) -> bool { let key = event.keystroke.key.as_str(); matches!(key, "enter" | "return") && !event.is_held && !event.keystroke.modifiers.modified() @@ -139,10 +133,9 @@ fn main_column( persistence_error: Option<&str>, hero_height: f32, show_off_height: f32, - chrome_opacity: f32, reveal_progress: f32, ) -> impl IntoElement { - let brand_height = lerp(show_off_height, hero_height, reveal_progress); + let brand_height = lerp_f32(show_off_height, hero_height, reveal_progress); let mut centered_content = div() .w_full() @@ -152,7 +145,7 @@ fn main_column( .items_center() .justify_center() .child(hero_brand_standalone(theme, brand_height)) - .child(hero_copy(theme, chrome_opacity)); + .child(hero_copy(theme, reveal_progress)); if let Some(message) = persistence_error { let muted = theme.foreground(ForegroundToken::Muted); @@ -161,7 +154,7 @@ fn main_column( centered_content = centered_content.child( div() .w_full() - .opacity(chrome_opacity) + .opacity(reveal_progress) .text_center() .text_size(px(TypeRole::MonoSm.size())) .font_family(mono) @@ -173,7 +166,7 @@ fn main_column( centered_content = centered_content .child(div().h(px(WELCOME_ACTION_SPACER))) - .child(action_row(theme, callbacks.clone(), chrome_opacity)); + .child(action_row(theme, callbacks.clone(), reveal_progress)); div() .size_full() @@ -184,7 +177,7 @@ fn main_column( .px(px(WELCOME_EDGE_INSET_H)) .child( div() - .opacity(chrome_opacity) + .opacity(reveal_progress) .child(header_row(theme, callbacks.clone())), ) .child(div().h(px(8.))) @@ -252,7 +245,7 @@ fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32) -> impl IntoEle .child(opencore_brand_image(theme, hero_height, 1.0)) } -fn hero_copy(theme: OpenCoreTheme, chrome_opacity: f32) -> impl IntoElement { +fn hero_copy(theme: OpenCoreTheme, reveal_progress: f32) -> impl IntoElement { let primary = theme.foreground(ForegroundToken::Primary); let secondary = theme.foreground(ForegroundToken::Secondary); let grotesk = SharedString::from("Space Grotesk"); @@ -268,8 +261,8 @@ fn hero_copy(theme: OpenCoreTheme, chrome_opacity: f32) -> impl IntoElement { .max_w(px(HERO_MAX_WIDTH)) .flex() .flex_col() - .items_center() - .opacity(chrome_opacity) + .items_stretch() + .opacity(reveal_progress) .child(div().h(px(spacing.lg as f32))) .child( div() @@ -285,7 +278,7 @@ fn hero_copy(theme: OpenCoreTheme, chrome_opacity: f32) -> impl IntoElement { div() .w_full() .max_w(px(HERO_MAX_WIDTH)) - .text_center() + .text_left() .text_size(px(TypeRole::MonoSm.size())) .line_height(relative(TypeRole::MonoSm.line_height())) .font_family(grotesk) @@ -298,13 +291,13 @@ fn hero_copy(theme: OpenCoreTheme, chrome_opacity: f32) -> impl IntoElement { fn action_row( theme: OpenCoreTheme, callbacks: WelcomeCallbacks, - chrome_opacity: f32, + reveal_progress: f32, ) -> impl IntoElement { let spacing = theme.spacing; let on_enter = callbacks.on_enter; div() .w_full() - .opacity(chrome_opacity) + .opacity(reveal_progress) .flex() .items_center() .justify_center() From befb01d5e58cd1f23763b98989f52d3699750d61 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 16:52:44 +0700 Subject: [PATCH 20/22] fix(welcome): align hero copy column with measured tagline width. Size the title and body to the rendered DisplayMd tagline in Space Grotesk so the description no longer sits narrower than the headline. --- src/app/welcome/view.rs | 54 ++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index e32df58..ad197eb 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -25,7 +25,9 @@ use crate::shared::theme::{ use super::theme_toggle::theme_toggle_button; use super::ui_state::WelcomeUiState; -const HERO_MAX_WIDTH: f32 = 680.0; +const HERO_TAGLINE: &str = "Your local AI command workspace"; +/// Single-line tagline width at [`TypeRole::DisplayMd`] in Space Grotesk. +const HERO_TAGLINE_COLUMN_WIDTH: f32 = 596.0; const HERO_GLOW_INSET_H: f32 = 44.0; const HERO_GLOW_INSET_TOP: f32 = 46.0; const HERO_GLOW_INSET_BOTTOM: f32 = 34.0; @@ -142,7 +144,7 @@ fn main_column( .flex_1() .flex() .flex_col() - .items_center() + .items_stretch() .justify_center() .child(hero_brand_standalone(theme, brand_height)) .child(hero_copy(theme, reveal_progress)); @@ -256,28 +258,22 @@ fn hero_copy(theme: OpenCoreTheme, reveal_progress: f32) -> impl IntoElement { .flex() .justify_center() .child( - div() - .w_full() - .max_w(px(HERO_MAX_WIDTH)) - .flex() - .flex_col() - .items_stretch() - .opacity(reveal_progress) + hero_copy_column(reveal_progress) .child(div().h(px(spacing.lg as f32))) .child( div() .w_full() + .whitespace_nowrap() .text_center() .text_size(px(TypeRole::DisplayMd.size())) .font_family(grotesk.clone()) .text_color(primary) - .child("Your local AI command workspace"), + .child(HERO_TAGLINE), ) .child(div().h(px(spacing.sm as f32))) .child( div() .w_full() - .max_w(px(HERO_MAX_WIDTH)) .text_left() .text_size(px(TypeRole::MonoSm.size())) .line_height(relative(TypeRole::MonoSm.line_height())) @@ -288,6 +284,16 @@ fn hero_copy(theme: OpenCoreTheme, reveal_progress: f32) -> impl IntoElement { ) } +fn hero_copy_column(reveal_progress: f32) -> gpui::Div { + div() + .w(px(HERO_TAGLINE_COLUMN_WIDTH)) + .max_w_full() + .flex() + .flex_col() + .items_stretch() + .opacity(reveal_progress) +} + fn action_row( theme: OpenCoreTheme, callbacks: WelcomeCallbacks, @@ -341,8 +347,32 @@ mod tests { #[test] fn welcome_hero_layout_constants() { - assert_eq!(HERO_MAX_WIDTH, 680.0); + assert_eq!(HERO_TAGLINE_COLUMN_WIDTH, 596.0); assert_eq!(HERO_GLOW_INSET_H, 44.0); assert_eq!(WELCOME_ENTER_BUTTON_HEIGHT, 48.0); } + + #[gpui::test] + fn hero_tagline_column_width_matches_measured_tagline(cx: &mut gpui::TestAppContext) { + use crate::shared::assets::AppAssets; + use crate::shared::theme::TypeRole; + use gpui::{font, px}; + + cx.update(|app| AppAssets.load_fonts(app).unwrap()); + + let measured = cx.update(|app| { + let text_system = app.text_system(); + let font_id = text_system.resolve_font(&font("Space Grotesk")); + let font_size = px(TypeRole::DisplayMd.size()); + HERO_TAGLINE + .chars() + .map(|ch| text_system.layout_width(font_id, font_size, ch).as_f32()) + .sum::() + }); + + assert!( + (measured - HERO_TAGLINE_COLUMN_WIDTH).abs() < 2.0, + "update HERO_TAGLINE_COLUMN_WIDTH to {measured}", + ); + } } From 245a8d84a8339a5e5a8e9ed56a86485fb46e3ae1 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 17:28:37 +0700 Subject: [PATCH 21/22] fix(welcome): address intro reveal review follow-ups. Share welcome vertical layout budget across show-off height math, cache welcome callbacks, add desktop intro gating tests, and remove unused docked brand export. --- src/app/desktop.rs | 99 +++++++++++++++++++++++++++++++++++-- src/app/hero/layout.rs | 56 +++++++++++---------- src/app/hero/mod.rs | 6 +-- src/app/welcome/ui_state.rs | 9 ++++ src/app/welcome/view.rs | 9 ++-- 5 files changed, 141 insertions(+), 38 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index 6e33c2a..53ce3c2 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -100,6 +100,7 @@ pub struct OpenCoreApp { _shutdown_subscription: gpui::Subscription, _window_closed_subscription: gpui::Subscription, theme_transition: Option, + welcome_callbacks: Option, persistence_error: Option, #[cfg(debug_assertions)] dev_reset_state: DevResetState, @@ -150,6 +151,7 @@ impl OpenCoreApp { _shutdown_subscription: shutdown_subscription, _window_closed_subscription: window_closed_subscription, theme_transition: None, + welcome_callbacks: None, persistence_error: None, #[cfg(debug_assertions)] dev_reset_state: DevResetState::default(), @@ -534,9 +536,13 @@ impl Render for OpenCoreApp { if accepts_enter { ui.ensure_initial_focus(window, &self.focus_handle, cx); } - let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); + let callbacks = self + .welcome_callbacks + .get_or_insert_with(|| WelcomeCallbacks::from_app(cx.entity().downgrade())) + .clone(); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); + let reveal_progress = ui.reveal_progress(now); div() .size_full() @@ -548,8 +554,7 @@ impl Render for OpenCoreApp { on_enter, welcome_screen( theme, - ui, - now, + reveal_progress, callbacks, persistence_error, WindowViewport::from_window(window), @@ -703,6 +708,94 @@ mod tests { } } +#[cfg(test)] +mod welcome_intro_gating_tests { + use super::*; + use crate::shared::preferences::AppPreferences; + use gpui::{AppContext, TestAppContext, VisualContext}; + use std::sync::Arc; + use tempfile::TempDir; + + fn welcome_app( + cx: &mut TestAppContext, + store: Arc, + ) -> (gpui::Entity, &mut gpui::VisualTestContext) { + cx.update(|app| gpui_component::init(app)); + cx.add_window_view(|_window, cx| { + OpenCoreApp::new( + AppState::from_preferences(AppPreferences::default()), + store, + cx, + ) + }) + } + + #[gpui::test] + fn enter_blocked_during_intro_reveal(cx: &mut TestAppContext) { + let _dir = TempDir::new().expect("temp dir"); + let store = Arc::new(FilePreferencesStore::at( + _dir.path().join("preferences.json"), + )); + let start = Instant::now(); + let (app, cx) = welcome_app(cx, store); + + cx.update_window_entity(&app, |app, window, entity_cx| { + app.welcome_ui + .as_mut() + .expect("welcome ui") + .tick(start); + app.apply_welcome_command(WelcomeCommand::EnterPressed, window, entity_cx); + }); + + cx.read_entity(&app, |app, _| { + assert_eq!(app.state.active_screen, ActiveScreen::Welcome); + assert!(!app.state.preferences.onboarding_completed); + }); + } + + #[gpui::test] + fn toggle_theme_blocked_during_intro_reveal(cx: &mut TestAppContext) { + let _dir = TempDir::new().expect("temp dir"); + let store = Arc::new(FilePreferencesStore::at( + _dir.path().join("preferences.json"), + )); + let start = Instant::now(); + let (app, cx) = welcome_app(cx, store); + let theme_before = cx.read_entity(&app, |app, _| app.state.theme_mode()); + + cx.update_window_entity(&app, |app, _window, entity_cx| { + app.welcome_ui + .as_mut() + .expect("welcome ui") + .tick(start); + app.toggle_theme(entity_cx); + }); + + cx.read_entity(&app, |app, _| { + assert_eq!(app.state.theme_mode(), theme_before); + }); + } + + #[gpui::test] + fn enter_enabled_after_intro_reveal(cx: &mut TestAppContext) { + let _dir = TempDir::new().expect("temp dir"); + let store = Arc::new(FilePreferencesStore::at( + _dir.path().join("preferences.json"), + )); + let (app, cx) = welcome_app(cx, store); + + cx.update_window_entity(&app, |app, _window, _entity_cx| { + app.welcome_ui = Some(WelcomeUiState::new()); + assert!(!app.welcome_input_enabled(Instant::now())); + app.welcome_ui + .as_mut() + .expect("welcome ui") + .complete_intro_for_test(); + assert!(app.welcome_input_enabled(Instant::now())); + }); + } +} + #[cfg(test)] mod animation_gate_tests { use super::*; diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index 87a3876..1702471 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -1,9 +1,8 @@ //! Hero layout math — welcome center, docked title-bar slot (layout A). -use super::brand::{BRAND_ASPECT, brand_width}; +use super::brand::BRAND_ASPECT; use crate::app::viewport::WindowViewport; use crate::shared::theme::TypeRole; -use gpui_component::TITLE_BAR_HEIGHT; pub const BRAND_HERO_MIN: f32 = 220.0; pub const BRAND_HERO_MAX: f32 = 320.0; @@ -11,7 +10,9 @@ pub const BRAND_HERO_MAX: f32 = 320.0; pub const BRAND_SHELL_HEIGHT: f32 = 18.0; /// Ghost xsmall icon button width in the shell title bar. +#[allow(dead_code)] pub const SHELL_TOGGLE_WIDTH: f32 = 28.0; +#[allow(dead_code)] pub const SHELL_TITLE_GAP: f32 = 4.0; pub const WELCOME_EDGE_INSET_H: f32 = 16.0; @@ -23,6 +24,7 @@ pub const WELCOME_ACTION_SPACER: f32 = 60.0; pub const WELCOME_ENTER_BUTTON_HEIGHT: f32 = 48.0; const WELCOME_ACTION_BAND: f32 = 260.0; +const WELCOME_HEADER_GAP: f32 = 8.0; const WELCOME_THEME_TOGGLE_HEIGHT: f32 = 32.0; /// macOS traffic-light inset matches gpui-component `TITLE_BAR_LEFT_PADDING`. @@ -52,6 +54,17 @@ pub fn welcome_header_row_height() -> f32 { text_column.max(WELCOME_THEME_TOGGLE_HEIGHT) } +/// Vertical space available for the centered brand frame and copy column. +pub fn welcome_vertical_content_budget(viewport: WindowViewport) -> f32 { + (viewport.height + - WELCOME_EDGE_INSET_TOP + - WELCOME_EDGE_INSET_BOTTOM + - welcome_header_row_height() + - WELCOME_HEADER_GAP + - WELCOME_ACTION_BAND) + .max(0.0) +} + /// Responsive welcome brand height. pub fn responsive_brand_height(viewport: WindowViewport) -> f32 { let square_limit = responsive_hero_size(viewport.width, viewport.height); @@ -68,23 +81,13 @@ pub fn responsive_brand_height(viewport: WindowViewport) -> f32 { pub fn show_off_brand_height(viewport: WindowViewport) -> f32 { let hero = responsive_brand_height(viewport); let width_limit = (viewport.width - WELCOME_EDGE_INSET_H * 2.0) / BRAND_ASPECT; - let available_height = - (viewport.height - welcome_header_row_height() - WELCOME_ACTION_BAND).max(0.0); + let available_height = welcome_vertical_content_budget(viewport); // Prominent intro size that still fits the viewport; morphs down to `hero`. width_limit .min(available_height * 0.4) .max(hero) } -/// Center of the docked brand: `[toggle-left] [brand lockup]` (layout A). -pub fn docked_brand_center(viewport: WindowViewport) -> (f32, f32) { - let title_h = TITLE_BAR_HEIGHT.as_f32(); - let width = brand_width(BRAND_SHELL_HEIGHT); - let x = title_bar_left_padding() + SHELL_TOGGLE_WIDTH + SHELL_TITLE_GAP + width * 0.5; - let _ = viewport; - (x, title_h * 0.5) -} - /// Linear interpolation between two values. pub fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t @@ -93,6 +96,17 @@ pub fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { #[cfg(test)] mod tests { use super::*; + use crate::app::hero::brand::brand_width; + use gpui_component::TITLE_BAR_HEIGHT; + + /// Center of the docked brand: `[toggle-left] [brand lockup]` (layout A). + fn docked_brand_center(viewport: WindowViewport) -> (f32, f32) { + let title_h = TITLE_BAR_HEIGHT.as_f32(); + let width = brand_width(BRAND_SHELL_HEIGHT); + let x = title_bar_left_padding() + SHELL_TOGGLE_WIDTH + SHELL_TITLE_GAP + width * 0.5; + let _ = viewport; + (x, title_h * 0.5) + } #[test] fn docked_brand_sits_after_left_toggle() { @@ -157,13 +171,7 @@ mod tests { height: 740.0, }; let height = show_off_brand_height(viewport); - let available = (viewport.height - - WELCOME_EDGE_INSET_TOP - - WELCOME_EDGE_INSET_BOTTOM - - welcome_header_row_height() - - 8.0 - - WELCOME_ACTION_BAND) - .max(0.0); + let available = welcome_vertical_content_budget(viewport); assert!(height + WELCOME_HERO_BRAND_FRAME_EXTRA <= available + 1.0); } @@ -174,13 +182,7 @@ mod tests { height: 500.0, }; let height = show_off_brand_height(viewport); - let available = (viewport.height - - WELCOME_EDGE_INSET_TOP - - WELCOME_EDGE_INSET_BOTTOM - - welcome_header_row_height() - - 8.0 - - WELCOME_ACTION_BAND) - .max(0.0); + let available = welcome_vertical_content_budget(viewport); assert!(height + WELCOME_HERO_BRAND_FRAME_EXTRA <= available + 1.0); } } diff --git a/src/app/hero/mod.rs b/src/app/hero/mod.rs index 196cf49..4ed88cb 100644 --- a/src/app/hero/mod.rs +++ b/src/app/hero/mod.rs @@ -7,9 +7,9 @@ pub use brand::{ BRAND_ASPECT, BRAND_IMAGE, BRAND_IMAGE_INVERSE, brand_width, opencore_brand_image, }; pub use layout::{ - lerp_f32, BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, docked_brand_center, - responsive_brand_height, responsive_hero_size, show_off_brand_height, - title_bar_left_padding, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, + lerp_f32, BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, responsive_brand_height, + responsive_hero_size, show_off_brand_height, title_bar_left_padding, + welcome_vertical_content_budget, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, }; diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index 6981920..f7a1a2e 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -52,6 +52,15 @@ impl WelcomeUiState { !self.intro_animating(now) } + #[cfg(test)] + pub fn complete_intro_for_test(&mut self) { + self.started_at = Some( + Instant::now() + .checked_sub(CHROME_REVEAL_DURATION + Duration::from_millis(1)) + .expect("recent instant"), + ); + } + /// Requests keyboard focus once per welcome session. pub fn ensure_initial_focus( &mut self, diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index ad197eb..367c844 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -8,7 +8,6 @@ use gpui::{ use gpui_component::button::{Button, ButtonVariants as _}; use std::cell::Cell; use std::rc::Rc; -use std::time::Instant; use crate::app::gpui_callbacks::WindowAppHandler; use crate::app::hero::{ @@ -23,10 +22,12 @@ use crate::shared::theme::{ }; use super::theme_toggle::theme_toggle_button; -use super::ui_state::WelcomeUiState; const HERO_TAGLINE: &str = "Your local AI command workspace"; /// Single-line tagline width at [`TypeRole::DisplayMd`] in Space Grotesk. +/// +/// The tagline uses `whitespace_nowrap`; content width should stay at or above this +/// value (default welcome window is 960px with 16px horizontal insets). const HERO_TAGLINE_COLUMN_WIDTH: f32 = 596.0; const HERO_GLOW_INSET_H: f32 = 44.0; const HERO_GLOW_INSET_TOP: f32 = 46.0; @@ -100,8 +101,7 @@ pub fn welcome_interactive_root( /// Full-screen welcome landing scene. pub fn welcome_screen( theme: OpenCoreTheme, - ui: &WelcomeUiState, - now: Instant, + reveal_progress: f32, callbacks: WelcomeCallbacks, persistence_error: Option<&str>, viewport: WindowViewport, @@ -109,7 +109,6 @@ pub fn welcome_screen( let background = theme.surface(BackgroundToken::Primary); let hero_height = responsive_brand_height(viewport); let show_off_height = show_off_brand_height(viewport); - let reveal_progress = ui.reveal_progress(now); div() .size_full() From 410a39630c10cd6f27788e56e7e2601cb52aca69 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Mon, 31 Aug 2026 17:33:32 +0700 Subject: [PATCH 22/22] style: apply rustfmt and fix clippy in welcome intro tests. --- src/app/desktop.rs | 12 +++--------- src/app/hero/layout.rs | 4 +--- src/app/hero/mod.rs | 10 +++++----- src/app/welcome/ui_state.rs | 4 +--- src/app/welcome/view.rs | 33 +++++++++++++++++---------------- 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/src/app/desktop.rs b/src/app/desktop.rs index 53ce3c2..9c653b8 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -720,7 +720,7 @@ mod welcome_intro_gating_tests { cx: &mut TestAppContext, store: Arc, ) -> (gpui::Entity, &mut gpui::VisualTestContext) { - cx.update(|app| gpui_component::init(app)); + cx.update(gpui_component::init); cx.add_window_view(|_window, cx| { OpenCoreApp::new( AppState::from_preferences(AppPreferences::default()), @@ -740,10 +740,7 @@ mod welcome_intro_gating_tests { let (app, cx) = welcome_app(cx, store); cx.update_window_entity(&app, |app, window, entity_cx| { - app.welcome_ui - .as_mut() - .expect("welcome ui") - .tick(start); + app.welcome_ui.as_mut().expect("welcome ui").tick(start); app.apply_welcome_command(WelcomeCommand::EnterPressed, window, entity_cx); }); @@ -764,10 +761,7 @@ mod welcome_intro_gating_tests { let theme_before = cx.read_entity(&app, |app, _| app.state.theme_mode()); cx.update_window_entity(&app, |app, _window, entity_cx| { - app.welcome_ui - .as_mut() - .expect("welcome ui") - .tick(start); + app.welcome_ui.as_mut().expect("welcome ui").tick(start); app.toggle_theme(entity_cx); }); diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index 1702471..b1c4832 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -83,9 +83,7 @@ pub fn show_off_brand_height(viewport: WindowViewport) -> f32 { let width_limit = (viewport.width - WELCOME_EDGE_INSET_H * 2.0) / BRAND_ASPECT; let available_height = welcome_vertical_content_budget(viewport); // Prominent intro size that still fits the viewport; morphs down to `hero`. - width_limit - .min(available_height * 0.4) - .max(hero) + width_limit.min(available_height * 0.4).max(hero) } /// Linear interpolation between two values. diff --git a/src/app/hero/mod.rs b/src/app/hero/mod.rs index 4ed88cb..7b3be0b 100644 --- a/src/app/hero/mod.rs +++ b/src/app/hero/mod.rs @@ -7,9 +7,9 @@ pub use brand::{ BRAND_ASPECT, BRAND_IMAGE, BRAND_IMAGE_INVERSE, brand_width, opencore_brand_image, }; pub use layout::{ - lerp_f32, BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, responsive_brand_height, - responsive_hero_size, show_off_brand_height, title_bar_left_padding, - welcome_vertical_content_budget, WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, - WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, WELCOME_ENTER_BUTTON_HEIGHT, - WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, + BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, WELCOME_ACTION_SPACER, + WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, + WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, lerp_f32, + responsive_brand_height, responsive_hero_size, show_off_brand_height, title_bar_left_padding, + welcome_vertical_content_budget, }; diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index f7a1a2e..5405337 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -29,9 +29,7 @@ impl WelcomeUiState { pub fn intro_animating(&self, now: Instant) -> bool { match self.started_at { None => true, - Some(started_at) => { - now.saturating_duration_since(started_at) < CHROME_REVEAL_DURATION - } + Some(started_at) => now.saturating_duration_since(started_at) < CHROME_REVEAL_DURATION, } } diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index 367c844..85517e8 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -11,10 +11,9 @@ use std::rc::Rc; use crate::app::gpui_callbacks::WindowAppHandler; use crate::app::hero::{ - lerp_f32, opencore_brand_image, responsive_brand_height, show_off_brand_height, - WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, - WELCOME_EDGE_INSET_TOP, WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, - WELCOME_TITLEBAR_HEIGHT, + WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, + WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, lerp_f32, + opencore_brand_image, responsive_brand_height, show_off_brand_height, }; use crate::app::viewport::WindowViewport; use crate::shared::theme::{ @@ -83,7 +82,12 @@ pub fn welcome_interactive_root( on_enter(window, cx); } }) - .child(div().size_full().pt(px(WELCOME_TITLEBAR_HEIGHT)).child(content)) + .child( + div() + .size_full() + .pt(px(WELCOME_TITLEBAR_HEIGHT)) + .child(content), + ) .child( div() .absolute() @@ -110,17 +114,14 @@ pub fn welcome_screen( let hero_height = responsive_brand_height(viewport); let show_off_height = show_off_brand_height(viewport); - div() - .size_full() - .bg(background) - .child(main_column( - theme, - callbacks, - persistence_error, - hero_height, - show_off_height, - reveal_progress, - )) + div().size_full().bg(background).child(main_column( + theme, + callbacks, + persistence_error, + hero_height, + show_off_height, + reveal_progress, + )) } fn is_enter_keystroke(event: &KeyDownEvent) -> bool {