1
0
forked from GRIN/grim
Files
goblin/src/gui/theme.rs
T
2ro c701f0f480 Floonet scoped Nym exit + NIP-44 v3 + wallet polish
Money path:
- Scoped, unbonded Nym exit for the money-path relay: the wallet dials a
  relay operator's co-located exit over a MixnetStream (src/nym/streamexit.rs)
  which pipes to its one relay; hostname-validated TLS end to end, no public
  DNS. Anchor + fallback (never pin-only): any exit failure degrades to the
  smolmix tunnel. relay.goblin.st's exit address is pinned in the relay pool
  (src/nostr/pool.rs) and the maintainer gist so it bootstraps offline.
- STREAM_SETTLE bridges the open-before-accept gap so the first TLS byte is
  not dropped into a stalled handshake.
- Verified end to end: two wallets complete a real gift-wrapped Grin payment
  through relay.goblin.st over the exit, finalized + posted on mainnet
  (src/wallet/e2e.rs, ignored live test).

Encryption:
- Adopt NIP-44 v3 for the NIP-17 gift-wrap path (G4): src/nostr/wrapv3.rs,
  nip44 path dep; v3<->v3 and v3->v2 interop.

Also: mix-DNS (src/nym/dns.rs), full localization pass, GUI polish,
avatar-ring example, Android icon/script updates, GRIM deviation notes,
xrelay + connect-timing tests.
2026-07-02 04:17:59 -04:00

358 lines
10 KiB
Rust

// 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<ThemeKind> {
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<Option<ThemeKind>> = 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<ThemeKind>);
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<DensityKind> {
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()
}