// Copyright 2026 The Goblin Developers // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Goblin design tokens: three themes (light/dark/yellow) and density scales, //! taken verbatim from the Goblin design handoff. use std::cell::Cell; use egui::Color32; use crate::AppConfig; /// Available color themes. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ThemeKind { Light, Dark, Yellow, } impl ThemeKind { pub fn id(&self) -> &'static str { match self { ThemeKind::Light => "light", ThemeKind::Dark => "dark", ThemeKind::Yellow => "yellow", } } pub fn from_id(id: &str) -> Option { match id { "light" => Some(ThemeKind::Light), "dark" => Some(ThemeKind::Dark), "yellow" => Some(ThemeKind::Yellow), _ => None, } } } /// Color tokens for a theme. pub struct ThemeTokens { pub bg: Color32, pub surface: Color32, pub surface2: Color32, pub text: Color32, pub text_dim: Color32, pub text_mute: Color32, /// Text on surface/surface2 fills. Matches `text` in light/dark, but the /// yellow theme has dark surfaces on a bright bg, so on-surface text must /// be light there while `text` stays dark for the bg. pub surface_text: Color32, pub surface_text_dim: Color32, pub surface_text_mute: Color32, pub line: Color32, pub accent: Color32, pub accent_dark: Color32, pub accent_ink: Color32, pub pos: Color32, pub neg: Color32, pub chip: Color32, pub hover: Color32, /// Avatar background palette (initial ink picked by luminance). pub avatar_pairs: [(Color32, Color32); 8], /// Whether egui widgets should use the dark base style. pub dark_base: bool, } /// Avatar (background, ink) pairs shared by all themes — bright pastels /// carry dark ink, saturated darks carry light ink. const AVATAR_PAIRS: [(Color32, Color32); 8] = [ ( Color32::from_rgb(0xFF, 0xD6, 0x0A), Color32::from_rgb(0x0E, 0x0E, 0x0C), ), // accent yellow / ink ( Color32::from_rgb(0xFF, 0x8E, 0x3C), Color32::from_rgb(0x26, 0x10, 0x02), ), // orange / deep brown ( Color32::from_rgb(0x5B, 0xD2, 0x7A), Color32::from_rgb(0x0E, 0x0E, 0x0C), ), // light green / black ( Color32::from_rgb(0x7B, 0xA7, 0xFF), Color32::from_rgb(0x0B, 0x14, 0x33), ), // periwinkle / navy ink ( Color32::from_rgb(0x6B, 0x4F, 0xC8), Color32::from_rgb(0xF4, 0xF0, 0xFF), ), // purple / light text ( Color32::from_rgb(0xE1, 0x74, 0xD0), Color32::from_rgb(0x32, 0x07, 0x2B), ), // pink / dark plum ( Color32::from_rgb(0x1F, 0x7A, 0x5C), Color32::from_rgb(0xE7, 0xFF, 0xF4), ), // deep teal / light mint ( Color32::from_rgb(0xA0, 0xE6, 0x6E), Color32::from_rgb(0x14, 0x22, 0x0A), ), // lime / dark moss ]; pub const LIGHT: ThemeTokens = ThemeTokens { bg: Color32::from_rgb(0xFA, 0xFA, 0xF7), surface: Color32::from_rgb(0xFF, 0xFF, 0xFF), surface2: Color32::from_rgb(0xF2, 0xF1, 0xEC), text: Color32::from_rgb(0x0E, 0x0E, 0x0C), text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), surface_text: Color32::from_rgb(0x0E, 0x0E, 0x0C), surface_text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), surface_text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), // rgba(14,14,12,0.08) premultiplied. line: Color32::from_rgba_premultiplied(1, 1, 1, 20), accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00), accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C), pos: Color32::from_rgb(0x0E, 0x7C, 0x3A), neg: Color32::from_rgb(0xB0, 0x48, 0x1E), chip: Color32::from_rgb(0xF2, 0xF1, 0xEC), hover: Color32::from_rgb(0xE9, 0xE7, 0xE0), avatar_pairs: AVATAR_PAIRS, dark_base: false, }; pub const DARK: ThemeTokens = ThemeTokens { bg: Color32::from_rgb(0x0E, 0x0E, 0x0C), surface: Color32::from_rgb(0x1A, 0x1A, 0x17), surface2: Color32::from_rgb(0x24, 0x24, 0x20), text: Color32::from_rgb(0xFA, 0xFA, 0xF7), text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), // rgba(255,255,255,0.08) premultiplied. line: Color32::from_rgba_premultiplied(20, 20, 20, 20), accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), accent_dark: Color32::from_rgb(0xEF, 0xC8, 0x00), accent_ink: Color32::from_rgb(0x0E, 0x0E, 0x0C), pos: Color32::from_rgb(0x5B, 0xD2, 0x7A), neg: Color32::from_rgb(0xFF, 0x8B, 0x5E), chip: Color32::from_rgb(0x24, 0x24, 0x20), hover: Color32::from_rgb(0x2E, 0x2E, 0x29), avatar_pairs: AVATAR_PAIRS, dark_base: true, }; pub const YELLOW: ThemeTokens = ThemeTokens { bg: Color32::from_rgb(0xFF, 0xD6, 0x0A), surface: Color32::from_rgb(0x0E, 0x0E, 0x0C), surface2: Color32::from_rgb(0x1A, 0x1A, 0x17), text: Color32::from_rgb(0x0E, 0x0E, 0x0C), text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36), // Muted on-bg tier darkened for the bright yellow bg: #6B6A63 was only // 3.85:1 (sub-WCAG-AA); #55534A is 5.5:1 and still the faintest tier. text_mute: Color32::from_rgb(0x55, 0x53, 0x4A), surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), // rgba(14,14,12,0.18) premultiplied. line: Color32::from_rgba_premultiplied(2, 2, 2, 46), accent: Color32::from_rgb(0x0E, 0x0E, 0x0C), accent_dark: Color32::from_rgb(0x24, 0x24, 0x20), accent_ink: Color32::from_rgb(0xFF, 0xD6, 0x0A), pos: Color32::from_rgb(0x0E, 0x7C, 0x3A), neg: Color32::from_rgb(0x9E, 0x2E, 0x0E), chip: Color32::from_rgba_premultiplied(2, 2, 2, 20), hover: Color32::from_rgb(0xEF, 0xC8, 0x00), avatar_pairs: AVATAR_PAIRS, dark_base: false, }; thread_local! { /// Per-frame theme override (see [`scoped`]). egui renders on one thread, so /// a thread-local Cell scopes a different theme to a single surface without /// touching the persisted app config. static OVERRIDE: Cell> = const { Cell::new(None) }; } /// RAII guard that forces [`kind`]/[`tokens`] to a specific theme for its /// lifetime, restoring the previous value on drop (panic-safe). Used to paint /// one surface — the Pay tab — in the yellow theme regardless of the user's /// chosen theme, à la a modern pay app's brand-colored pay screen. #[must_use = "the override only lasts while the guard is alive"] pub struct ScopedTheme(Option); impl Drop for ScopedTheme { fn drop(&mut self) { OVERRIDE.with(|c| c.set(self.0.take())); } } /// Override the active theme until the returned guard drops. pub fn scoped(kind: ThemeKind) -> ScopedTheme { ScopedTheme(OVERRIDE.with(|c| c.replace(Some(kind)))) } /// Current theme kind: a scoped override if one is active, else app config /// (dark is the product default). pub fn kind() -> ThemeKind { OVERRIDE.with(|c| c.get()).unwrap_or_else(AppConfig::theme) } /// Current theme tokens. pub fn tokens() -> &'static ThemeTokens { match kind() { ThemeKind::Light => &LIGHT, ThemeKind::Dark => &DARK, ThemeKind::Yellow => &YELLOW, } } /// Set each frame by the Pay surface (which paints a bright yellow top under a /// possibly-dark global theme), so the status bar can pick readable icons for it. static YELLOW_SURFACE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Flag whether the bright Pay/yellow surface is currently on screen. pub fn set_status_surface_yellow(yellow: bool) { YELLOW_SURFACE.store(yellow, std::sync::atomic::Ordering::Relaxed); } /// Whether the status bar should use light (white) icons: true on the dark /// theme (dark top), false on the light/yellow themes (bright top). The bright /// Pay surface forces dark icons even when the global theme is dark. pub fn status_bar_white_icons() -> bool { if YELLOW_SURFACE.load(std::sync::atomic::Ordering::Relaxed) { return false; } tokens().dark_base } /// Density scales from the design handoff. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum DensityKind { Compact, Regular, Comfy, } impl DensityKind { pub fn id(&self) -> &'static str { match self { DensityKind::Compact => "compact", DensityKind::Regular => "regular", DensityKind::Comfy => "comfy", } } pub fn from_id(id: &str) -> Option { match id { "compact" => Some(DensityKind::Compact), "regular" => Some(DensityKind::Regular), "comfy" => Some(DensityKind::Comfy), _ => None, } } } /// Spacing tokens for a density. #[derive(Clone, Copy)] pub struct DensityTokens { pub pad: f32, pub gap: f32, pub radius: f32, pub row: f32, } pub const COMPACT: DensityTokens = DensityTokens { pad: 12.0, gap: 10.0, radius: 10.0, row: 56.0, }; pub const REGULAR: DensityTokens = DensityTokens { pad: 16.0, gap: 14.0, radius: 16.0, row: 64.0, }; pub const COMFY: DensityTokens = DensityTokens { pad: 20.0, gap: 18.0, radius: 22.0, row: 72.0, }; /// Current density tokens from app config (comfy is the product default). pub fn density() -> DensityTokens { match AppConfig::density() { DensityKind::Compact => COMPACT, DensityKind::Regular => REGULAR, DensityKind::Comfy => COMFY, } } /// Font family helpers for the Geist weight stack registered in `setup_fonts`. pub mod fonts { use egui::{FontFamily, FontId}; pub fn regular() -> FontFamily { FontFamily::Proportional } pub fn medium() -> FontFamily { FontFamily::Name("geist-medium".into()) } pub fn semibold() -> FontFamily { FontFamily::Name("geist-semibold".into()) } pub fn bold() -> FontFamily { FontFamily::Name("geist-bold".into()) } pub fn mono() -> FontFamily { FontFamily::Monospace } pub fn mono_semibold() -> FontFamily { FontFamily::Name("geist-mono-sb".into()) } /// Uppercase kicker label size (11px in the design). pub fn kicker() -> FontId { FontId::new(11.0, semibold()) } } /// Pick a readable ink (black or white) for the given background by luminance. pub fn ink_for(bg: Color32) -> Color32 { let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32; if lum > 140.0 { Color32::from_rgb(0x0E, 0x0E, 0x0C) } else { Color32::from_rgb(0xFA, 0xFA, 0xF7) } } /// Number of avatar color pairs (hue derivation modulus). pub fn avatar_pairs_len() -> usize { tokens().avatar_pairs.len() }