From a5426aec73678e0e8fad3604847751c695ad6451 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:46:41 +0100 Subject: [PATCH 1/5] feat: add white-label branding support Ports the white-labeling feature from pierrick/white-labeling onto main (post indexer+api merge into atlas-server). - Backend: add 6 branding fields to Config and AppState, new /api/config handler returns chain name, logo URL, and color settings from env vars - Frontend: BrandingContext fetches /api/config on startup, applies CSS custom properties for accent/background colors, updates logo/title - Color utility derives full surface palette from a single base hex - Docker: branding env vars and volume mount for logo assets - Docs: WHITE_LABELING.md explains full configuration options --- .env.example | 8 + .gitignore | 4 + README.md | 3 + .../atlas-server/src/api/handlers/config.rs | 84 ++++++++ .../atlas-server/src/api/handlers/mod.rs | 1 + backend/crates/atlas-server/src/api/mod.rs | 14 ++ backend/crates/atlas-server/src/config.rs | 81 +++++++- backend/crates/atlas-server/src/main.rs | 6 + branding/.gitkeep | 0 docker-compose.yml | 8 + docs/WHITE_LABELING.md | 127 ++++++++++++ frontend/.gitignore | 3 + frontend/nginx.conf | 6 + frontend/src/App.tsx | 3 + frontend/src/api/config.ts | 16 ++ frontend/src/components/Layout.tsx | 11 +- frontend/src/context/BrandingContext.tsx | 87 ++++++++ frontend/src/context/branding-context.ts | 15 ++ frontend/src/hooks/useBranding.ts | 6 + frontend/src/index.css | 42 ++-- frontend/src/pages/WelcomePage.tsx | 7 +- frontend/src/utils/color.ts | 192 ++++++++++++++++++ frontend/tailwind.config.js | 10 +- 23 files changed, 703 insertions(+), 31 deletions(-) create mode 100644 backend/crates/atlas-server/src/api/handlers/config.rs create mode 100644 branding/.gitkeep create mode 100644 docs/WHITE_LABELING.md create mode 100644 frontend/src/api/config.ts create mode 100644 frontend/src/context/BrandingContext.tsx create mode 100644 frontend/src/context/branding-context.ts create mode 100644 frontend/src/hooks/useBranding.ts create mode 100644 frontend/src/utils/color.ts diff --git a/.env.example b/.env.example index f382b9f..9443a34 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,14 @@ RPC_BATCH_SIZE=20 # API_DB_MAX_CONNECTIONS=20 # SSE_REPLAY_BUFFER_BLOCKS=4096 # replay tail used only for active connected clients +# Branding / white-label (all optional) +# CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.svg). Default: bundled logo +# 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 +# SUCCESS_COLOR= # Success indicator hex. Default: #22c55e +# ERROR_COLOR= # Error indicator hex. Default: #dc2626 + # Optional faucet feature # FAUCET_ENABLED=false # FAUCET_PRIVATE_KEY=0x... diff --git a/.gitignore b/.gitignore index 6f05858..9bc3dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ logs/ frontend/node_modules/ frontend/dist/ frontend/.env.local + +# Branding assets (track directory, not contents) +branding/* +!branding/.gitkeep diff --git a/README.md b/README.md index b5583c9..f8edcb9 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,13 @@ Copy `.env.example` to `.env` and set `RPC_URL`. Common options: | `IPFS_GATEWAY` | Gateway for NFT metadata | `https://ipfs.io/ipfs/` | | `REINDEX` | Wipe and reindex from start | `false` | +See [White Labeling](docs/WHITE_LABELING.md) for branding customization (chain name, logo, colors). + ## Documentation - [API Reference](docs/API.md) - [Architecture](docs/ARCHITECTURE.md) +- [White Labeling](docs/WHITE_LABELING.md) - [Product Requirements](docs/PRD.md) ## License diff --git a/backend/crates/atlas-server/src/api/handlers/config.rs b/backend/crates/atlas-server/src/api/handlers/config.rs new file mode 100644 index 0000000..eba39b9 --- /dev/null +++ b/backend/crates/atlas-server/src/api/handlers/config.rs @@ -0,0 +1,84 @@ +use axum::{extract::State, Json}; +use serde::Serialize; +use std::sync::Arc; + +use crate::api::AppState; + +#[derive(Serialize)] +pub struct BrandingConfig { + pub chain_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub accent_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_color_dark: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_color_light: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub success_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_color: Option, +} + +/// GET /api/config - Returns white-label branding configuration +/// No DB access, no auth — returns static config from environment variables +pub async fn get_config(State(state): State>) -> Json { + Json(BrandingConfig { + chain_name: state.chain_name.clone(), + logo_url: state.chain_logo_url.clone(), + accent_color: state.accent_color.clone(), + background_color_dark: state.background_color_dark.clone(), + background_color_light: state.background_color_light.clone(), + success_color: state.success_color.clone(), + error_color: state.error_color.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn branding_config_skips_none_fields() { + let config = BrandingConfig { + chain_name: "TestChain".to_string(), + logo_url: None, + accent_color: Some("#3b82f6".to_string()), + background_color_dark: None, + background_color_light: None, + success_color: None, + error_color: None, + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["chain_name"], "TestChain"); + assert_eq!(json["accent_color"], "#3b82f6"); + assert!(json.get("logo_url").is_none()); + assert!(json.get("background_color_dark").is_none()); + assert!(json.get("success_color").is_none()); + assert!(json.get("error_color").is_none()); + } + + #[test] + fn branding_config_includes_all_fields_when_set() { + let config = BrandingConfig { + chain_name: "MyChain".to_string(), + logo_url: Some("/branding/logo.svg".to_string()), + accent_color: Some("#3b82f6".to_string()), + background_color_dark: Some("#0a0a0f".to_string()), + background_color_light: Some("#faf5ef".to_string()), + success_color: Some("#10b981".to_string()), + error_color: Some("#ef4444".to_string()), + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["chain_name"], "MyChain"); + assert_eq!(json["logo_url"], "/branding/logo.svg"); + assert_eq!(json["accent_color"], "#3b82f6"); + assert_eq!(json["background_color_dark"], "#0a0a0f"); + assert_eq!(json["background_color_light"], "#faf5ef"); + assert_eq!(json["success_color"], "#10b981"); + assert_eq!(json["error_color"], "#ef4444"); + } +} diff --git a/backend/crates/atlas-server/src/api/handlers/mod.rs b/backend/crates/atlas-server/src/api/handlers/mod.rs index 09e09b0..f5952e8 100644 --- a/backend/crates/atlas-server/src/api/handlers/mod.rs +++ b/backend/crates/atlas-server/src/api/handlers/mod.rs @@ -1,5 +1,6 @@ pub mod addresses; pub mod blocks; +pub mod config; pub mod etherscan; pub mod faucet; pub mod logs; diff --git a/backend/crates/atlas-server/src/api/mod.rs b/backend/crates/atlas-server/src/api/mod.rs index 81950c4..fd749d0 100644 --- a/backend/crates/atlas-server/src/api/mod.rs +++ b/backend/crates/atlas-server/src/api/mod.rs @@ -21,6 +21,12 @@ pub struct AppState { pub faucet: Option, pub chain_id: u64, pub chain_name: String, + pub chain_logo_url: Option, + pub accent_color: Option, + pub background_color_dark: Option, + pub background_color_light: Option, + pub success_color: Option, + pub error_color: Option, } /// Build the Axum router. @@ -145,6 +151,8 @@ pub fn build_router(state: Arc, cors_origin: Option) -> Router // Status .route("/api/height", get(handlers::status::get_height)) .route("/api/status", get(handlers::status::get_status)) + // Config (white-label branding) + .route("/api/config", get(handlers::config::get_config)) // Health .route("/health", get(|| async { "OK" })); @@ -244,6 +252,12 @@ mod tests { faucet, chain_id: 1, chain_name: "Test Chain".to_string(), + chain_logo_url: None, + accent_color: None, + background_color_dark: None, + background_color_light: None, + success_color: None, + error_color: None, }) } diff --git a/backend/crates/atlas-server/src/config.rs b/backend/crates/atlas-server/src/config.rs index ab9d691..10f6abc 100644 --- a/backend/crates/atlas-server/src/config.rs +++ b/backend/crates/atlas-server/src/config.rs @@ -34,6 +34,14 @@ pub struct Config { pub cors_origin: Option, pub sse_replay_buffer_blocks: usize, pub chain_name: String, + + // Branding / white-label + pub chain_logo_url: Option, + pub accent_color: Option, + pub background_color_dark: Option, + pub background_color_light: Option, + pub success_color: Option, + pub error_color: Option, } #[derive(Clone)] @@ -123,7 +131,16 @@ impl Config { .context("Invalid API_PORT")?, cors_origin: env::var("CORS_ORIGIN").ok(), sse_replay_buffer_blocks, - chain_name: env::var("CHAIN_NAME").unwrap_or_else(|_| "Unknown".to_string()), + chain_name: env::var("CHAIN_NAME") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "Unknown".to_string()), + chain_logo_url: parse_optional_env(env::var("CHAIN_LOGO_URL").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()), + success_color: parse_optional_env(env::var("SUCCESS_COLOR").ok()), + error_color: parse_optional_env(env::var("ERROR_COLOR").ok()), }) } } @@ -175,6 +192,10 @@ impl FaucetConfig { } } +fn parse_optional_env(val: Option) -> Option { + val.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) +} + fn parse_faucet_amount_to_wei(amount: &str) -> Result { let trimmed = amount.trim(); if trimmed.is_empty() { @@ -240,6 +261,64 @@ mod tests { env::set_var("FAUCET_COOLDOWN_MINUTES", "30"); } + #[test] + fn chain_name_defaults_to_unknown_when_unset() { + let _lock = ENV_LOCK.lock().unwrap(); + set_required_env(); + env::remove_var("CHAIN_NAME"); + assert_eq!(Config::from_env().unwrap().chain_name, "Unknown"); + } + + #[test] + fn chain_name_defaults_to_unknown_when_empty() { + let _lock = ENV_LOCK.lock().unwrap(); + set_required_env(); + env::set_var("CHAIN_NAME", ""); + assert_eq!(Config::from_env().unwrap().chain_name, "Unknown"); + env::remove_var("CHAIN_NAME"); + } + + #[test] + fn chain_name_defaults_to_unknown_when_whitespace_only() { + let _lock = ENV_LOCK.lock().unwrap(); + set_required_env(); + env::set_var("CHAIN_NAME", " "); + assert_eq!(Config::from_env().unwrap().chain_name, "Unknown"); + env::remove_var("CHAIN_NAME"); + } + + #[test] + fn chain_name_uses_provided_value() { + let _lock = ENV_LOCK.lock().unwrap(); + set_required_env(); + env::set_var("CHAIN_NAME", "MyChain"); + assert_eq!(Config::from_env().unwrap().chain_name, "MyChain"); + env::remove_var("CHAIN_NAME"); + } + + #[test] + fn optional_env_returns_none_when_unset() { + assert_eq!(parse_optional_env(None), None); + } + + #[test] + fn optional_env_returns_none_when_empty() { + assert_eq!(parse_optional_env(Some("".to_string())), None); + } + + #[test] + fn optional_env_returns_none_when_whitespace_only() { + assert_eq!(parse_optional_env(Some(" ".to_string())), None); + } + + #[test] + fn optional_env_trims_and_returns_value() { + assert_eq!( + parse_optional_env(Some(" #dc2626 ".to_string())), + Some("#dc2626".to_string()) + ); + } + #[test] fn sse_replay_buffer_validation() { let _lock = ENV_LOCK.lock().unwrap(); diff --git a/backend/crates/atlas-server/src/main.rs b/backend/crates/atlas-server/src/main.rs index 4de168e..481c401 100644 --- a/backend/crates/atlas-server/src/main.rs +++ b/backend/crates/atlas-server/src/main.rs @@ -116,6 +116,12 @@ async fn main() -> Result<()> { faucet, chain_id, chain_name: config.chain_name.clone(), + chain_logo_url: config.chain_logo_url.clone(), + accent_color: config.accent_color.clone(), + background_color_dark: config.background_color_dark.clone(), + background_color_light: config.background_color_light.clone(), + success_color: config.success_color.clone(), + error_color: config.error_color.clone(), }); // Spawn indexer task with retry logic diff --git a/branding/.gitkeep b/branding/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 4e4be78..a494f2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,12 @@ services: FAUCET_AMOUNT: ${FAUCET_AMOUNT:-} FAUCET_COOLDOWN_MINUTES: ${FAUCET_COOLDOWN_MINUTES:-} CHAIN_NAME: ${CHAIN_NAME:-Unknown} + CHAIN_LOGO_URL: ${CHAIN_LOGO_URL:-} + ACCENT_COLOR: ${ACCENT_COLOR:-} + BACKGROUND_COLOR_DARK: ${BACKGROUND_COLOR_DARK:-} + BACKGROUND_COLOR_LIGHT: ${BACKGROUND_COLOR_LIGHT:-} + SUCCESS_COLOR: ${SUCCESS_COLOR:-} + ERROR_COLOR: ${ERROR_COLOR:-} API_HOST: 0.0.0.0 API_PORT: 3000 RUST_LOG: atlas_server=info,tower_http=info @@ -47,6 +53,8 @@ services: dockerfile: Dockerfile ports: - "80:8080" + volumes: + - ${BRANDING_DIR:-./branding}:/usr/share/nginx/html/branding:ro depends_on: - atlas-server restart: unless-stopped diff --git a/docs/WHITE_LABELING.md b/docs/WHITE_LABELING.md new file mode 100644 index 0000000..9edf813 --- /dev/null +++ b/docs/WHITE_LABELING.md @@ -0,0 +1,127 @@ +# White Labeling + +Atlas supports white-labeling so each L2 chain can customize the explorer's appearance — name, logo, and color scheme — without rebuilding the frontend. + +All branding is configured through environment variables. When none are set, the explorer uses the default ev-node branding (red accent, dark/warm-beige backgrounds). `CHAIN_NAME` defaults to "Unknown" — deployers should always set it. + +## Configuration + +Add these variables to your `.env` file alongside `RPC_URL`: + +| Variable | Description | Default (ev-node) | +|----------|-------------|--------------------| +| `CHAIN_NAME` | Displayed in the navbar, page title, and welcome page | `Unknown` | +| `CHAIN_LOGO_URL` | URL or path to your logo (e.g. `/branding/logo.svg`) | Bundled ev-node logo | +| `ACCENT_COLOR` | Primary accent hex for links, buttons, active states | `#dc2626` | +| `BACKGROUND_COLOR_DARK` | Dark mode base background hex | `#050505` | +| `BACKGROUND_COLOR_LIGHT` | Light mode base background hex | `#f4ede6` | +| `SUCCESS_COLOR` | Success indicator hex (e.g. confirmed badges) | `#22c55e` | +| `ERROR_COLOR` | Error indicator hex (e.g. failed badges) | `#dc2626` | + +All variables are optional. Unset variables use the ev-node defaults shown above. + +## Custom Logo + +To use a custom logo, place your image file in a `branding/` directory at the project root and set `CHAIN_LOGO_URL` to its path: + +```text +atlas/ +├── branding/ +│ └── logo.svg # Your custom logo +├── .env +├── docker-compose.yml +└── ... +``` + +```env +CHAIN_LOGO_URL=/branding/logo.svg +``` + +The logo appears in the navbar, the welcome page, and as the browser favicon. + +### Docker + +In Docker, the `branding/` directory is mounted into the frontend container as a read-only volume. This is configured automatically in `docker-compose.yml`: + +```yaml +atlas-frontend: + volumes: + - ${BRANDING_DIR:-./branding}:/usr/share/nginx/html/branding:ro +``` + +To use a different directory, set `BRANDING_DIR` in your `.env`: + +```env +BRANDING_DIR=/path/to/my/assets +``` + +### Local Development + +For `bun run dev`, create a symlink so Vite's dev server can serve the branding files: + +```bash +cd frontend/public +ln -s ../../branding branding +``` + +## Color System + +### Accent Color + +`ACCENT_COLOR` sets the primary interactive color used for links, buttons, focus rings, and active indicators throughout the UI. + +### Background Colors + +Each theme (dark and light) takes a single base color. The frontend automatically derives a full surface palette from it: + +- **5 surface shades** (from darkest to lightest for dark mode, reversed for light mode) +- **Border color** +- **Text hierarchy** (primary, secondary, muted, subtle, faint) + +This means you only need to set one color per theme to get a cohesive palette. + +### Success and Error Colors + +`SUCCESS_COLOR` and `ERROR_COLOR` control status badges and indicators. For example, "Success" transaction badges use the success color, and "Failed" badges use the error color. + +## Examples + +### Blue theme + +```env +CHAIN_NAME=MegaChain +CHAIN_LOGO_URL=/branding/logo.png +ACCENT_COLOR=#3b82f6 +BACKGROUND_COLOR_DARK=#0a0a1a +BACKGROUND_COLOR_LIGHT=#e6f0f4 +``` + +### Green theme (Eden) + +```env +CHAIN_NAME=Eden +CHAIN_LOGO_URL=/branding/logo.svg +ACCENT_COLOR=#4ade80 +BACKGROUND_COLOR_DARK=#0a1f0a +BACKGROUND_COLOR_LIGHT=#e8f5e8 +SUCCESS_COLOR=#22c55e +ERROR_COLOR=#dc2626 +``` + +### Minimal — just rename + +```env +CHAIN_NAME=MyChain +``` + +Everything else stays default ev-node branding. + +## How It Works + +1. The backend reads branding env vars at startup and serves them via `GET /api/config` +2. The frontend fetches this config once on page load +3. CSS custom properties are set on the document root, overriding the defaults +4. Background surface shades are derived automatically using HSL color manipulation +5. The page title, navbar logo, and favicon are updated dynamically + +No frontend rebuild is needed — just change the env vars and restart the API. diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..039f7af 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Local branding symlink (created manually for dev, see docs/WHITE_LABELING.md) +public/branding diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 2ae77fe..5e4aa1b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -27,6 +27,12 @@ server { proxy_cache off; } + # Serve mounted branding assets (logos, etc.) + location /branding/ { + alias /usr/share/nginx/html/branding/; + expires 1h; + } + # Proxy API requests to atlas-api service location /api/ { proxy_pass http://atlas-server:3000/api/; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbb8e77..e558eed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Layout } from './components'; import { ThemeProvider } from './context/ThemeContext'; +import { BrandingProvider } from './context/BrandingContext'; const BlocksPage = lazy(() => import('./pages/BlocksPage')); const BlockDetailPage = lazy(() => import('./pages/BlockDetailPage')); @@ -32,6 +33,7 @@ function PageLoader() { export default function App() { return ( + }> @@ -55,6 +57,7 @@ export default function App() { + ); } diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..311e64e --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,16 @@ +import client from './client'; + +export interface BrandingConfig { + chain_name: string; + logo_url?: string; + accent_color?: string; + background_color_dark?: string; + background_color_light?: string; + success_color?: string; + error_color?: string; +} + +export async function getConfig(): Promise { + const response = await client.get('/config'); + return response.data; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b6ac519..192bfd7 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,10 +4,11 @@ import SearchBar from './SearchBar'; import useBlockSSE from '../hooks/useBlockSSE'; import useFaucetInfo from '../hooks/useFaucetInfo'; import SmoothCounter from './SmoothCounter'; -import logoImg from '../assets/logo.png'; +import defaultLogoImg from '../assets/logo.png'; import { BlockStatsContext } from '../context/BlockStatsContext'; import { FaucetInfoContext } from '../context/FaucetInfoContext'; import { useTheme } from '../hooks/useTheme'; +import { useBranding } from '../hooks/useBranding'; export default function Layout() { const location = useLocation(); @@ -34,6 +35,8 @@ export default function Layout() { }`; const { theme, toggleTheme } = useTheme(); const isDark = theme === 'dark'; + const { chainName, logoUrl } = useBranding(); + const logoSrc = logoUrl || defaultLogoImg; return (
@@ -43,8 +46,8 @@ export default function Layout() {
{/* Logo */}
- - Atlas + + {chainName}
@@ -114,7 +117,7 @@ export default function Layout() {
diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx new file mode 100644 index 0000000..f7ea843 --- /dev/null +++ b/frontend/src/context/BrandingContext.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState, useContext, type ReactNode } from 'react'; +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'; + +export function BrandingProvider({ children }: { children: ReactNode }) { + const [branding, setBranding] = useState(brandingDefaults); + const [config, setConfig] = useState(null); + const themeCtx = useContext(ThemeContext); + const theme = themeCtx?.theme ?? 'dark'; + + // Fetch config once on mount + useEffect(() => { + getConfig() + .then((cfg) => { + setConfig(cfg); + setBranding({ + chainName: cfg.chain_name, + logoUrl: cfg.logo_url || null, + loaded: true, + }); + + // Update page title + document.title = `${cfg.chain_name} - Block Explorer`; + + // Update favicon if logo_url is set + if (cfg.logo_url) { + const link = document.querySelector("link[rel='icon']"); + if (link) { + link.href = cfg.logo_url; + } + } + }) + .catch(() => { + setBranding({ ...brandingDefaults, loaded: true }); + }); + }, []); + + // Apply accent + semantic colors (theme-independent) + useEffect(() => { + if (!config) return; + const root = document.documentElement; + + if (config.accent_color) { + root.style.setProperty('--color-accent-primary', hexToRgbTriplet(config.accent_color)); + } + if (config.success_color) { + root.style.setProperty('--color-accent-success', hexToRgbTriplet(config.success_color)); + } + if (config.error_color) { + root.style.setProperty('--color-accent-error', hexToRgbTriplet(config.error_color)); + } + }, [config]); + + // Apply background palette reactively on theme change + useEffect(() => { + if (!config) return; + + if (theme === 'dark' && config.background_color_dark) { + const palette = deriveSurfaceShades(config.background_color_dark, 'dark'); + applyPalette(palette); + } else if (theme === 'light' && config.background_color_light) { + const palette = deriveSurfaceShades(config.background_color_light, 'light'); + applyPalette(palette); + } else { + // Remove any inline overrides so the CSS defaults take effect + const root = document.documentElement; + const vars = [ + '--color-surface-900', '--color-surface-800', '--color-surface-700', + '--color-surface-600', '--color-surface-500', '--color-body-bg', + '--color-body-text', '--color-border', '--color-text-primary', + '--color-text-secondary', '--color-text-muted', '--color-text-subtle', + '--color-text-faint', + ]; + vars.forEach((v) => { + root.style.removeProperty(v); + }); + } + }, [config, theme]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/context/branding-context.ts b/frontend/src/context/branding-context.ts new file mode 100644 index 0000000..d772081 --- /dev/null +++ b/frontend/src/context/branding-context.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +export interface BrandingContextValue { + chainName: string; + logoUrl: string | null; + loaded: boolean; +} + +export const brandingDefaults: BrandingContextValue = { + chainName: 'Unknown', + logoUrl: null, + loaded: false, +}; + +export const BrandingContext = createContext(brandingDefaults); diff --git a/frontend/src/hooks/useBranding.ts b/frontend/src/hooks/useBranding.ts new file mode 100644 index 0000000..faa821c --- /dev/null +++ b/frontend/src/hooks/useBranding.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { BrandingContext } from '../context/branding-context'; + +export function useBranding() { + return useContext(BrandingContext); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d6f3cd8..5861530 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,6 +4,9 @@ @layer base { :root { + --color-accent-primary: 220 38 38; + --color-accent-success: 34 197 94; + --color-accent-error: 220 38 38; --color-surface-900: 6 6 8; --color-surface-800: 12 12 16; --color-surface-700: 20 20 28; @@ -73,11 +76,12 @@ .btn { @apply px-3 py-1.5 font-medium rounded-lg transition-all duration-150; - @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary/50; + @apply focus-visible:outline-none focus-visible:ring-2; + --tw-ring-color: color-mix(in srgb, rgb(var(--color-accent-primary)) 50%, transparent); } .btn-primary { - @apply text-white bg-gradient-to-r from-accent-primary to-red-600 hover:to-red-500 shadow-sm shadow-black/20; + @apply text-white bg-accent-primary hover:brightness-110 shadow-sm shadow-black/20; } .btn-secondary { @@ -105,8 +109,8 @@ border: 1px solid rgb(var(--color-surface-500)); font-size: 0.65rem; font-weight: 600; - background-color: rgb(var(--color-dark-600)); - color: #e2e8f0; + background-color: rgb(var(--color-surface-600)); + color: rgb(var(--color-text-secondary)); } .status-badge { @@ -121,33 +125,33 @@ } .status-badge--success { - color: #4ade80; - border-color: rgba(74, 222, 128, 0.35); - background-color: rgba(34, 197, 94, 0.12); + color: rgb(var(--color-accent-success)); + border-color: color-mix(in srgb, rgb(var(--color-accent-success)) 35%, transparent); + background-color: color-mix(in srgb, rgb(var(--color-accent-success)) 12%, transparent); } .status-badge--error { - color: #f87171; - border-color: rgba(248, 113, 113, 0.4); - background-color: rgba(248, 113, 113, 0.12); + color: rgb(var(--color-accent-error)); + border-color: color-mix(in srgb, rgb(var(--color-accent-error)) 40%, transparent); + background-color: color-mix(in srgb, rgb(var(--color-accent-error)) 12%, transparent); } [data-theme='light'] .badge-chip { - border-color: #ccbcae; - background-color: #ede0d4; - color: #2f241b; + border-color: rgb(var(--color-surface-500)); + background-color: rgb(var(--color-surface-600)); + color: rgb(var(--color-text-primary)); } [data-theme='light'] .status-badge--success { - color: #166534; - border-color: #86efac; - background-color: #d1fae5; + color: color-mix(in srgb, rgb(var(--color-accent-success)) 80%, black); + border-color: color-mix(in srgb, rgb(var(--color-accent-success)) 60%, white); + background-color: color-mix(in srgb, rgb(var(--color-accent-success)) 15%, white); } [data-theme='light'] .status-badge--error { - color: #7f1d1d; - border-color: #fca5a5; - background-color: #fee2e2; + color: color-mix(in srgb, rgb(var(--color-accent-error)) 80%, black); + border-color: color-mix(in srgb, rgb(var(--color-accent-error)) 60%, white); + background-color: color-mix(in srgb, rgb(var(--color-accent-error)) 15%, white); } } diff --git a/frontend/src/pages/WelcomePage.tsx b/frontend/src/pages/WelcomePage.tsx index b046ffe..78fa3b7 100644 --- a/frontend/src/pages/WelcomePage.tsx +++ b/frontend/src/pages/WelcomePage.tsx @@ -1,19 +1,22 @@ import SearchBar from '../components/SearchBar'; -import logoImg from '../assets/logo.png'; +import defaultLogoImg from '../assets/logo.png'; import useStats from '../hooks/useStats'; import { formatNumber } from '../utils'; import { useContext, useMemo } from 'react'; import { BlockStatsContext } from '../context/BlockStatsContext'; +import { useBranding } from '../hooks/useBranding'; export default function WelcomePage() { const { totals, dailyTx, avgBlockTimeSec, loading } = useStats(); const { bps } = useContext(BlockStatsContext); const headerAvgSec = useMemo(() => (bps && bps > 0 ? 1 / bps : null), [bps]); + const { chainName, logoUrl } = useBranding(); + const logoSrc = logoUrl || defaultLogoImg; return (
- Atlas + {chainName}

diff --git a/frontend/src/utils/color.ts b/frontend/src/utils/color.ts new file mode 100644 index 0000000..6c86384 --- /dev/null +++ b/frontend/src/utils/color.ts @@ -0,0 +1,192 @@ +/** + * Color utilities for white-label theming. + * Derives surface shade palettes from a single base background color. + */ + +interface RGB { + r: number; + g: number; + b: number; +} + +interface HSL { + h: number; + s: number; + l: number; +} + +interface DerivedPalette { + surface900: string; // RGB triplet "r g b" + surface800: string; + surface700: string; + surface600: string; + surface500: string; + bodyBg: string; // hex + bodyText: string; // hex + border: string; // RGB triplet + textPrimary: string; // RGB triplet + textSecondary: string; + textMuted: string; + textSubtle: string; + textFaint: string; +} + +export function hexToRgbTriplet(hex: string): string { + const { r, g, b } = hexToRgb(hex); + return `${r} ${g} ${b}`; +} + +function hexToRgb(hex: string): RGB { + const raw = hex.trim().replace(/^#/, ''); + const clean = raw.length === 3 + ? raw.split('').map((c) => c + c).join('') + : raw; + + if (!/^[0-9a-fA-F]{6}$/.test(clean)) { + throw new Error(`Invalid hex color: "${hex}"`); + } + + return { + r: parseInt(clean.slice(0, 2), 16), + g: parseInt(clean.slice(2, 4), 16), + b: parseInt(clean.slice(4, 6), 16), + }; +} + +function rgbToHsl({ r, g, b }: RGB): HSL { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + let h = 0; + let s = 0; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case rn: + h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + break; + case gn: + h = ((bn - rn) / d + 2) / 6; + break; + case bn: + h = ((rn - gn) / d + 4) / 6; + break; + } + } + + return { h: h * 360, s: s * 100, l: l * 100 }; +} + +function hslToRgb({ h, s, l }: HSL): RGB { + const sn = s / 100; + const ln = l / 100; + const c = (1 - Math.abs(2 * ln - 1)) * sn; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = ln - c / 2; + let rn = 0, gn = 0, bn = 0; + + if (h < 60) { rn = c; gn = x; } + else if (h < 120) { rn = x; gn = c; } + else if (h < 180) { gn = c; bn = x; } + else if (h < 240) { gn = x; bn = c; } + else if (h < 300) { rn = x; bn = c; } + else { rn = c; bn = x; } + + return { + r: Math.round((rn + m) * 255), + g: Math.round((gn + m) * 255), + b: Math.round((bn + m) * 255), + }; +} + +function rgbTriplet(rgb: RGB): string { + return `${rgb.r} ${rgb.g} ${rgb.b}`; +} + +function adjustLightness(hsl: HSL, delta: number): RGB { + return hslToRgb({ ...hsl, l: Math.min(100, Math.max(0, hsl.l + delta)) }); +} + +/** + * Derive a full surface palette from a single base background color. + * For dark mode, surfaces are lighter than the base. + * For light mode, surfaces are darker than the base. + */ +export function deriveSurfaceShades(baseHex: string, mode: 'dark' | 'light'): DerivedPalette { + const baseRgb = hexToRgb(baseHex); + const baseHsl = rgbToHsl(baseRgb); + const dir = mode === 'dark' ? 1 : -1; + + const surface900 = adjustLightness(baseHsl, dir * 1); + const surface800 = adjustLightness(baseHsl, dir * 3); + const surface700 = adjustLightness(baseHsl, dir * 6); + const surface600 = adjustLightness(baseHsl, dir * 11); + const surface500 = adjustLightness(baseHsl, dir * 17); + const border = adjustLightness(baseHsl, dir * 14); + + // Text colors: neutral grays with good contrast + if (mode === 'dark') { + return { + surface900: rgbTriplet(surface900), + surface800: rgbTriplet(surface800), + surface700: rgbTriplet(surface700), + surface600: rgbTriplet(surface600), + surface500: rgbTriplet(surface500), + bodyBg: baseHex, + bodyText: '#f8fafc', + border: rgbTriplet(border), + textPrimary: '248 250 252', + textSecondary: '229 231 235', + textMuted: '203 213 225', + textSubtle: '148 163 184', + textFaint: '100 116 139', + }; + } else { + return { + surface900: rgbTriplet(surface900), + surface800: rgbTriplet(surface800), + surface700: rgbTriplet(surface700), + surface600: rgbTriplet(surface600), + surface500: rgbTriplet(surface500), + bodyBg: baseHex, + bodyText: '#1f1f1f', + border: rgbTriplet(border), + textPrimary: '31 31 31', + textSecondary: '54 54 54', + textMuted: '88 88 88', + textSubtle: '120 120 120', + textFaint: '150 150 150', + }; + } +} + +/** + * Apply a derived palette to the document root as CSS custom properties. + */ +export function applyPalette(palette: DerivedPalette) { + const root = document.documentElement; + + // For dark mode, set on :root directly. + // For light mode, we set the same vars — they'll be active when data-theme='light' + // is set because the BrandingContext applies them reactively on theme change. + const setVar = (name: string, value: string) => root.style.setProperty(name, value); + + setVar('--color-surface-900', palette.surface900); + setVar('--color-surface-800', palette.surface800); + setVar('--color-surface-700', palette.surface700); + setVar('--color-surface-600', palette.surface600); + setVar('--color-surface-500', palette.surface500); + setVar('--color-body-bg', palette.bodyBg); + setVar('--color-body-text', palette.bodyText); + setVar('--color-border', palette.border); + setVar('--color-text-primary', palette.textPrimary); + setVar('--color-text-secondary', palette.textSecondary); + setVar('--color-text-muted', palette.textMuted); + setVar('--color-text-subtle', palette.textSubtle); + setVar('--color-text-faint', palette.textFaint); +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index ed526d7..c70c67a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -44,11 +44,11 @@ export default { 700: 'rgb(var(--color-gray-700) / )', }, accent: { - primary: '#dc2626', - secondary: '#dc2626', - success: '#22c55e', - warning: '#dc2626', - error: '#dc2626', + primary: 'rgb(var(--color-accent-primary, 220 38 38) / )', + secondary: 'rgb(var(--color-accent-primary, 220 38 38) / )', + success: 'rgb(var(--color-accent-success, 34 197 94) / )', + warning: 'rgb(var(--color-accent-primary, 220 38 38) / )', + error: 'rgb(var(--color-accent-error, 220 38 38) / )', }, }, }, From 83f68e9c6e418fde20623e01959b9a751b78d47e Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:51:35 +0100 Subject: [PATCH 2/5] fix: add branding fields to AppState constructors in test helpers --- backend/crates/atlas-server/src/api/handlers/faucet.rs | 6 ++++++ backend/crates/atlas-server/src/api/handlers/status.rs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/backend/crates/atlas-server/src/api/handlers/faucet.rs b/backend/crates/atlas-server/src/api/handlers/faucet.rs index 80efb5c..d3a50ad 100644 --- a/backend/crates/atlas-server/src/api/handlers/faucet.rs +++ b/backend/crates/atlas-server/src/api/handlers/faucet.rs @@ -169,6 +169,12 @@ mod tests { faucet, chain_id: 1, chain_name: "Test Chain".to_string(), + chain_logo_url: None, + accent_color: None, + background_color_dark: None, + background_color_light: None, + success_color: None, + error_color: None, }) } diff --git a/backend/crates/atlas-server/src/api/handlers/status.rs b/backend/crates/atlas-server/src/api/handlers/status.rs index 792e3e2..dd6fc5c 100644 --- a/backend/crates/atlas-server/src/api/handlers/status.rs +++ b/backend/crates/atlas-server/src/api/handlers/status.rs @@ -101,6 +101,12 @@ mod tests { faucet: None, chain_id: 1, chain_name: "Test Chain".to_string(), + chain_logo_url: None, + accent_color: None, + background_color_dark: None, + background_color_light: None, + success_color: None, + error_color: None, })) } From d8c6c8da5428f8dbc78c95c3e40f29576a59865d Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:12:10 +0100 Subject: [PATCH 3/5] fix: address CodeRabbit review comments and CI failure - Trim CHAIN_NAME before storing (prevents " MyChain " propagating) - Wrap color parsing in try/catch in BrandingContext (bad hex won't crash theming) - Fix getConfig() to return response directly, not response.data (custom fetch client, not axios) --- backend/crates/atlas-server/src/config.rs | 12 ++++++- frontend/src/api/config.ts | 3 +- frontend/src/context/BrandingContext.tsx | 40 +++++++++++++++-------- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/backend/crates/atlas-server/src/config.rs b/backend/crates/atlas-server/src/config.rs index 10f6abc..2f48591 100644 --- a/backend/crates/atlas-server/src/config.rs +++ b/backend/crates/atlas-server/src/config.rs @@ -133,7 +133,8 @@ impl Config { sse_replay_buffer_blocks, chain_name: env::var("CHAIN_NAME") .ok() - .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) .unwrap_or_else(|| "Unknown".to_string()), chain_logo_url: parse_optional_env(env::var("CHAIN_LOGO_URL").ok()), accent_color: parse_optional_env(env::var("ACCENT_COLOR").ok()), @@ -296,6 +297,15 @@ mod tests { env::remove_var("CHAIN_NAME"); } + #[test] + fn chain_name_trims_surrounding_whitespace() { + let _lock = ENV_LOCK.lock().unwrap(); + set_required_env(); + env::set_var("CHAIN_NAME", " MyChain "); + assert_eq!(Config::from_env().unwrap().chain_name, "MyChain"); + env::remove_var("CHAIN_NAME"); + } + #[test] fn optional_env_returns_none_when_unset() { assert_eq!(parse_optional_env(None), None); diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 311e64e..8e85768 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -11,6 +11,5 @@ export interface BrandingConfig { } export async function getConfig(): Promise { - const response = await client.get('/config'); - return response.data; + return client.get('/config'); } diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index f7ea843..788f625 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -42,15 +42,21 @@ export function BrandingProvider({ children }: { children: ReactNode }) { if (!config) return; const root = document.documentElement; - if (config.accent_color) { - root.style.setProperty('--color-accent-primary', hexToRgbTriplet(config.accent_color)); - } - if (config.success_color) { - root.style.setProperty('--color-accent-success', hexToRgbTriplet(config.success_color)); - } - if (config.error_color) { - root.style.setProperty('--color-accent-error', hexToRgbTriplet(config.error_color)); - } + const setRgbVar = (cssVar: string, hex: string | null | undefined) => { + if (!hex) { + root.style.removeProperty(cssVar); + return; + } + try { + root.style.setProperty(cssVar, hexToRgbTriplet(hex)); + } catch { + root.style.removeProperty(cssVar); + } + }; + + setRgbVar('--color-accent-primary', config.accent_color); + setRgbVar('--color-accent-success', config.success_color); + setRgbVar('--color-accent-error', config.error_color); }, [config]); // Apply background palette reactively on theme change @@ -58,11 +64,19 @@ export function BrandingProvider({ children }: { children: ReactNode }) { if (!config) return; if (theme === 'dark' && config.background_color_dark) { - const palette = deriveSurfaceShades(config.background_color_dark, 'dark'); - applyPalette(palette); + try { + const palette = deriveSurfaceShades(config.background_color_dark, 'dark'); + applyPalette(palette); + } catch { + // fall through to default CSS vars + } } else if (theme === 'light' && config.background_color_light) { - const palette = deriveSurfaceShades(config.background_color_light, 'light'); - applyPalette(palette); + try { + const palette = deriveSurfaceShades(config.background_color_light, 'light'); + applyPalette(palette); + } catch { + // fall through to default CSS vars + } } else { // Remove any inline overrides so the CSS defaults take effect const root = document.documentElement; From ac3e2ad05e495d21eb94e6766cfa178d850da006 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:28:04 +0100 Subject: [PATCH 4/5] fix: cache branding config in localStorage to prevent flash on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read cached config synchronously before first render so custom colors are applied immediately. Background fetch revalidates and updates the cache — stale-while-revalidate is safe since branding config is admin-controlled and rarely changes. --- frontend/src/context/BrandingContext.tsx | 54 +++++++++++++++--------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index 788f625..a84b5d2 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -4,37 +4,49 @@ import { deriveSurfaceShades, applyPalette, hexToRgbTriplet } from '../utils/col import { ThemeContext } from './theme-context'; import { BrandingContext, brandingDefaults } from './branding-context'; +const CACHE_KEY = 'branding_config'; + +function readCache(): BrandingConfig | null { + try { + const raw = localStorage.getItem(CACHE_KEY); + return raw ? (JSON.parse(raw) as BrandingConfig) : null; + } catch { + return 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; + } +} + export function BrandingProvider({ children }: { children: ReactNode }) { - const [branding, setBranding] = useState(brandingDefaults); - const [config, setConfig] = useState(null); + const [branding, setBranding] = useState(() => { + const cached = readCache(); + return cached + ? { chainName: cached.chain_name, logoUrl: cached.logo_url || null, loaded: true } + : brandingDefaults; + }); + const [config, setConfig] = useState(readCache); const themeCtx = useContext(ThemeContext); const theme = themeCtx?.theme ?? 'dark'; - // Fetch config once on mount + // Apply cached config immediately on mount (no flash), then revalidate in background useEffect(() => { getConfig() .then((cfg) => { - setConfig(cfg); - setBranding({ - chainName: cfg.chain_name, - logoUrl: cfg.logo_url || null, - loaded: true, - }); - - // Update page title - document.title = `${cfg.chain_name} - Block Explorer`; - - // Update favicon if logo_url is set - if (cfg.logo_url) { - const link = document.querySelector("link[rel='icon']"); - if (link) { - link.href = cfg.logo_url; - } - } + localStorage.setItem(CACHE_KEY, JSON.stringify(cfg)); + applyConfigState(cfg, setBranding, setConfig); }) .catch(() => { - setBranding({ ...brandingDefaults, loaded: true }); + if (!config) setBranding({ ...brandingDefaults, loaded: true }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Apply accent + semantic colors (theme-independent) From a8c57b5e13a649b0f9b63cc74d66f2710a275c21 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:32:06 +0100 Subject: [PATCH 5/5] feat: add neutral loading screen while branding config is fetched On first visit (no localStorage cache), show a full-screen spinner using neutral slate tones instead of rendering the app with unbranded default colors. Once the /api/config response arrives and colors are applied to :root, the spinner disappears and the app renders correctly. Return visits are unaffected: cached config is read synchronously in the useState initializer so loaded starts as true and the spinner never shows. --- frontend/src/context/BrandingContext.tsx | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index a84b5d2..e677a72 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -105,6 +105,33 @@ export function BrandingProvider({ children }: { children: ReactNode }) { } }, [config, theme]); + if (!branding.loaded) { + return ( +

+
+ +
+ ); + } + return ( {children}