From e508a55933f2517678b5d906d1d4580312ddb621 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:13:05 +0200 Subject: [PATCH 1/2] Add theme-specific branding logos --- .env.example | 2 + .../atlas-server/src/api/handlers/config.rs | 14 ++++ .../atlas-server/src/api/handlers/faucet.rs | 2 + .../atlas-server/src/api/handlers/status.rs | 2 + backend/crates/atlas-server/src/api/mod.rs | 4 ++ backend/crates/atlas-server/src/cli.rs | 16 +++++ backend/crates/atlas-server/src/config.rs | 63 ++++++++++++++++- backend/crates/atlas-server/src/main.rs | 2 + .../atlas-server/tests/integration/common.rs | 2 + frontend/src/api/config.ts | 2 + frontend/src/context/BrandingContext.tsx | 35 ++++++---- frontend/src/context/branding.test.ts | 68 +++++++++++++++++++ frontend/src/context/branding.ts | 25 +++++++ 13 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 frontend/src/context/branding.test.ts create mode 100644 frontend/src/context/branding.ts diff --git a/.env.example b/.env.example index d554b30..ae4dd38 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,8 @@ ENABLE_DA_TRACKING=false # Branding / white-label (all optional) # CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.svg). Default: bundled logo +# CHAIN_LOGO_URL_LIGHT= # URL or path to logo used in light theme +# CHAIN_LOGO_URL_DARK= # URL or path to logo used in dark theme # ACCENT_COLOR= # Primary accent hex (e.g. #3b82f6). Default: #dc2626 (red) # BACKGROUND_COLOR_DARK= # Dark mode base background hex. Default: #050505 # BACKGROUND_COLOR_LIGHT= # Light mode base background hex. Default: #f4ede6 diff --git a/backend/crates/atlas-server/src/api/handlers/config.rs b/backend/crates/atlas-server/src/api/handlers/config.rs index eba39b9..13792ed 100644 --- a/backend/crates/atlas-server/src/api/handlers/config.rs +++ b/backend/crates/atlas-server/src/api/handlers/config.rs @@ -10,6 +10,10 @@ pub struct BrandingConfig { #[serde(skip_serializing_if = "Option::is_none")] pub logo_url: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub logo_url_light: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_url_dark: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub accent_color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub background_color_dark: Option, @@ -27,6 +31,8 @@ pub async fn get_config(State(state): State>) -> Json, + pub chain_logo_url_light: Option, + pub chain_logo_url_dark: Option, pub accent_color: Option, pub background_color_dark: Option, pub background_color_light: Option, @@ -273,6 +275,8 @@ mod tests { chain_id: 1, chain_name: "Test Chain".to_string(), chain_logo_url: None, + chain_logo_url_light: None, + chain_logo_url_dark: None, accent_color: None, background_color_dark: None, background_color_light: None, diff --git a/backend/crates/atlas-server/src/cli.rs b/backend/crates/atlas-server/src/cli.rs index 24bdf52..5dbdc83 100644 --- a/backend/crates/atlas-server/src/cli.rs +++ b/backend/crates/atlas-server/src/cli.rs @@ -243,6 +243,22 @@ pub struct ChainArgs { help = "URL to the chain logo image" )] pub logo_url: Option, + + #[arg( + long = "atlas.chain.logo-url-light", + env = "CHAIN_LOGO_URL_LIGHT", + value_name = "URL", + help = "URL to the chain logo image used in light theme" + )] + pub logo_url_light: Option, + + #[arg( + long = "atlas.chain.logo-url-dark", + env = "CHAIN_LOGO_URL_DARK", + value_name = "URL", + help = "URL to the chain logo image used in dark theme" + )] + pub logo_url_dark: Option, } #[derive(Args, Clone)] diff --git a/backend/crates/atlas-server/src/config.rs b/backend/crates/atlas-server/src/config.rs index 1114654..bacb9f9 100644 --- a/backend/crates/atlas-server/src/config.rs +++ b/backend/crates/atlas-server/src/config.rs @@ -48,6 +48,8 @@ pub struct Config { // Branding / white-label pub chain_logo_url: Option, + pub chain_logo_url_light: Option, + pub chain_logo_url_dark: Option, pub accent_color: Option, pub background_color_dark: Option, pub background_color_light: Option, @@ -198,6 +200,8 @@ impl Config { .filter(|s| !s.is_empty()) .unwrap_or_else(|| "Unknown".to_string()), chain_logo_url: parse_optional_env(env::var("CHAIN_LOGO_URL").ok()), + chain_logo_url_light: parse_optional_env(env::var("CHAIN_LOGO_URL_LIGHT").ok()), + chain_logo_url_dark: parse_optional_env(env::var("CHAIN_LOGO_URL_DARK").ok()), accent_color: parse_optional_env(env::var("ACCENT_COLOR").ok()), background_color_dark: parse_optional_env(env::var("BACKGROUND_COLOR_DARK").ok()), background_color_light: parse_optional_env(env::var("BACKGROUND_COLOR_LIGHT").ok()), @@ -324,6 +328,8 @@ impl Config { sse_replay_buffer_blocks, chain_name, chain_logo_url: parse_optional_env(args.chain.logo_url), + chain_logo_url_light: parse_optional_env(args.chain.logo_url_light), + chain_logo_url_dark: parse_optional_env(args.chain.logo_url_dark), accent_color: parse_optional_env(args.branding.accent_color), background_color_dark: parse_optional_env(args.branding.background_dark), background_color_light: parse_optional_env(args.branding.background_light), @@ -461,6 +467,8 @@ mod tests_from_run_args { chain: cli::ChainArgs { name: "TestChain".to_string(), logo_url: None, + logo_url_light: None, + logo_url_dark: None, }, da: cli::DaArgs { enabled: false, @@ -572,12 +580,30 @@ mod tests_from_run_args { #[test] fn branding_blank_strings_become_none() { let mut args = minimal_run_args(); - args.branding.accent_color = Some(" ".to_string()); + args.chain.logo_url_light = Some(" ".to_string()); args.branding.success_color = Some("#00ff00".to_string()); let config = Config::from_run_args(args).unwrap(); - assert!(config.accent_color.is_none()); + assert!(config.chain_logo_url_light.is_none()); assert_eq!(config.success_color.as_deref(), Some("#00ff00")); } + + #[test] + fn theme_specific_logo_urls_are_trimmed() { + let mut args = minimal_run_args(); + args.chain.logo_url_light = Some(" /branding/light.svg ".to_string()); + args.chain.logo_url_dark = Some(" /branding/dark.svg ".to_string()); + + let config = Config::from_run_args(args).unwrap(); + + assert_eq!( + config.chain_logo_url_light.as_deref(), + Some("/branding/light.svg") + ); + assert_eq!( + config.chain_logo_url_dark.as_deref(), + Some("/branding/dark.svg") + ); + } } #[cfg(test)] @@ -606,6 +632,12 @@ mod tests { env::remove_var("FAUCET_COOLDOWN_MINUTES"); } + fn clear_branding_env() { + env::remove_var("CHAIN_LOGO_URL"); + env::remove_var("CHAIN_LOGO_URL_LIGHT"); + env::remove_var("CHAIN_LOGO_URL_DARK"); + } + fn set_valid_faucet_env() { env::set_var("FAUCET_ENABLED", "true"); env::set_var( @@ -620,6 +652,7 @@ mod tests { fn chain_name_defaults_to_unknown_when_unset() { let _lock = ENV_LOCK.lock().unwrap(); set_required_env(); + clear_branding_env(); env::remove_var("CHAIN_NAME"); assert_eq!(Config::from_env().unwrap().chain_name, "Unknown"); } @@ -628,6 +661,7 @@ mod tests { fn chain_name_defaults_to_unknown_when_empty() { let _lock = ENV_LOCK.lock().unwrap(); set_required_env(); + clear_branding_env(); env::set_var("CHAIN_NAME", ""); assert_eq!(Config::from_env().unwrap().chain_name, "Unknown"); env::remove_var("CHAIN_NAME"); @@ -637,6 +671,7 @@ mod tests { fn chain_name_defaults_to_unknown_when_whitespace_only() { let _lock = ENV_LOCK.lock().unwrap(); set_required_env(); + clear_branding_env(); env::set_var("CHAIN_NAME", " "); assert_eq!(Config::from_env().unwrap().chain_name, "Unknown"); env::remove_var("CHAIN_NAME"); @@ -646,6 +681,7 @@ mod tests { fn chain_name_uses_provided_value() { let _lock = ENV_LOCK.lock().unwrap(); set_required_env(); + clear_branding_env(); env::set_var("CHAIN_NAME", "MyChain"); assert_eq!(Config::from_env().unwrap().chain_name, "MyChain"); env::remove_var("CHAIN_NAME"); @@ -655,11 +691,34 @@ mod tests { fn chain_name_trims_surrounding_whitespace() { let _lock = ENV_LOCK.lock().unwrap(); set_required_env(); + clear_branding_env(); env::set_var("CHAIN_NAME", " MyChain "); assert_eq!(Config::from_env().unwrap().chain_name, "MyChain"); env::remove_var("CHAIN_NAME"); } + #[test] + fn theme_specific_logo_urls_are_read_from_env() { + let _lock = ENV_LOCK.lock().unwrap(); + set_required_env(); + clear_branding_env(); + env::set_var("CHAIN_LOGO_URL_LIGHT", " /branding/light.svg "); + env::set_var("CHAIN_LOGO_URL_DARK", " /branding/dark.svg "); + + let config = Config::from_env().unwrap(); + + assert_eq!( + config.chain_logo_url_light.as_deref(), + Some("/branding/light.svg") + ); + assert_eq!( + config.chain_logo_url_dark.as_deref(), + Some("/branding/dark.svg") + ); + + clear_branding_env(); + } + #[test] fn optional_env_returns_none_when_unset() { assert_eq!(parse_optional_env(None), None); diff --git a/backend/crates/atlas-server/src/main.rs b/backend/crates/atlas-server/src/main.rs index 14d932c..1623ec3 100644 --- a/backend/crates/atlas-server/src/main.rs +++ b/backend/crates/atlas-server/src/main.rs @@ -280,6 +280,8 @@ async fn run(args: cli::RunArgs) -> Result<()> { chain_id, chain_name: config.chain_name.clone(), chain_logo_url: config.chain_logo_url.clone(), + chain_logo_url_light: config.chain_logo_url_light.clone(), + chain_logo_url_dark: config.chain_logo_url_dark.clone(), accent_color: config.accent_color.clone(), background_color_dark: config.background_color_dark.clone(), background_color_light: config.background_color_light.clone(), diff --git a/backend/crates/atlas-server/tests/integration/common.rs b/backend/crates/atlas-server/tests/integration/common.rs index da128f6..e895452 100644 --- a/backend/crates/atlas-server/tests/integration/common.rs +++ b/backend/crates/atlas-server/tests/integration/common.rs @@ -73,6 +73,8 @@ pub fn test_router() -> Router { chain_id: 42, chain_name: "Test Chain".to_string(), chain_logo_url: None, + chain_logo_url_light: None, + chain_logo_url_dark: None, accent_color: None, background_color_dark: None, background_color_light: None, diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 8e85768..e3f43e5 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -3,6 +3,8 @@ import client from './client'; export interface BrandingConfig { chain_name: string; logo_url?: string; + logo_url_light?: string; + logo_url_dark?: string; accent_color?: string; background_color_dark?: string; background_color_light?: string; diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index e677a72..3b3b976 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -3,6 +3,8 @@ import { getConfig, type BrandingConfig } from '../api/config'; import { deriveSurfaceShades, applyPalette, hexToRgbTriplet } from '../utils/color'; import { ThemeContext } from './theme-context'; import { BrandingContext, brandingDefaults } from './branding-context'; +import { resolveBrandingValue, resolveLogoUrl } from './branding'; +import defaultLogoImg from '../assets/logo.png'; const CACHE_KEY = 'branding_config'; @@ -15,33 +17,32 @@ function readCache(): BrandingConfig | null { } } -function applyConfigState(cfg: BrandingConfig, setBranding: (v: typeof brandingDefaults) => void, setConfig: (v: BrandingConfig) => void) { - setConfig(cfg); - setBranding({ chainName: cfg.chain_name, logoUrl: cfg.logo_url || null, loaded: true }); - document.title = `${cfg.chain_name} - Block Explorer`; - if (cfg.logo_url) { - const link = document.querySelector("link[rel='icon']"); - if (link) link.href = cfg.logo_url; +function setFavicon(href: string) { + let link = document.querySelector("link[rel='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + link.type = 'image/png'; + document.head.appendChild(link); } + link.href = href; } export function BrandingProvider({ children }: { children: ReactNode }) { + const themeCtx = useContext(ThemeContext); + const theme = themeCtx?.theme ?? 'dark'; const [branding, setBranding] = useState(() => { const cached = readCache(); - return cached - ? { chainName: cached.chain_name, logoUrl: cached.logo_url || null, loaded: true } - : brandingDefaults; + return cached ? resolveBrandingValue(cached, theme) : brandingDefaults; }); const [config, setConfig] = useState(readCache); - const themeCtx = useContext(ThemeContext); - const theme = themeCtx?.theme ?? 'dark'; // Apply cached config immediately on mount (no flash), then revalidate in background useEffect(() => { getConfig() .then((cfg) => { localStorage.setItem(CACHE_KEY, JSON.stringify(cfg)); - applyConfigState(cfg, setBranding, setConfig); + setConfig(cfg); }) .catch(() => { if (!config) setBranding({ ...brandingDefaults, loaded: true }); @@ -49,6 +50,14 @@ export function BrandingProvider({ children }: { children: ReactNode }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!config) return; + + setBranding(resolveBrandingValue(config, theme)); + document.title = `${config.chain_name} - Block Explorer`; + setFavicon(resolveLogoUrl(config, theme) ?? defaultLogoImg); + }, [config, theme]); + // Apply accent + semantic colors (theme-independent) useEffect(() => { if (!config) return; diff --git a/frontend/src/context/branding.test.ts b/frontend/src/context/branding.test.ts new file mode 100644 index 0000000..f289d33 --- /dev/null +++ b/frontend/src/context/branding.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveLogoUrl } from './branding'; + +describe('resolveLogoUrl', () => { + test('uses theme-specific logos when both are present', () => { + const config = { + logo_url_light: '/branding/light.svg', + logo_url_dark: '/branding/dark.svg', + }; + + expect(resolveLogoUrl(config, 'light')).toBe('/branding/light.svg'); + expect(resolveLogoUrl(config, 'dark')).toBe('/branding/dark.svg'); + }); + + test('reuses the light logo when dark is missing', () => { + const config = { + logo_url_light: '/branding/light.svg', + }; + + expect(resolveLogoUrl(config, 'light')).toBe('/branding/light.svg'); + expect(resolveLogoUrl(config, 'dark')).toBe('/branding/light.svg'); + }); + + test('reuses the dark logo when light is missing', () => { + const config = { + logo_url_dark: '/branding/dark.svg', + }; + + expect(resolveLogoUrl(config, 'light')).toBe('/branding/dark.svg'); + expect(resolveLogoUrl(config, 'dark')).toBe('/branding/dark.svg'); + }); + + test('prefers the shared logo when the current theme-specific logo is missing', () => { + expect( + resolveLogoUrl( + { + logo_url: '/branding/shared.svg', + logo_url_dark: '/branding/dark.svg', + }, + 'light', + ), + ).toBe('/branding/shared.svg'); + + expect( + resolveLogoUrl( + { + logo_url: '/branding/shared.svg', + logo_url_light: '/branding/light.svg', + }, + 'dark', + ), + ).toBe('/branding/shared.svg'); + }); + + test('uses the shared logo in both themes when it is the only custom logo', () => { + const config = { + logo_url: '/branding/shared.svg', + }; + + expect(resolveLogoUrl(config, 'light')).toBe('/branding/shared.svg'); + expect(resolveLogoUrl(config, 'dark')).toBe('/branding/shared.svg'); + }); + + test('returns null when no custom logo is configured', () => { + expect(resolveLogoUrl({}, 'light')).toBeNull(); + expect(resolveLogoUrl({}, 'dark')).toBeNull(); + }); +}); diff --git a/frontend/src/context/branding.ts b/frontend/src/context/branding.ts new file mode 100644 index 0000000..df45ce9 --- /dev/null +++ b/frontend/src/context/branding.ts @@ -0,0 +1,25 @@ +import type { BrandingConfig } from '../api/config'; +import type { BrandingContextValue } from './branding-context'; +import type { Theme } from './theme-context'; + +export function resolveLogoUrl( + config: Pick, + theme: Theme, +): string | null { + if (theme === 'light') { + return config.logo_url_light ?? config.logo_url ?? config.logo_url_dark ?? null; + } + + return config.logo_url_dark ?? config.logo_url ?? config.logo_url_light ?? null; +} + +export function resolveBrandingValue( + config: BrandingConfig, + theme: Theme, +): BrandingContextValue { + return { + chainName: config.chain_name, + logoUrl: resolveLogoUrl(config, theme), + loaded: true, + }; +} From daa99c14bc55fa0fb792fede986005a0caebc735 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:40:43 +0200 Subject: [PATCH 2/2] Fix frontend Bun CI typing --- frontend/bun.lock | 5 +++++ frontend/package.json | 3 ++- frontend/src/hooks/useChartData.ts | 6 +++--- frontend/tsconfig.app.json | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index 23246a0..d282e72 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -14,6 +14,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@preact/preset-vite": "^2.10.4", + "@types/bun": "^1.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -239,6 +240,8 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], @@ -323,6 +326,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], diff --git a/frontend/package.json b/frontend/package.json index d07b788..e297989 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "bunx vite build", "lint": "eslint .", "preview": "vite preview" }, @@ -19,6 +19,7 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@preact/preset-vite": "^2.10.4", + "@types/bun": "^1.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/hooks/useChartData.ts b/frontend/src/hooks/useChartData.ts index c00f0c1..29a8c4b 100644 --- a/frontend/src/hooks/useChartData.ts +++ b/frontend/src/hooks/useChartData.ts @@ -47,7 +47,7 @@ export function useChartData(window: ChartWindow): ChartData { // Daily txs are window-independent — fetch once and refresh on a slow interval useEffect(() => { let mounted = true; - let timeoutId: number | undefined; + let timeoutId: ReturnType | undefined; setDailyTxsLoading(true); const fetchDaily = async () => { @@ -82,7 +82,7 @@ export function useChartData(window: ChartWindow): ChartData { // Blocks chart depends on the selected window useEffect(() => { let mounted = true; - let timeoutId: number | undefined; + let timeoutId: ReturnType | undefined; setBlocksChartLoading(true); const fetch = async () => { @@ -117,7 +117,7 @@ export function useChartData(window: ChartWindow): ChartData { // Gas price chart depends on the selected window useEffect(() => { let mounted = true; - let timeoutId: number | undefined; + let timeoutId: ReturnType | undefined; setGasPriceLoading(true); const fetch = async () => { diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index a9b5a59..4535511 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -5,7 +5,7 @@ "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], + "types": ["vite/client", "bun"], "skipLibCheck": true, /* Bundler mode */