Restructure home hero with greeting and design suggestion pills

The home page now opens with the design's three-stage hero — a sun-icon
greeting row that picks morning/afternoon/evening from the system clock
and addresses the active profile by name, the serif headline, then a
54-tall search pill with an arrow chip and three contextual pills below
(Search Tabs, Switch Workspace, Open History).

Greeting derives "morning/afternoon/evening" from a UTC hour bucket,
which matches the design's 1500x940 dawn artboard well enough without
pulling in a tz-aware crate. Pure helper covered by unit tests so the
phase boundaries don't drift unintentionally.

The favourites grid is widened to 7 columns to match the design and
gains a plus-tile that routes to ely://bookmarks. Tabs section keeps the
existing data flow with a chevron section label to match the design's
collapsible affordance.
This commit is contained in:
2026-05-09 18:01:37 -04:00
parent 599ed51ce7
commit 76eff3cf6e
@@ -1,34 +1,24 @@
use std::time::{SystemTime, UNIX_EPOCH};
use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors;
use ely_domain::{BrowserTab, TabId};
use ely_domain::BrowserTab;
use gpui::{
AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString,
StatefulInteractiveElement, Styled, div, px, rgb, rgba,
AnyElement, BoxShadow, Context, FontWeight, InteractiveElement, IntoElement, ParentElement,
SharedString, StatefulInteractiveElement, Styled, div, hsla, point, px, rgb, rgba,
};
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
use gpui_component::{IconName, scroll::ScrollableElement};
use super::ElyShell;
struct QuickAction {
label: &'static str,
route: &'static str,
icon: IconName,
}
const QUICK_ACTIONS: &[QuickAction] = &[
QuickAction { label: "History", route: "ely://history", icon: IconName::Undo2 },
QuickAction { label: "Bookmarks", route: "ely://bookmarks", icon: IconName::BookOpen },
QuickAction { label: "Downloads", route: "ely://downloads", icon: IconName::Folder },
QuickAction { label: "Plugins", route: "ely://plugins", icon: IconName::Asterisk },
QuickAction { label: "Settings", route: "ely://settings", icon: IconName::Settings2 },
];
impl ElyShell {
pub(super) fn render_new_tab_page(
&mut self,
snapshot: &BrowserSnapshot,
cx: &mut Context<Self>,
) -> AnyElement {
let greeting = greeting_for_now(SystemTime::now(), &snapshot.active_profile_name);
div()
.flex_1()
.h_full()
@@ -36,12 +26,13 @@ impl ElyShell {
.child(
div()
.size_full()
.p(px(48.0))
.pt(px(48.0))
.px(px(64.0))
.pb(px(28.0))
.flex()
.flex_col()
.gap(px(24.0))
.child(render_hero_section())
.child(render_quick_actions(cx))
.gap(px(48.0))
.child(render_hero(greeting, cx))
.child(render_favorites_grid(snapshot, cx))
.child(render_tabs_section(snapshot, cx)),
)
@@ -49,82 +40,153 @@ impl ElyShell {
}
}
fn render_hero_section() -> AnyElement {
fn render_hero(greeting: String, cx: &mut Context<ElyShell>) -> AnyElement {
div()
.flex()
.flex_col()
.items_center()
.gap(px(28.0))
.pt(px(24.0))
.pb(px(14.0))
.gap(px(14.0))
.child(render_greeting_row(greeting))
.child(render_serif_headline())
.child(render_search_bar(cx))
.child(render_suggestion_pills(cx))
.into_any_element()
}
fn render_greeting_row(text: String) -> AnyElement {
div()
.flex()
.items_center()
.gap(px(8.0))
.text_size(px(13.0))
.text_color(rgb(colors::INK_3))
.child(
div()
.text_color(rgb(colors::ACCENT_LIGHT))
.child(IconName::Sun),
)
.child(text)
.into_any_element()
}
fn render_serif_headline() -> AnyElement {
div()
.text_size(px(64.0))
.font_weight(gpui::FontWeight(400.0))
.font_weight(FontWeight(400.0))
.text_color(rgb(colors::INK))
.child("Where focus finds flow."),
)
.child(
.child("Where focus finds flow.")
.into_any_element()
}
fn render_search_bar(cx: &mut Context<ElyShell>) -> AnyElement {
div()
.id(SharedString::from("home-search"))
.w(px(640.0))
.h(px(54.0))
.rounded(px(14.0))
.bg(rgba(SEARCH_BAR_BG))
.bg(rgba(SEARCH_BG))
.shadow(card_shadow())
.px(px(16.0))
.flex()
.items_center()
.gap(px(12.0))
.child(div().text_color(rgb(colors::INK_3)).child(IconName::Search))
.cursor_pointer()
.on_click(cx.listener(|shell, _, window, cx| {
shell.focus_address_bar(window, cx);
}))
.child(
div()
.text_color(rgb(colors::INK_3))
.child(IconName::Search),
)
.child(
div()
.flex_1()
.text_size(px(14.0))
.text_color(rgb(colors::INK_4))
.child("Search the web or ELY"),
),
)
.child(
div()
.size(px(28.0))
.rounded(px(8.0))
.bg(rgba(ARROW_CHIP_BG))
.flex()
.items_center()
.justify_center()
.text_color(rgb(colors::INK_3))
.child(IconName::ArrowRight),
)
.into_any_element()
}
const SEARCH_BAR_BG: u32 = 0xffffffd9; // rgba(255,255,255,0.85)
fn render_quick_actions(cx: &mut Context<ElyShell>) -> AnyElement {
fn render_suggestion_pills(cx: &mut Context<ElyShell>) -> AnyElement {
div()
.flex()
.items_center()
.justify_center()
.gap(px(8.0))
.children(QUICK_ACTIONS.iter().enumerate().map(|(index, action)| {
let route = action.route;
.child(render_pill(
"pill-search-tabs",
IconName::Search,
"Search Tabs",
cx,
|shell, window, cx| shell.focus_address_bar(window, cx),
))
.child(render_pill(
"pill-switch-workspace",
IconName::LayoutDashboard,
"Switch Workspace",
cx,
|shell, window, cx| shell.cycle_to_next_space(window, cx),
))
.child(render_pill(
"pill-open-history",
IconName::Undo2,
"Open History",
cx,
|shell, window, cx| shell.open_internal_tab("ely://history", window, cx),
))
.into_any_element()
}
fn render_pill<F>(
id: &'static str,
icon: IconName,
label: &'static str,
cx: &mut Context<ElyShell>,
handler: F,
) -> AnyElement
where
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
{
div()
.id(SharedString::from(format!("quick-action-{index}")))
.id(SharedString::from(id))
.rounded(px(999.0))
.bg(rgba(colors::GLASS_2))
.bg(rgba(PILL_BG))
.shadow(soft_shadow())
.px(px(12.0))
.py(px(6.0))
.flex()
.items_center()
.gap(px(6.0))
.cursor_pointer()
.hover(|style| style.bg(rgba(colors::GLASS_3)))
.hover(|style| style.bg(rgba(PILL_BG_HOVER)))
.active(|style| style.opacity(0.82))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.open_internal_tab(route, window, cx);
}))
.on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx)))
.child(
div()
.text_color(rgb(colors::INK_3))
.text_size(px(12.0))
.child(action.icon.clone()),
.child(icon),
)
.child(
div()
.text_size(px(12.0))
.font_weight(gpui::FontWeight(500.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(colors::INK_2))
.child(action.label),
.child(label),
)
}))
.into_any_element()
}
@@ -137,26 +199,42 @@ fn render_favorites_grid(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>)
.flex()
.flex_col()
.gap(px(12.0))
.child(
div()
.text_size(px(10.5))
.font_weight(gpui::FontWeight(500.0))
.text_color(rgb(colors::INK_4))
.child("FAVORITES"),
)
.child(render_section_chevron_label("Favorites"))
.child(
div()
.grid()
.grid_cols(7)
.gap(px(10.0))
.children(snapshot.favorites.iter().enumerate().map(|(index, tab)| {
render_favorite_card(index, tab, cx)
})),
.children(
snapshot
.favorites
.iter()
.enumerate()
.map(|(index, tab)| render_favorite_tile(index, tab, cx)),
)
.child(render_add_favorite_tile(cx)),
)
.into_any_element()
}
fn render_favorite_card(
fn render_section_chevron_label(label: &'static str) -> AnyElement {
div()
.flex()
.items_center()
.gap(px(6.0))
.text_color(rgb(colors::INK_3))
.text_size(px(12.5))
.font_weight(FontWeight(500.0))
.child(label)
.child(
div()
.text_color(rgb(colors::INK_4))
.child(IconName::ChevronDown),
)
.into_any_element()
}
fn render_favorite_tile(
index: usize,
tab: &BrowserTab,
cx: &mut Context<ElyShell>,
@@ -169,50 +247,68 @@ fn render_favorite_card(
.unwrap_or('?')
.to_uppercase()
.to_string();
let title = tab.title().to_string();
div()
.id(SharedString::from(format!("fav-card-{index}")))
.id(SharedString::from(format!("fav-tile-{index}")))
.h(px(96.0))
.rounded(px(16.0))
.bg(rgba(colors::GLASS_2))
.bg(rgba(CARD_BG))
.shadow(card_shadow())
.flex()
.flex_col()
.items_center()
.justify_center()
.gap(px(8.0))
.cursor_pointer()
.hover(|style| style.bg(rgba(colors::GLASS_3)))
.active(|style| style.opacity(0.82))
.hover(|style| style.bg(rgba(CARD_BG_HOVER)))
.active(|style| style.opacity(0.85))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_tab(&tab_id, window, cx);
}))
.child(
div()
.size(px(36.0))
.rounded(px(10.0))
.size(px(28.0))
.rounded(px(7.0))
.bg(rgb(colors::ACCENT))
.flex()
.items_center()
.justify_center()
.child(
div()
.text_size(px(16.0))
.font_semibold()
.text_color(rgb(colors::SURFACE_CARD))
.text_size(px(14.0))
.font_weight(FontWeight(600.0))
.text_color(rgb(0xffffff))
.child(initial),
),
)
.child(
div()
.text_size(px(11.0))
.text_color(rgb(colors::INK_2))
.text_size(px(11.5))
.text_color(rgb(colors::INK_3))
.max_w(px(80.0))
.truncate()
.child(tab.title().to_string()),
.child(title),
)
.into_any_element()
}
fn render_add_favorite_tile(cx: &mut Context<ElyShell>) -> AnyElement {
div()
.id(SharedString::from("fav-tile-add"))
.h(px(96.0))
.rounded(px(16.0))
.bg(rgba(ADD_TILE_BG))
.flex()
.items_center()
.justify_center()
.text_color(rgb(colors::INK_4))
.cursor_pointer()
.hover(|style| style.bg(rgba(CARD_BG)))
.on_click(cx.listener(|shell, _, window, cx| {
shell.open_internal_tab("ely://bookmarks", window, cx);
}))
.child(IconName::Plus)
.into_any_element()
}
fn render_tabs_section(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
if snapshot.tabs.is_empty() {
return div().into_any_element();
@@ -222,25 +318,7 @@ fn render_tabs_section(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -
.flex()
.flex_col()
.gap(px(12.0))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
div()
.text_size(px(10.5))
.font_weight(gpui::FontWeight(500.0))
.text_color(rgb(colors::INK_4))
.child("OPEN TABS"),
)
.child(
div()
.text_size(px(11.0))
.text_color(rgb(colors::INK_4))
.child(format!("{}", snapshot.tabs.len())),
),
)
.child(render_section_chevron_label("Open Tabs"))
.child(
div()
.flex()
@@ -255,15 +333,15 @@ fn render_tabs_section(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -
fn render_tab_row(
index: usize,
tab: &BrowserTab,
active_tab_id: &TabId,
active_tab_id: &ely_domain::TabId,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let tab_id = tab.id().clone();
let active = tab.id() == active_tab_id;
let bg = if active { colors::GLASS_3 } else { 0x00000000 };
let bg = if active { CARD_BG_HOVER } else { 0x00000000 };
div()
.id(SharedString::from(format!("tab-row-{index}")))
.id(SharedString::from(format!("home-tab-row-{index}")))
.rounded(px(8.0))
.px(px(10.0))
.py(px(8.0))
@@ -272,7 +350,7 @@ fn render_tab_row(
.gap(px(10.0))
.cursor_pointer()
.bg(rgba(bg))
.hover(|style| style.bg(rgba(colors::GLASS_3)))
.hover(|style| style.bg(rgba(CARD_BG_HOVER)))
.active(|style| style.opacity(0.82))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_tab(&tab_id, window, cx);
@@ -292,7 +370,7 @@ fn render_tab_row(
.child(
div()
.text_size(px(13.0))
.font_weight(gpui::FontWeight(500.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(colors::INK))
.truncate()
.child(tab.title().to_string()),
@@ -307,3 +385,96 @@ fn render_tab_row(
)
.into_any_element()
}
fn greeting_for_now(now: SystemTime, name: &str) -> String {
let phase = match now.duration_since(UNIX_EPOCH) {
Ok(duration) => {
let local_seconds = duration.as_secs() as i64;
let day_seconds = local_seconds.rem_euclid(86_400);
let hour = (day_seconds / 3_600) as u32;
day_phase(hour)
}
Err(_) => "today",
};
if name.is_empty() {
format!("Good {phase}")
} else {
format!("Good {phase}, {name}")
}
}
fn day_phase(hour_utc: u32) -> &'static str {
match hour_utc {
5..=11 => "morning",
12..=17 => "afternoon",
_ => "evening",
}
}
const SEARCH_BG: u32 = 0xffffffd9;
const ARROW_CHIP_BG: u32 = 0x281e140a;
const PILL_BG: u32 = 0xffffff8c;
const PILL_BG_HOVER: u32 = 0xffffffd9;
const CARD_BG: u32 = 0xffffffc7;
const CARD_BG_HOVER: u32 = 0xffffffeb;
const ADD_TILE_BG: u32 = 0xffffff7f;
fn card_shadow() -> Vec<BoxShadow> {
vec![BoxShadow {
color: hsla(25.0 / 360.0, 0.33, 0.12, 0.18),
offset: point(px(0.0), px(8.0)),
blur_radius: px(24.0),
spread_radius: px(-10.0),
}]
}
fn soft_shadow() -> Vec<BoxShadow> {
vec![
BoxShadow {
color: hsla(0.0, 0.0, 1.0, 0.7),
offset: point(px(0.0), px(1.0)),
blur_radius: px(0.0),
spread_radius: px(0.0),
},
BoxShadow {
color: hsla(25.0 / 360.0, 0.33, 0.12, 0.08),
offset: point(px(0.0), px(0.0)),
blur_radius: px(0.0),
spread_radius: px(1.0),
},
]
}
#[cfg(test)]
mod tests {
use super::{day_phase, greeting_for_now};
use std::time::{Duration, UNIX_EPOCH};
#[test]
fn day_phase_assigns_morning_to_seven_am() {
assert_eq!(day_phase(7), "morning");
}
#[test]
fn day_phase_assigns_afternoon_to_one_pm() {
assert_eq!(day_phase(13), "afternoon");
}
#[test]
fn day_phase_assigns_evening_to_eleven_pm() {
assert_eq!(day_phase(23), "evening");
}
#[test]
fn greeting_includes_profile_name() {
let two_pm = UNIX_EPOCH + Duration::from_secs(14 * 3_600);
assert_eq!(greeting_for_now(two_pm, "Alex"), "Good afternoon, Alex");
}
#[test]
fn greeting_omits_name_when_blank() {
let nine_am = UNIX_EPOCH + Duration::from_secs(9 * 3_600);
assert_eq!(greeting_for_now(nine_am, ""), "Good morning");
}
}