From 943ff7c15f66022715cd0a2c816db43e488b1bb0 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 1 Jul 2026 07:03:07 -0400 Subject: [PATCH] Unify colors via theme CSS variables; theme egui visuals Add Theme::var(name, ctx) (CSS custom-property getter) and Theme::apply_to_egui(ctx) which maps the palette onto egui's global Visuals so standard widgets share the theme colors. Add a mobile::palette::Palette built from the theme each frame and replace the duplicated per-file C_* color constants across all mobile modules (omni, topbar, stack, inspector, transport, intent, shell) with it. Add --scrim and --accent-* category vars to styles.css. Apply the theme visuals every frame in update(). Mobile and the main UI now share one CSS-variable-driven palette that responds to light/dark/user themes. --- .../lightningbeam-editor/assets/styles.css | 8 +++ .../lightningbeam-editor/src/main.rs | 4 ++ .../src/mobile/inspector.rs | 27 +++----- .../lightningbeam-editor/src/mobile/intent.rs | 44 +++++------- .../lightningbeam-editor/src/mobile/mod.rs | 20 +++--- .../lightningbeam-editor/src/mobile/omni.rs | 68 ++++++++----------- .../src/mobile/palette.rs | 59 ++++++++++++++++ .../lightningbeam-editor/src/mobile/stack.rs | 36 ++++------ .../lightningbeam-editor/src/mobile/topbar.rs | 50 +++++++------- .../src/mobile/transport.rs | 26 +++---- .../lightningbeam-editor/src/theme.rs | 67 ++++++++++++++++++ 11 files changed, 255 insertions(+), 154 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/mobile/palette.rs diff --git a/lightningbeam-ui/lightningbeam-editor/assets/styles.css b/lightningbeam-ui/lightningbeam-editor/assets/styles.css index 7599b9b..f947592 100644 --- a/lightningbeam-ui/lightningbeam-editor/assets/styles.css +++ b/lightningbeam-ui/lightningbeam-editor/assets/styles.css @@ -99,6 +99,14 @@ --font-size-small: 11px; --font-size-default: 13px; --pane-border-width: 1px; + + /* Modal backdrop (translucent) + intent/category accents (shared by light & dark) */ + --scrim: #10141ab0; + --accent-coral: #e8826b; + --accent-cyan: #54c3e8; + --accent-amber: #f4a340; + --accent-pink: #c75b8a; + --accent-violet: #8a6ec0; } /* ============================================ diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index ec0f956..63e0c6a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -5343,6 +5343,10 @@ impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let _frame_start = std::time::Instant::now(); + // Apply the theme's palette to egui's global visuals so the standard widgets (dialogs, pane + // chrome, text) share the same colors as the mobile UI and respond to light/dark/user themes. + self.theme.apply_to_egui(ctx); + // === Raster fault-in (Phase 3 paging) === // The canvas records raster keyframe ids whose `raw_pixels` weren't resident // (it can't mutate the document while rendering). Drain that sink here, BEFORE diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs index 2cbeb4f..e75ab69 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs @@ -6,16 +6,10 @@ use eframe::egui; use lightningbeam_core::pane::PaneType; use lightningbeam_core::selection::FocusSelection; -use super::{surface, MobileState, MOBILE_NS}; +use super::{surface, MobileState, Palette, MOBILE_NS}; use crate::panes::{NodePath, SharedPaneState}; use crate::RenderContext; -const C_SHEET: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37); -const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); -const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1); -const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); -const C_CYAN: egui::Color32 = egui::Color32::from_rgb(0x54, 0xc3, 0xe8); - const GRAB_H: f32 = 16.0; const HEAD_H: f32 = 30.0; const CHIP_H: f32 = 30.0; @@ -104,14 +98,15 @@ pub fn render( region_h: f32, rc: &mut RenderContext, state: &mut MobileState, + pal: &Palette, ) { // Sheet background with rounded top. ui.painter().rect_filled( rect, egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 }, - C_SHEET, + pal.surface_alt, ); - ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, C_LINE)); + ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line)); // Grab handle — drag to resize the sheet. let grab = egui::Rect::from_min_max( @@ -120,7 +115,7 @@ pub fn render( ); let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag()); let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(34.0, 4.0)); - ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { C_CYAN } else { C_LINE }); + ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line }); if gresp.dragged() && region_h > 1.0 { state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region_h).clamp(0.2, 0.85); } @@ -139,7 +134,7 @@ pub fn render( egui::Align2::LEFT_CENTER, title(&rc.shared), egui::FontId::proportional(13.0), - C_BRIGHT, + pal.text, ); let close = egui::Rect::from_min_size(egui::pos2(head.right() - 36.0, head.top()), egui::vec2(36.0, HEAD_H)); let cresp = ui.interact(close, ui.id().with("mobile_inspector_close"), egui::Sense::click()); @@ -148,7 +143,7 @@ pub fn render( egui::Align2::CENTER_CENTER, super::icons::X, super::icons::font(16.0), - if cresp.hovered() { C_BRIGHT } else { C_DIM }, + if cresp.hovered() { pal.text } else { pal.text_dim }, ); if cresp.clicked() { rc.shared.selection.clear(); @@ -162,14 +157,14 @@ pub fn render( let galley = ui.painter().layout_no_wrap( chip.label.to_string(), egui::FontId::proportional(11.0), - C_BRIGHT, + pal.text, ); let w = galley.size().x + 18.0; let chip_rect = egui::Rect::from_min_size(egui::pos2(cx, chip_y + 4.0), egui::vec2(w, CHIP_H - 8.0)); let resp = ui.interact(chip_rect, ui.id().with(("mobile_inspector_chip", i)), egui::Sense::click()); - ui.painter().rect_filled(chip_rect, 11.0, if resp.hovered() { C_LINE } else { C_SHEET }); - ui.painter().rect_stroke(chip_rect, 11.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside); - ui.painter().galley(chip_rect.center() - galley.size() * 0.5, galley, C_BRIGHT); + ui.painter().rect_filled(chip_rect, 11.0, if resp.hovered() { pal.line } else { pal.surface_alt }); + ui.painter().rect_stroke(chip_rect, 11.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); + ui.painter().galley(chip_rect.center() - galley.size() * 0.5, galley, pal.text); if resp.clicked() { let (top, count) = chip.window; state.window_top = top; diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs index 664a69c..911dd07 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/intent.rs @@ -7,16 +7,9 @@ use eframe::egui; -use super::icons; +use super::{icons, Palette}; use crate::EditorApp; -const C_DEVICE: egui::Color32 = egui::Color32::from_rgb(0x14, 0x16, 0x1b); -const C_CARD: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); -const C_CARD_HOT: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37); -const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); -const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); -const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1); - /// One intent card. struct Intent { label: &'static str, @@ -28,12 +21,8 @@ struct Intent { window: (usize, usize), } -fn intents() -> [Intent; 6] { - let coral = egui::Color32::from_rgb(0xe8, 0x82, 0x6b); - let cyan = egui::Color32::from_rgb(0x54, 0xc3, 0xe8); - let amber = egui::Color32::from_rgb(0xf4, 0xa3, 0x40); - let pink = egui::Color32::from_rgb(0xc7, 0x5b, 0x8a); - let violet = egui::Color32::from_rgb(0x6a, 0x46, 0x90); +fn intents(pal: &Palette) -> [Intent; 6] { + let [coral, cyan, amber, pink, violet] = pal.accents; [ // Stage indices (see super::STACK): Stage=2, Timeline=3, PianoRoll=4, VirtualPiano=5. Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1) }, @@ -41,13 +30,14 @@ fn intents() -> [Intent; 6] { Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 3) }, Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 3) }, Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2) }, - Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: C_DIM, focus: 0, window: (2, 2) }, + Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2) }, ] } pub fn render(app: &mut EditorApp, ctx: &egui::Context) { + let pal = Palette::from_theme(&app.theme, ctx); egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(C_DEVICE)) + .frame(egui::Frame::NONE.fill(pal.bg)) .show(ctx, |ui| { let rect = ui.available_rect_before_wrap(); let margin = 16.0; @@ -60,7 +50,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { egui::Align2::LEFT_TOP, "Start something", egui::FontId::proportional(22.0), - C_BRIGHT, + pal.text, ); // Vertical budget: ~2/3 for the intent grid, ~1/3 for the recent list. @@ -72,7 +62,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { // 2×3 grid of intent cards filling the grid budget. let col_w = (right - left - gap) / 2.0; let card_h = (grid_h - 2.0 * gap) / 3.0; - for (i, intent) in intents().iter().enumerate() { + for (i, intent) in intents(&pal).iter().enumerate() { let col = (i % 2) as f32; let row = (i / 2) as f32; let cx = left + col * (col_w + gap); @@ -81,8 +71,8 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { let resp = ui.interact(card, ui.id().with(("mobile_intent", i)), egui::Sense::click()); let p = ui.painter(); - p.rect_filled(card, 12.0, if resp.hovered() { C_CARD_HOT } else { C_CARD }); - p.rect_stroke(card, 12.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside); + p.rect_filled(card, 12.0, if resp.hovered() { pal.surface_alt } else { pal.surface }); + p.rect_stroke(card, 12.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); p.text( egui::pos2(card.center().x, card.top() + card.height() * 0.40), egui::Align2::CENTER_CENTER, @@ -95,7 +85,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { egui::Align2::CENTER_CENTER, intent.label, egui::FontId::proportional(15.0), - C_BRIGHT, + pal.text, ); if resp.clicked() { @@ -113,7 +103,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { egui::Align2::LEFT_TOP, "Recent", egui::FontId::proportional(13.0), - C_DIM, + pal.text_dim, ); let list_top = recent_top + 22.0; let recents = app.config.get_recent_files(); @@ -123,7 +113,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { egui::Align2::LEFT_TOP, "No recent projects", egui::FontId::proportional(12.0), - C_DIM, + pal.text_dim, ); } else { let row_h = 38.0; @@ -138,14 +128,14 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { let resp = ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click()); let p = ui.painter(); - p.rect_filled(row_rect, 8.0, if resp.hovered() { C_CARD_HOT } else { C_CARD }); - p.rect_stroke(row_rect, 8.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside); + p.rect_filled(row_rect, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface }); + p.rect_stroke(row_rect, 8.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); p.text( egui::pos2(row_rect.left() + 12.0, row_rect.center().y), egui::Align2::LEFT_CENTER, icons::FOLDER_OPEN, icons::font(15.0), - C_DIM, + pal.text_dim, ); let name = path .file_name() @@ -156,7 +146,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) { egui::Align2::LEFT_CENTER, &name, egui::FontId::proportional(13.0), - C_BRIGHT, + pal.text, ); if resp.clicked() { chosen = Some(path.clone()); diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 9c722ca..0baacd3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -21,11 +21,14 @@ pub mod icons; mod inspector; pub mod intent; mod omni; +mod palette; mod stack; mod surface; mod topbar; mod transport; +use palette::Palette; + /// Reserved sentinel namespace for mobile pane-instance paths. Desktop layout paths are built /// from small child indices, so prefixing with `usize::MAX` guarantees mobile slots never alias a /// real layout path in the shared `pane_instances` map. @@ -203,9 +206,10 @@ pub fn render_mobile_shell( rc: &mut RenderContext, state: &mut MobileState, ) { - // Background (wireframe device color; bands paint over most of it). - ui.painter() - .rect_filled(available_rect, 0.0, egui::Color32::from_rgb(0x14, 0x16, 0x1b)); + let pal = Palette::from_theme(rc.shared.theme, ui.ctx()); + + // Background (device color; bands paint over most of it). + ui.painter().rect_filled(available_rect, 0.0, pal.bg); let topbar_rect = egui::Rect::from_min_max( available_rect.min, @@ -243,19 +247,19 @@ pub fn render_mobile_shell( region }; - stack::render(ui, stack_rect, rc, state); + stack::render(ui, stack_rect, rc, state, &pal); if inspector_shown { let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max); - inspector::render(ui, sheet_rect, region.height(), rc, state); + inspector::render(ui, sheet_rect, region.height(), rc, state, &pal); } // Transport floor: drawn last = always on top, the persistent spine. - transport::render(ui, transport_rect, &mut rc.shared); + transport::render(ui, transport_rect, &mut rc.shared, &pal); // Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else. - omni::render(ui, region, rc, state); + omni::render(ui, region, rc, state, &pal); // Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last. - topbar::render(ui, topbar_rect, available_rect, rc, state); + topbar::render(ui, topbar_rect, available_rect, rc, state, &pal); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/omni.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/omni.rs index 3d80d29..7158071 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/omni.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/omni.rs @@ -9,18 +9,10 @@ use lightningbeam_core::layer::{AnyLayer, LayerType}; use lightningbeam_core::selection::FocusSelection; use lightningbeam_core::tool::Tool; -use super::{icons, MobileState}; +use super::{icons, MobileState, Palette}; use crate::menu::MenuAction; use crate::RenderContext; -const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40); -const C_DARK: egui::Color32 = egui::Color32::from_rgb(0x1b, 0x13, 0x0a); -const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); -const C_PANEL2: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37); -const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); -const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); -const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1); - const FAB_R: f32 = 26.0; const IR: f32 = 20.0; const PRIMARY: [Tool; 14] = [ @@ -106,7 +98,7 @@ fn close_all(state: &mut MobileState) { state.omni_create_open = false; } -pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) { +pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { let ctx_tools: Vec = Tool::for_layer_type(active_layer_type(rc)).to_vec(); let primary: Vec = PRIMARY .iter() @@ -140,7 +132,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta enabled: true, }) .collect(); - let (close, clicked) = draw_grid(ui, region, "Tools", &cells); + let (close, clicked) = draw_grid(ui, region, "Tools", &cells, pal); if let Some(i) = clicked { *rc.shared.selected_tool = ctx_tools[i]; close_all(state); @@ -159,7 +151,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta enabled: it.3, }) .collect(); - let (close, clicked) = draw_grid(ui, region, "New", &cells); + let (close, clicked) = draw_grid(ui, region, "New", &cells, pal); if let Some(i) = clicked { rc.shared.pending_menu_actions.push(items[i].2); close_all(state); @@ -169,8 +161,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta } else if state.omni_open { // Radial: scrim, then tool petals + special petals (more / new). let scrim = ui.interact(region, ui.id().with("mobile_omni_scrim"), egui::Sense::click()); - ui.painter() - .rect_filled(region, 0.0, egui::Color32::from_rgba_premultiplied(8, 10, 14, 140)); + ui.painter().rect_filled(region, 0.0, pal.scrim); if scrim.clicked() { state.omni_open = false; } @@ -194,7 +185,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta let t = primary[k]; let resp = ui.interact(rect, ui.id().with(("mobile_omni_tool", k)), egui::Sense::click()); let selected = *rc.shared.selected_tool == t; - petal(ui, c, if selected { C_AMBER } else { petal_bg(&resp) }, tool_icon(t), if selected { C_DARK } else { C_BRIGHT }); + petal(ui, c, if selected { pal.accent } else { petal_bg(&resp, pal) }, tool_icon(t), if selected { pal.on_accent } else { pal.text }, pal); if resp.clicked() { *rc.shared.selected_tool = t; state.omni_open = false; @@ -203,16 +194,16 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta match specials[k - primary.len()] { Special::More => { let resp = ui.interact(rect, ui.id().with("mobile_omni_more"), egui::Sense::click()); - petal(ui, c, petal_bg(&resp), icons::MENU, C_BRIGHT); + petal(ui, c, petal_bg(&resp, pal), icons::MENU, pal.text, pal); if resp.clicked() { state.omni_grid_open = true; } } Special::New => { let resp = ui.interact(rect, ui.id().with("mobile_omni_new"), egui::Sense::click()); - petal(ui, c, C_AMBER, icons::PLUS, C_DARK); + petal(ui, c, pal.accent, icons::PLUS, pal.on_accent, pal); if resp.hovered() { - ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.5, C_BRIGHT)); + ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.5, pal.text)); } if resp.clicked() { state.omni_create_open = true; @@ -226,7 +217,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta // The FAB itself (on top). Closed → current tool icon; open → ✕. let fab_rect = egui::Rect::from_center_size(fab_center, egui::vec2(FAB_R * 2.0, FAB_R * 2.0)); let fresp = ui.interact(fab_rect, ui.id().with("mobile_omni_fab"), egui::Sense::click()); - ui.painter().circle_filled(fab_center, FAB_R, C_AMBER); + ui.painter().circle_filled(fab_center, FAB_R, pal.accent); let open = state.omni_open || state.omni_grid_open || state.omni_create_open; let glyph = if open { icons::X @@ -236,7 +227,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta icons::PLUS // off-Stage: a create button }; ui.painter() - .text(fab_center, egui::Align2::CENTER_CENTER, glyph, icons::font(22.0), C_DARK); + .text(fab_center, egui::Align2::CENTER_CENTER, glyph, icons::font(22.0), pal.on_accent); if fresp.clicked() { if open { close_all(state); @@ -248,17 +239,17 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta } } -fn petal_bg(resp: &egui::Response) -> egui::Color32 { +fn petal_bg(resp: &egui::Response, pal: &Palette) -> egui::Color32 { if resp.hovered() { - C_LINE + pal.line } else { - C_PANEL2 + pal.surface_alt } } -fn petal(ui: &egui::Ui, c: egui::Pos2, bg: egui::Color32, glyph: &str, fg: egui::Color32) { +fn petal(ui: &egui::Ui, c: egui::Pos2, bg: egui::Color32, glyph: &str, fg: egui::Color32, pal: &Palette) { ui.painter().circle_filled(c, IR, bg); - ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.0, C_LINE)); + ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.0, pal.line)); ui.painter().text(c, egui::Align2::CENTER_CENTER, glyph, icons::font(18.0), fg); } @@ -270,20 +261,19 @@ struct Cell { } /// A modal grid sheet of icon+label cells. Returns (backdrop-tapped, Some(clicked-enabled-index)). -fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell]) -> (bool, Option) { +fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell], pal: &Palette) -> (bool, Option) { let scrim = ui.interact(region, ui.id().with(("mobile_omni_gridscrim", title)), egui::Sense::click()); - ui.painter() - .rect_filled(region, 0.0, egui::Color32::from_rgba_premultiplied(8, 10, 14, 170)); + ui.painter().rect_filled(region, 0.0, pal.scrim); let panel = region.shrink2(egui::vec2(16.0, 40.0)); - ui.painter().rect_filled(panel, 14.0, C_PANEL); - ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside); + ui.painter().rect_filled(panel, 14.0, pal.surface); + ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); ui.painter().text( egui::pos2(panel.left() + 16.0, panel.top() + 18.0), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(14.0), - C_BRIGHT, + pal.text, ); let cols = 4usize; @@ -310,20 +300,20 @@ fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell]) ui.interact(r, ui.id().with(("mobile_omni_cell_off", title, i)), egui::Sense::hover()) }; let bg = if cell.selected { - C_AMBER + pal.accent } else if cell.enabled && resp.hovered() { - C_PANEL2 + pal.surface_alt } else { - C_PANEL + pal.surface }; ui.painter().rect_filled(r, 10.0, bg); - ui.painter().rect_stroke(r, 10.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside); + ui.painter().rect_stroke(r, 10.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); let fg = if cell.selected { - C_DARK + pal.on_accent } else if cell.enabled { - C_BRIGHT + pal.text } else { - C_LINE + pal.line }; ui.painter().text( egui::pos2(r.center().x, r.top() + r.height() * 0.38), @@ -337,7 +327,7 @@ fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell]) egui::Align2::CENTER_CENTER, cell.label, egui::FontId::proportional(9.5), - if cell.selected { C_DARK } else if cell.enabled { C_DIM } else { C_LINE }, + if cell.selected { pal.on_accent } else if cell.enabled { pal.text_dim } else { pal.line }, ); if cell.enabled && resp.clicked() { clicked = Some(i); diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/palette.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/palette.rs new file mode 100644 index 0000000..cb479be --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/palette.rs @@ -0,0 +1,59 @@ +//! The mobile UI's semantic color palette, sourced from the shared theme CSS variables so it +//! matches the main UI and responds to light/dark/user themes. Built once per frame from the active +//! [`Theme`](crate::theme::Theme) and passed (by `Copy`) into the mobile render functions, replacing +//! the per-file hardcoded color constants. + +use eframe::egui::{Color32, Context}; + +use crate::theme::Theme; + +#[derive(Clone, Copy)] +pub struct Palette { + /// App/device background behind everything. + pub bg: Color32, + /// Panels, cards, bands, sheets. + pub surface: Color32, + /// Raised / hovered surface. + pub surface_alt: Color32, + /// Band headers, the top bar. + pub header: Color32, + /// Borders and dividers. + pub line: Color32, + /// Primary text / icons. + pub text: Color32, + /// Secondary (dim) text / icons. + pub text_dim: Color32, + /// Selection / active accent. + pub accent: Color32, + /// Text / icons drawn on top of `accent`. + pub on_accent: Color32, + /// Modal backdrop (translucent). + pub scrim: Color32, + /// Decorative category accents (intent picker, chips): coral, cyan, amber, pink, violet. + pub accents: [Color32; 5], +} + +impl Palette { + pub fn from_theme(theme: &Theme, ctx: &Context) -> Self { + let c = |name: &str, fb: Color32| theme.var(name, ctx).unwrap_or(fb); + Self { + bg: c("bg-app", Color32::from_rgb(0x2a, 0x2a, 0x2a)), + surface: c("bg-panel", Color32::from_rgb(0x22, 0x22, 0x22)), + surface_alt: c("bg-surface-raised", Color32::from_rgb(0x3f, 0x3f, 0x3f)), + header: c("bg-header", Color32::from_rgb(0x35, 0x35, 0x35)), + line: c("border-default", Color32::from_rgb(0x44, 0x44, 0x44)), + text: c("text-primary", Color32::from_rgb(0xf6, 0xf6, 0xf6)), + text_dim: c("text-secondary", Color32::from_rgb(0xaa, 0xaa, 0xaa)), + accent: c("accent", Color32::from_rgb(0x39, 0x6c, 0xd8)), + on_accent: c("text-on-accent", Color32::WHITE), + scrim: c("scrim", Color32::from_rgba_unmultiplied(0x10, 0x14, 0x1a, 0xb0)), + accents: [ + c("accent-coral", Color32::from_rgb(0xe8, 0x82, 0x6b)), + c("accent-cyan", Color32::from_rgb(0x54, 0xc3, 0xe8)), + c("accent-amber", Color32::from_rgb(0xf4, 0xa3, 0x40)), + c("accent-pink", Color32::from_rgb(0xc7, 0x5b, 0x8a)), + c("accent-violet", Color32::from_rgb(0x8a, 0x6e, 0xc0)), + ], + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs index 8debad8..5921527 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs @@ -18,7 +18,7 @@ use eframe::egui; -use super::{icons, slot_path, LayoutAnim, MobileState, StackDrag, StackPane, STACK}; +use super::{icons, slot_path, LayoutAnim, MobileState, Palette, StackDrag, StackPane, STACK}; use crate::RenderContext; const N: usize = STACK.len(); @@ -34,12 +34,6 @@ const BTN_W: f32 = 44.0; /// Max pixels of drag to complete a window transition (so you don't drag half the screen). const TRIGGER_MAX: f32 = 150.0; -const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); -const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40); -const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x7c, 0x86, 0x93); -const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); -const C_HEADER: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); - /// A draggable boundary of the stack. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Handle { @@ -345,7 +339,7 @@ fn interp_layout( /// The interaction (drag) target for a band's boundary is its full-width header bar. Bands are /// laid out in the area above a reserved bottom footer (the BottomEdge handle). Band 0's header is /// the TopEdge handle; band i's header (i≥1) is the divider above it; the footer is the BottomEdge. -pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) { +pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { let top = state.window_top; let count = state.window_count; @@ -437,24 +431,24 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state brect.left_top(), egui::pos2(brect.right(), brect.top() + HEADER_H.min(brect.height())), ); - draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen); + draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen, pal); } - draw_footer(ui, footer_rect, top + count >= N); + draw_footer(ui, footer_rect, top + count >= N, pal); // 3) Interactions on the resting header/footer rects (added last → they win the press). handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state); } -fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool) { +fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool, pal: &Palette) { let p = ui.painter(); // Rounded top corners so the header reads as a tab atop the pane; square at the bottom where // it meets the pane content. p.rect_filled( hr, egui::CornerRadius { nw: HEADER_RADIUS, ne: HEADER_RADIUS, sw: 0, se: 0 }, - C_HEADER, + pal.header, ); - p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, C_LINE)); + p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, pal.line)); let cy = hr.center().y; // Grip glyph on the left (Lucide). p.text( @@ -462,14 +456,14 @@ fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: b egui::Align2::CENTER_CENTER, icons::GRIP_HORIZONTAL, icons::font(16.0), - C_DIM, + pal.text_dim, ); p.text( egui::pos2(hr.left() + 32.0, cy), egui::Align2::LEFT_CENTER, sp.label(show_instruments), egui::FontId::proportional(15.0), - C_BRIGHT, + pal.text, ); // Right-side buttons: fullscreen / restore rightmost, Node/Instrument toggle just left of it. p.text( @@ -477,7 +471,7 @@ fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: b egui::Align2::CENTER_CENTER, if fullscreen { icons::MINIMIZE } else { icons::MAXIMIZE }, icons::font(17.0), - C_DIM, + pal.text_dim, ); if sp == StackPane::NodeInstrument { p.text( @@ -485,16 +479,16 @@ fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: b egui::Align2::CENTER_CENTER, icons::ARROW_LEFT_RIGHT, icons::font(17.0), - C_AMBER, + pal.accent, ); } } -fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool) { +fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool, pal: &Palette) { let p = ui.painter(); - p.rect_filled(fr, 0.0, C_HEADER); - p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, C_LINE)); - let col = if at_end { C_LINE } else { C_DIM }; + p.rect_filled(fr, 0.0, pal.header); + p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, pal.line)); + let col = if at_end { pal.line } else { pal.text_dim }; let cy = fr.center().y; let galley = p.layout_no_wrap( "pull up for more".to_string(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs index 9f4ac72..982382e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/topbar.rs @@ -5,15 +5,10 @@ use eframe::egui; -use super::{icons, MobileState}; +use super::{icons, MobileState, Palette}; use crate::menu::{MenuAction, MenuDef, MenuItemDef}; use crate::RenderContext; -const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); -const C_PANEL2: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37); -const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); -const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); -const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1); const BTN: f32 = 40.0; /// Curated overflow (⋯) commands. @@ -56,9 +51,10 @@ pub fn render( full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, + pal: &Palette, ) { - ui.painter().rect_filled(bar, 0.0, C_PANEL); - ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, C_LINE)); + ui.painter().rect_filled(bar, 0.0, pal.header); + ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, pal.line)); // Filename (or app name when unsaved). let name = rc @@ -73,7 +69,7 @@ pub fn render( egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(14.0), - C_BRIGHT, + pal.text, ); // Right cluster: ⌕ (palette) then ⋯ (overflow). @@ -82,7 +78,7 @@ pub fn render( let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click()); ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0), - if sresp.hovered() || state.palette_open { C_BRIGHT } else { C_DIM }); + if sresp.hovered() || state.palette_open { pal.text } else { pal.text_dim }); if sresp.clicked() { state.palette_open = !state.palette_open; state.overflow_open = false; @@ -91,48 +87,48 @@ pub fn render( let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click()); ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0), - if oresp.hovered() || state.overflow_open { C_BRIGHT } else { C_DIM }); + if oresp.hovered() || state.overflow_open { pal.text } else { pal.text_dim }); if oresp.clicked() { state.overflow_open = !state.overflow_open; state.palette_open = false; } if state.overflow_open { - render_overflow(ui, full, rc, state); + render_overflow(ui, full, rc, state, pal); } else if state.palette_open { - render_palette(ui, full, rc, state); + render_palette(ui, full, rc, state, pal); } } /// Common modal scrim + panel. Returns (backdrop-tapped, panel inner rect). -fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str) -> (bool, egui::Rect) { +fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str, pal: &Palette) -> (bool, egui::Rect) { let scrim = ui.interact(full, ui.id().with(("mobile_topbar_scrim", id)), egui::Sense::click()); - ui.painter().rect_filled(full, 0.0, egui::Color32::from_rgba_premultiplied(8, 10, 14, 170)); + ui.painter().rect_filled(full, 0.0, pal.scrim); let panel = egui::Rect::from_min_max( egui::pos2(full.left() + 16.0, full.top() + 44.0), egui::pos2(full.right() - 16.0, full.bottom() - 60.0), ); - ui.painter().rect_filled(panel, 14.0, C_PANEL); - ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside); + ui.painter().rect_filled(panel, 14.0, pal.surface); + ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside); (scrim.clicked(), panel) } -fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize)) -> bool { +fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize), pal: &Palette) -> bool { let resp = ui.interact(r, ui.id().with(("mobile_cmd", key.0, key.1)), egui::Sense::click()); - ui.painter().rect_filled(r, 8.0, if resp.hovered() { C_PANEL2 } else { C_PANEL }); - ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, C_LINE)); + ui.painter().rect_filled(r, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface }); + ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, pal.line)); ui.painter().text( egui::pos2(r.left() + 14.0, r.center().y), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(13.0), - C_BRIGHT, + pal.text, ); resp.clicked() } -fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) { - let (backdrop, panel) = open_panel(ui, full, "overflow"); +fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { + let (backdrop, panel) = open_panel(ui, full, "overflow", pal); let mut close = backdrop; let items = overflow_items(); let row_h = 44.0; @@ -145,7 +141,7 @@ fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, if r.bottom() > inner.bottom() { break; } - if command_button(ui, r, label, ("of", i)) { + if command_button(ui, r, label, ("of", i), pal) { rc.shared.pending_menu_actions.push(*action); close = true; } @@ -155,8 +151,8 @@ fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, } } -fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) { - let (backdrop, panel) = open_panel(ui, full, "palette"); +fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) { + let (backdrop, panel) = open_panel(ui, full, "palette", pal); let mut close = backdrop; let inner = panel.shrink(8.0); @@ -184,7 +180,7 @@ fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, s if r.bottom() > inner.bottom() { break; } - if command_button(ui, r, label, ("pal", i)) { + if command_button(ui, r, label, ("pal", i), pal) { rc.shared.pending_menu_actions.push(*action); close = true; } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs index 90b2a50..87f0e5a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/transport.rs @@ -3,19 +3,13 @@ use eframe::egui; -use super::icons; +use super::{icons, Palette}; use crate::panes::SharedPaneState; -const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c); -const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); -const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40); -const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3); -const C_DARK: egui::Color32 = egui::Color32::from_rgb(0x1b, 0x13, 0x0a); - -pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { +pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState, pal: &Palette) { let painter = ui.painter_at(rect); - painter.rect_filled(rect, 0.0, C_PANEL); - painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, C_LINE)); + painter.rect_filled(rect, 0.0, pal.surface); + painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line)); let cy = rect.center().y; @@ -24,14 +18,14 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) let btn_center = egui::pos2(rect.left() + 20.0 + btn_r, cy); let btn_rect = egui::Rect::from_center_size(btn_center, egui::vec2(btn_r * 2.0, btn_r * 2.0)); let btn_resp = ui.interact(btn_rect, ui.id().with("mobile_transport_play"), egui::Sense::click()); - painter.circle_filled(btn_center, btn_r, C_AMBER); + painter.circle_filled(btn_center, btn_r, pal.accent); let glyph = if *shared.is_playing { icons::PAUSE } else { icons::PLAY }; painter.text( btn_center, egui::Align2::CENTER_CENTER, glyph, icons::font(16.0), - C_DARK, + pal.on_accent, ); if btn_resp.clicked() { *shared.is_playing = !*shared.is_playing; @@ -55,7 +49,7 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) egui::Align2::LEFT_CENTER, &tc, egui::FontId::monospace(13.0), - C_BRIGHT, + pal.text, ); let tc_width = 78.0; @@ -71,18 +65,18 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) ui.id().with("mobile_transport_scrub"), egui::Sense::click_and_drag(), ); - painter.rect_filled(scrub_rect, 3.0, C_LINE); + painter.rect_filled(scrub_rect, 3.0, pal.line); let frac = (*shared.playback_time / duration).clamp(0.0, 1.0) as f32; let filled = egui::Rect::from_min_max( scrub_rect.min, egui::pos2(scrub_rect.left() + scrub_rect.width() * frac, scrub_rect.bottom()), ); - painter.rect_filled(filled, 3.0, C_AMBER.gamma_multiply(0.5)); + painter.rect_filled(filled, 3.0, pal.accent.gamma_multiply(0.5)); let head_x = scrub_rect.left() + scrub_rect.width() * frac; painter.vline( head_x, (scrub_rect.top() - 4.0)..=(scrub_rect.bottom() + 4.0), - egui::Stroke::new(2.0, C_AMBER), + egui::Stroke::new(2.0, pal.accent), ); if (scrub_resp.dragged() || scrub_resp.clicked()) && scrub_rect.width() > 0.0 { diff --git a/lightningbeam-ui/lightningbeam-editor/src/theme.rs b/lightningbeam-ui/lightningbeam-editor/src/theme.rs index 1b6f847..6624b7d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/theme.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/theme.rs @@ -519,6 +519,73 @@ impl Theme { self.resolve(context, ctx).border_color.unwrap_or(fallback) } + /// Look up a CSS custom property (variable) as a color, respecting the active light/dark mode. + /// `name` is given without the leading `--` (e.g. `"bg-surface"`). Dark overrides light. + pub fn var(&self, name: &str, ctx: &egui::Context) -> Option { + let mut vars = self.light_variables.clone(); + if self.is_dark(ctx) { + vars.extend(self.dark_variables.clone()); + } + let raw = vars.get(name)?.clone(); + parse_color_value(&raw, &vars) + } + + /// Apply the theme's core palette variables to egui's global `Visuals`, so the standard egui + /// widgets (buttons, text fields, dialogs, pane chrome) share the same colors as the mobile UI. + /// Cheap enough to call every frame; respects the active light/dark mode. + pub fn apply_to_egui(&self, ctx: &egui::Context) { + let is_dark = self.is_dark(ctx); + let v = |name: &str, fb: egui::Color32| self.var(name, ctx).unwrap_or(fb); + let g = |n: u8| egui::Color32::from_gray(n); + + let text = v("text-primary", if is_dark { g(230) } else { g(20) }); + let bg_app = v("bg-app", if is_dark { g(42) } else { g(224) }); + let panel = v("bg-panel", bg_app); + let surface = v("bg-surface", panel); + let raised = v("bg-surface-raised", surface); + let sunken = v("bg-surface-sunken", bg_app); + let border = v("border-default", if is_dark { g(68) } else { g(153) }); + let accent = v("accent", egui::Color32::from_rgb(0x39, 0x6c, 0xd8)); + let on_accent = v("text-on-accent", egui::Color32::WHITE); + let stroke = |c: egui::Color32| egui::Stroke::new(1.0, c); + + let mut visuals = if is_dark { egui::Visuals::dark() } else { egui::Visuals::light() }; + visuals.panel_fill = panel; + visuals.window_fill = panel; + visuals.window_stroke = stroke(border); + visuals.extreme_bg_color = sunken; + visuals.faint_bg_color = surface; + visuals.override_text_color = Some(text); + visuals.hyperlink_color = accent; + + let w = &mut visuals.widgets; + w.noninteractive.bg_fill = panel; + w.noninteractive.weak_bg_fill = panel; + w.noninteractive.bg_stroke = stroke(border); + w.noninteractive.fg_stroke = stroke(text); + w.inactive.bg_fill = surface; + w.inactive.weak_bg_fill = surface; + w.inactive.bg_stroke = stroke(border); + w.inactive.fg_stroke = stroke(text); + w.hovered.bg_fill = raised; + w.hovered.weak_bg_fill = raised; + w.hovered.bg_stroke = stroke(border); + w.hovered.fg_stroke = stroke(text); + w.active.bg_fill = accent; + w.active.weak_bg_fill = accent; + w.active.bg_stroke = stroke(accent); + w.active.fg_stroke = stroke(on_accent); + w.open.bg_fill = surface; + w.open.weak_bg_fill = surface; + w.open.bg_stroke = stroke(border); + w.open.fg_stroke = stroke(text); + + visuals.selection.bg_fill = accent.linear_multiply(0.4); + visuals.selection.stroke = stroke(on_accent); + + ctx.set_visuals(visuals); + } + /// Convenience: resolve and extract a dimension with fallback #[allow(dead_code)] pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 {