c701f0f480
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.
358 lines
10 KiB
Rust
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()
|
|
}
|