From a35e0f78723e19a855531e9b203948d0f8c9dc06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 18:50:33 -0400 Subject: [PATCH] Rebuild /settings/appearance as the design form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appearance route now opens with the design layout: serif "GENERAL" overline + "Appearance" headline + intro paragraph, a four-tile wallpaper picker (Dawn/Violet/Mint/Slate with active outline + check glyph), a theme-mode segmented control, an accent swatch row, a reduce-motion toggle, and a reset row. Every control mutates real state through new shell methods (set_wallpaper_theme, set_theme_mode, toggle_reduce_motion, reset_appearance) which delegate to the core. The chrome lives in chrome::appearance_form so the page module is a six-line shim. Light/Dark theme buttons currently switch the persisted mode; rendering swaps will land when light/dark token sheets ship — keeping the persistence so the eventual flip is one place. --- claude-goal | 1 + .../src/shell/chrome/appearance_form.rs | 381 ++++++++++++++++++ crates/ely_app/src/shell/chrome/mod.rs | 2 + crates/ely_app/src/shell/internal_pages.rs | 2 +- .../src/shell/internal_pages/appearance.rs | 238 +---------- crates/ely_app/src/shell/settings_actions.rs | 39 +- 6 files changed, 432 insertions(+), 231 deletions(-) create mode 160000 claude-goal create mode 100644 crates/ely_app/src/shell/chrome/appearance_form.rs diff --git a/claude-goal b/claude-goal new file mode 160000 index 0000000..cacea1b --- /dev/null +++ b/claude-goal @@ -0,0 +1 @@ +Subproject commit cacea1bbe7c5221a2197559313e1e464d48a3c90 diff --git a/crates/ely_app/src/shell/chrome/appearance_form.rs b/crates/ely_app/src/shell/chrome/appearance_form.rs new file mode 100644 index 0000000..7e08390 --- /dev/null +++ b/crates/ely_app/src/shell/chrome/appearance_form.rs @@ -0,0 +1,381 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{ThemeMode, WallpaperTheme}; +use gpui::{ + AnyElement, Context, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, + SharedString, StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, + linear_gradient, prelude::FluentBuilder, px, rgb, rgba, +}; +use gpui_component::{IconName, scroll::ScrollableElement}; + +use crate::shell::ElyShell; + +pub(crate) fn render_appearance_form( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + div() + .flex_1() + .h_full() + .overflow_y_scrollbar() + .pt(px(28.0)) + .px(px(40.0)) + .pb(px(32.0)) + .child( + div() + .max_w(px(780.0)) + .mx_auto() + .flex() + .flex_col() + .gap(px(28.0)) + .child(render_header()) + .child(render_wallpaper_grid(snapshot, cx)) + .child(render_appearance_rows(snapshot, cx)), + ) + .into_any_element() +} + +fn render_header() -> AnyElement { + div() + .flex() + .flex_col() + .gap(px(6.0)) + .child( + div() + .text_size(px(11.0)) + .text_color(rgb(colors::INK_4)) + .child("GENERAL"), + ) + .child( + div() + .text_size(px(34.0)) + .font_weight(FontWeight(400.0)) + .text_color(rgb(colors::INK)) + .child("Appearance"), + ) + .child( + div() + .max_w(px(520.0)) + .text_size(px(13.0)) + .text_color(rgb(colors::INK_3)) + .child( + "Tune the atmosphere of your browser. ELY's wallpaper sets the ambient \ + palette of every surface; pick one and let it breathe.", + ), + ) + .into_any_element() +} + +fn render_wallpaper_grid( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + let active = snapshot.appearance.wallpaper(); + div() + .grid() + .grid_cols(4) + .gap(px(10.0)) + .child(render_wallpaper_swatch(WallpaperTheme::Dawn, active, cx)) + .child(render_wallpaper_swatch(WallpaperTheme::Violet, active, cx)) + .child(render_wallpaper_swatch(WallpaperTheme::Mint, active, cx)) + .child(render_wallpaper_swatch(WallpaperTheme::Slate, active, cx)) + .into_any_element() +} + +fn render_wallpaper_swatch( + theme: WallpaperTheme, + active: WallpaperTheme, + cx: &mut Context, +) -> AnyElement { + let label = wallpaper_label(theme); + let id = SharedString::from(format!("wallpaper-{}", label.to_ascii_lowercase())); + let selected = theme == active; + let (upper, lower, base) = swatch_colors(theme); + + div() + .id(id) + .flex() + .flex_col() + .gap(px(8.0)) + .cursor_pointer() + .hover(|style| style.opacity(0.94)) + .active(|style| style.opacity(0.85)) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_wallpaper_theme(theme, cx); + })) + .child( + div() + .h(px(96.0)) + .rounded(px(10.0)) + .when(selected, |el| { + el.border_2().border_color(rgb(colors::ACCENT)) + }) + .when(!selected, |el| { + el.border_1().border_color(rgba(colors::STROKE)) + }) + .relative() + .overflow_hidden() + .bg(rgb(base)) + .child( + div() + .absolute() + .inset_0() + .bg(linear_gradient( + 225.0, + linear_color_stop(upper, 0.0), + linear_color_stop(transparent_like(upper), 0.6), + )), + ) + .child( + div() + .absolute() + .inset_0() + .bg(linear_gradient( + 45.0, + linear_color_stop(lower, 0.0), + linear_color_stop(transparent_like(lower), 0.6), + )), + ), + ) + .child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .text_size(px(12.0)) + .child( + div() + .flex_1() + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK)) + .child(label), + ) + .when(selected, |el| { + el.child( + div() + .text_color(rgb(colors::ACCENT)) + .child(IconName::Check), + ) + }), + ) + .into_any_element() +} + +fn render_appearance_rows( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + let appearance = snapshot.appearance; + + div() + .flex() + .flex_col() + .child(render_theme_mode_row(appearance.theme_mode(), cx)) + .child(render_accent_row()) + .child(render_reduce_motion_row(appearance.reduce_motion(), cx)) + .child(render_reset_row(cx)) + .into_any_element() +} + +fn render_theme_mode_row( + active: ThemeMode, + cx: &mut Context, +) -> AnyElement { + settings_row( + "Theme mode", + "Match system appearance, or pick one to lock.", + div() + .flex() + .items_center() + .gap(px(2.0)) + .p(px(2.0)) + .rounded(px(8.0)) + .bg(rgba(SEGMENT_BG)) + .child(theme_segment("System", ThemeMode::System, active, cx)) + .child(theme_segment("Light", ThemeMode::Light, active, cx)) + .child(theme_segment("Dark", ThemeMode::Dark, active, cx)) + .into_any_element(), + ) +} + +fn theme_segment( + label: &'static str, + mode: ThemeMode, + active: ThemeMode, + cx: &mut Context, +) -> AnyElement { + let selected = mode == active; + let bg = if selected { 0xffffffff } else { 0x00000000 }; + let id = SharedString::from(format!("theme-mode-{}", label.to_ascii_lowercase())); + + div() + .id(id) + .px(px(12.0)) + .py(px(4.0)) + .rounded(px(6.0)) + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK_2)) + .bg(rgba(bg)) + .cursor_pointer() + .hover(|style| style.opacity(0.92)) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_theme_mode(mode, cx); + })) + .child(label) + .into_any_element() +} + +fn render_accent_row() -> AnyElement { + settings_row( + "Accent color", + "Used across selection, focus rings & links.", + div() + .flex() + .items_center() + .gap(px(8.0)) + .child(swatch(colors::ACCENT, true)) + .child(swatch(0xec6a8e, false)) + .child(swatch(0x7c6cf7, false)) + .child(swatch(0x4fb59a, false)) + .child(swatch(colors::INK, false)) + .into_any_element(), + ) +} + +fn swatch(color: u32, selected: bool) -> AnyElement { + let mut element = div() + .size(px(22.0)) + .rounded_full() + .bg(rgb(color)) + .border_1() + .border_color(rgba(0x0000000d)); + if selected { + element = element + .border_2() + .border_color(rgb(colors::ACCENT)); + } + element.into_any_element() +} + +fn render_reduce_motion_row(reduce: bool, cx: &mut Context) -> AnyElement { + settings_row( + "Reduce motion", + "Mute transitions and ambient effects.", + div() + .id(SharedString::from("toggle-reduce-motion")) + .w(px(34.0)) + .h(px(20.0)) + .rounded_full() + .bg(if reduce { rgb(colors::ACCENT) } else { rgba(0x281e1426) }) + .p(px(2.0)) + .cursor_pointer() + .hover(|style| style.opacity(0.9)) + .active(|style| style.opacity(0.78)) + .on_click(cx.listener(|shell, _, _, cx| shell.toggle_reduce_motion(cx))) + .child( + div() + .size(px(16.0)) + .rounded_full() + .bg(rgb(0xffffff)) + .when(reduce, |el| el.ml(px(14.0))), + ) + .into_any_element(), + ) +} + +fn render_reset_row(cx: &mut Context) -> AnyElement { + settings_row( + "Restore defaults", + "Reset wallpaper, theme mode, and motion to defaults.", + div() + .id(SharedString::from("appearance-reset")) + .px(px(12.0)) + .py(px(7.0)) + .rounded(px(8.0)) + .bg(rgba(SEGMENT_BG)) + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK_2)) + .cursor_pointer() + .hover(|style| style.opacity(0.92)) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(|shell, _, _, cx| shell.reset_appearance(cx))) + .child("Reset") + .into_any_element(), + ) +} + +fn settings_row(title: &'static str, detail: &'static str, control: AnyElement) -> AnyElement { + div() + .flex() + .items_center() + .gap(px(16.0)) + .py(px(14.0)) + .border_b_1() + .border_color(rgba(colors::DIVIDER)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK)) + .child(title), + ) + .child( + div() + .text_size(px(11.5)) + .text_color(rgb(colors::INK_3)) + .child(detail), + ), + ) + .child(control) + .into_any_element() +} + +fn wallpaper_label(theme: WallpaperTheme) -> &'static str { + match theme { + WallpaperTheme::Dawn => "Dawn", + WallpaperTheme::Violet => "Violet", + WallpaperTheme::Mint => "Mint", + WallpaperTheme::Slate => "Slate", + } +} + +fn swatch_colors(theme: WallpaperTheme) -> (Hsla, Hsla, u32) { + match theme { + WallpaperTheme::Dawn => ( + hsla(351.0 / 360.0, 1.0, 0.91, 0.95), + hsla(228.0 / 360.0, 1.0, 0.86, 0.85), + 0xefe8e1, + ), + WallpaperTheme::Violet => ( + hsla(263.0 / 360.0, 1.0, 0.88, 0.95), + hsla(33.0 / 360.0, 1.0, 0.87, 0.85), + 0xe9e2ee, + ), + WallpaperTheme::Mint => ( + hsla(146.0 / 360.0, 0.69, 0.85, 0.95), + hsla(40.0 / 360.0, 1.0, 0.86, 0.85), + 0xe6ece2, + ), + WallpaperTheme::Slate => ( + hsla(218.0 / 360.0, 0.20, 0.85, 0.95), + hsla(20.0 / 360.0, 0.0, 0.10, 0.40), + 0xd6dae3, + ), + } +} + +fn transparent_like(color: Hsla) -> Hsla { + hsla(color.h, color.s, color.l, 0.0) +} + +const SEGMENT_BG: u32 = 0x281e140d; diff --git a/crates/ely_app/src/shell/chrome/mod.rs b/crates/ely_app/src/shell/chrome/mod.rs index a366e22..6eb9d60 100644 --- a/crates/ely_app/src/shell/chrome/mod.rs +++ b/crates/ely_app/src/shell/chrome/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod appearance_form; pub(crate) mod brand_glyph; pub(crate) mod command_footer; pub(crate) mod command_overlay; @@ -9,6 +10,7 @@ pub(crate) mod split_pane; pub(crate) mod topbar; pub(crate) mod wallpaper; +pub(crate) use appearance_form::render_appearance_form; pub(crate) use brand_glyph::render_glyph_for; pub(crate) use command_overlay::render_command_overlay; pub(crate) use home::render_home_page; diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index ae636e2..ec13717 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -84,7 +84,7 @@ impl ElyShell { "ely://about" => self.render_about_page(snapshot), "ely://settings" => self.render_settings_page(snapshot, cx), "ely://settings/advanced" => self.render_advanced_page(snapshot), - "ely://settings/appearance" => self.render_appearance_page(snapshot), + "ely://settings/appearance" => self.render_appearance_page(snapshot, cx), "ely://settings/general" => self.render_general_page(snapshot, cx), "ely://settings/sidebar-tabs" => self.render_sidebar_tabs_page(snapshot, cx), "ely://settings/search" => self.render_search_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/appearance.rs b/crates/ely_app/src/shell/internal_pages/appearance.rs index 0688e05..ae7ca13 100644 --- a/crates/ely_app/src/shell/internal_pages/appearance.rs +++ b/crates/ely_app/src/shell/internal_pages/appearance.rs @@ -1,235 +1,15 @@ use ely_browser_core::BrowserSnapshot; -use ely_design_system::colors; -use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; -use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; +use gpui::{AnyElement, Context}; -use super::{ElyShell, render_canvas_surface}; - -struct ColorToken { - label: &'static str, - detail: &'static str, - value: u32, -} - -const THEME_TOKENS: &[ColorToken] = &[ - ColorToken { label: "Canvas", detail: "Browser frame background", value: colors::CANVAS }, - ColorToken { - label: "Canvas Soft", - detail: "Internal page backdrop", - value: colors::CANVAS_SOFT, - }, - ColorToken { label: "Surface", detail: "Cards and panels", value: colors::SURFACE_CARD }, - ColorToken { label: "Ink", detail: "Primary text", value: colors::INK }, - ColorToken { label: "Muted", detail: "Secondary text", value: colors::MUTED }, - ColorToken { label: "Hairline", detail: "Borders and dividers", value: colors::HAIRLINE }, - ColorToken { label: "Accent", detail: "Primary actions", value: colors::PRIMARY }, - ColorToken { label: "Success", detail: "Resolved state", value: colors::SUCCESS }, - ColorToken { label: "Error", detail: "Attention state", value: colors::ERROR }, -]; +use super::ElyShell; +use crate::shell::chrome::render_appearance_form; impl ElyShell { - pub(super) fn render_appearance_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { - render_canvas_surface( - div() - .size_full() - .p_8() - .flex() - .flex_col() - .gap_5() - .child(render_appearance_header(snapshot)) - .child(render_theme_summary(snapshot)) - .child(render_color_sections(snapshot)), - ) + pub(super) fn render_appearance_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_appearance_form(snapshot, cx) } } - -fn render_appearance_header(snapshot: &BrowserSnapshot) -> AnyElement { - div() - .flex() - .items_end() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Appearance")) - .child( - div() - .text_sm() - .truncate() - .text_color(rgb(colors::MUTED)) - .child(format!("Space: {}", snapshot.active_space_name)), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::MUTED)) - .child(IconName::Palette) - .child(format!("{} tokens", THEME_TOKENS.len())), - ) - .into_any_element() -} - -fn render_theme_summary(snapshot: &BrowserSnapshot) -> AnyElement { - div() - .rounded_md() - .border_1() - .border_color(rgb(colors::HAIRLINE)) - .bg(rgb(colors::CANVAS_SOFT)) - .px_4() - .py_3() - .flex() - .items_center() - .justify_between() - .gap_4() - .child( - div() - .min_w_0() - .flex() - .items_center() - .gap_3() - .child(div().text_color(rgb(colors::PRIMARY)).child(IconName::Palette)) - .child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .text_color(rgb(colors::INK)) - .child("ELY Theme"), - ) - .child(div().text_xs().truncate().text_color(rgb(colors::MUTED)).child( - format!( - "{} profile on {} space", - snapshot.active_profile_name, snapshot.active_space_name - ), - )), - ), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::MUTED)) - .child(color_swatch(colors::PRIMARY)) - .child(hex_label(colors::PRIMARY)), - ) - .into_any_element() -} - -fn render_color_sections(snapshot: &BrowserSnapshot) -> AnyElement { - div() - .flex_1() - .min_h_0() - .flex() - .flex_col() - .overflow_y_scrollbar() - .border_t_1() - .border_color(rgb(colors::HAIRLINE)) - .child(render_section_label("Theme Tokens")) - .children(THEME_TOKENS.iter().map(render_token_row)) - .child(render_section_label("Current Context")) - .child(render_active_space_color(snapshot)) - .child(render_active_profile_color(snapshot)) - .into_any_element() -} - -fn render_section_label(label: &'static str) -> AnyElement { - div() - .pt_4() - .pb_2() - .text_xs() - .font_semibold() - .text_color(rgb(colors::MUTED)) - .child(label) - .into_any_element() -} - -fn render_token_row(token: &ColorToken) -> AnyElement { - render_color_row(token.label, token.detail.to_string(), token.value) -} - -fn render_active_space_color(snapshot: &BrowserSnapshot) -> AnyElement { - let detail = format!("Space: {}", snapshot.active_space_name); - let value = snapshot - .spaces - .iter() - .find(|space| space.id() == &snapshot.active_space_id) - .map_or(colors::ERROR, |space| space.accent_hex()); - - render_color_row("Active Space Accent", detail, value) -} - -fn render_active_profile_color(snapshot: &BrowserSnapshot) -> AnyElement { - let detail = format!("Profile: {}", snapshot.active_profile_name); - let value = snapshot - .profiles - .iter() - .find(|profile| profile.id() == &snapshot.active_profile_id) - .map_or(colors::ERROR, |profile| profile.color_hex()); - - render_color_row("Active Profile Color", detail, value) -} - -fn render_color_row(label: &'static str, detail: String, value: u32) -> AnyElement { - div() - .py_3() - .border_b_1() - .border_color(rgb(colors::HAIRLINE)) - .flex() - .items_center() - .justify_between() - .gap_4() - .child( - div().min_w_0().flex().items_center().gap_3().child(color_swatch(value)).child( - div() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_semibold() - .truncate() - .text_color(rgb(colors::INK)) - .child(label), - ) - .child(div().text_xs().truncate().text_color(rgb(colors::MUTED)).child(detail)), - ), - ) - .child( - div().text_xs().font_semibold().text_color(rgb(colors::BODY)).child(hex_label(value)), - ) - .into_any_element() -} - -fn color_swatch(value: u32) -> AnyElement { - div() - .w(px(30.0)) - .h(px(22.0)) - .rounded_md() - .border_1() - .border_color(rgb(colors::HAIRLINE_STRONG)) - .bg(rgb(value)) - .into_any_element() -} - -fn hex_label(value: u32) -> String { - format!("#{value:06X}") -} diff --git a/crates/ely_app/src/shell/settings_actions.rs b/crates/ely_app/src/shell/settings_actions.rs index 6c645b3..07c6b0a 100644 --- a/crates/ely_app/src/shell/settings_actions.rs +++ b/crates/ely_app/src/shell/settings_actions.rs @@ -1,7 +1,7 @@ use ely_domain::{ ArchivePolicy, DiagnosticsReportingPolicy, DownloadPolicy, FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, ProfileId, ProfileSyncPolicy, SearchEngine, - SyncObjectKind, SyncObjectPolicy, UpdatePolicy, + SyncObjectKind, SyncObjectPolicy, ThemeMode, UpdatePolicy, WallpaperTheme, }; use gpui::Context; @@ -49,6 +49,43 @@ impl ElyShell { } } + pub(super) fn set_wallpaper_theme( + &mut self, + wallpaper: WallpaperTheme, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state { + core.set_wallpaper_theme(wallpaper); + cx.notify(); + } + } + + pub(super) fn set_theme_mode( + &mut self, + theme_mode: ThemeMode, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state { + core.set_theme_mode(theme_mode); + cx.notify(); + } + } + + pub(super) fn toggle_reduce_motion(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state { + let next = !core.appearance().reduce_motion(); + core.set_reduce_motion(next); + cx.notify(); + } + } + + pub(super) fn reset_appearance(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state { + core.reset_appearance(); + cx.notify(); + } + } + pub(super) fn reset_general_settings(&mut self, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state { core.reset_general_settings();