From f83e31350e62aff7743fb19fe493154baba571d4 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:48:54 +0100 Subject: [PATCH 01/11] feat: add white-label branding support Add runtime-configurable branding via environment variables so each chain deployment can customize name, logo, colors without rebuilding. Backend: new /api/config endpoint serving branding config from env vars (CHAIN_NAME, CHAIN_LOGO_URL, ACCENT_COLOR, BACKGROUND_COLOR_DARK, BACKGROUND_COLOR_LIGHT, SUCCESS_COLOR, ERROR_COLOR). Frontend: BrandingContext fetches config once on load and applies CSS custom properties for accent, success/error, and derived surface palettes. Logo, chain name, favicon, and page title update dynamically. All values are optional with sensible defaults matching current Atlas branding. --- .env.example | 10 + .../crates/atlas-api/src/handlers/config.rs | 36 ++++ backend/crates/atlas-api/src/handlers/mod.rs | 1 + backend/crates/atlas-api/src/main.rs | 24 +++ docker-compose.yml | 9 + 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 | 102 ++++++++++ frontend/src/index.css | 42 ++-- frontend/src/pages/WelcomePage.tsx | 7 +- frontend/src/utils/color.ts | 183 ++++++++++++++++++ frontend/tailwind.config.js | 10 +- 14 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 backend/crates/atlas-api/src/handlers/config.rs create mode 100644 frontend/src/api/config.ts create mode 100644 frontend/src/context/BrandingContext.tsx create mode 100644 frontend/src/utils/color.ts diff --git a/.env.example b/.env.example index 4426f24..eed29de 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,13 @@ FETCH_WORKERS=10 # Number of blocks to fetch per RPC batch request (reduces HTTP round-trips) RPC_BATCH_SIZE=20 + +# Branding / White-label (all optional) +# If not set, the default Atlas branding is used. +CHAIN_NAME=Atlas +CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.png). Default: bundled Atlas logo +ACCENT_COLOR=#dc2626 # Primary accent color (links, buttons, active states) +BACKGROUND_COLOR_DARK=#050505 # Dark mode base background +BACKGROUND_COLOR_LIGHT=#f4ede6 # Light mode base background +SUCCESS_COLOR=#22c55e # Success indicator color +ERROR_COLOR=#dc2626 # Error indicator color diff --git a/backend/crates/atlas-api/src/handlers/config.rs b/backend/crates/atlas-api/src/handlers/config.rs new file mode 100644 index 0000000..b13e156 --- /dev/null +++ b/backend/crates/atlas-api/src/handlers/config.rs @@ -0,0 +1,36 @@ +use axum::{extract::State, Json}; +use serde::Serialize; +use std::sync::Arc; + +use crate::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(), + }) +} diff --git a/backend/crates/atlas-api/src/handlers/mod.rs b/backend/crates/atlas-api/src/handlers/mod.rs index 769a4e2..29d1dee 100644 --- a/backend/crates/atlas-api/src/handlers/mod.rs +++ b/backend/crates/atlas-api/src/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod addresses; pub mod auth; pub mod blocks; +pub mod config; pub mod contracts; pub mod etherscan; pub mod labels; diff --git a/backend/crates/atlas-api/src/main.rs b/backend/crates/atlas-api/src/main.rs index 771ce32..09181f5 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -21,6 +21,14 @@ pub struct AppState { pub rpc_url: String, pub solc_path: String, pub admin_api_key: Option, + // White-label branding + 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, } #[tokio::main] @@ -42,6 +50,13 @@ async fn main() -> Result<()> { let rpc_url = std::env::var("RPC_URL").expect("RPC_URL must be set"); let solc_path = std::env::var("SOLC_PATH").unwrap_or_else(|_| "solc".to_string()); let admin_api_key = std::env::var("ADMIN_API_KEY").ok(); + let chain_name = std::env::var("CHAIN_NAME").unwrap_or_else(|_| "Atlas".to_string()); + let chain_logo_url = std::env::var("CHAIN_LOGO_URL").ok().filter(|s| !s.is_empty()); + let accent_color = std::env::var("ACCENT_COLOR").ok().filter(|s| !s.is_empty()); + let background_color_dark = std::env::var("BACKGROUND_COLOR_DARK").ok().filter(|s| !s.is_empty()); + let background_color_light = std::env::var("BACKGROUND_COLOR_LIGHT").ok().filter(|s| !s.is_empty()); + let success_color = std::env::var("SUCCESS_COLOR").ok().filter(|s| !s.is_empty()); + let error_color = std::env::var("ERROR_COLOR").ok().filter(|s| !s.is_empty()); let host = std::env::var("API_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let port: u16 = std::env::var("API_PORT") .unwrap_or_else(|_| "3000".to_string()) @@ -63,6 +78,13 @@ async fn main() -> Result<()> { rpc_url, solc_path, admin_api_key, + chain_name, + chain_logo_url, + accent_color, + background_color_dark, + background_color_light, + success_color, + error_color, }); tokio::spawn(handlers::sse::run_block_event_fanout( @@ -225,6 +247,8 @@ async fn main() -> Result<()> { .route("/api/search", get(handlers::search::search)) // Status .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" })) .layer(TimeoutLayer::with_status_code( diff --git a/docker-compose.yml b/docker-compose.yml index 0e206aa..4a94ed4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,13 @@ services: API_HOST: 0.0.0.0 API_PORT: 3000 RUST_LOG: atlas_api=info,tower_http=info + CHAIN_NAME: ${CHAIN_NAME:-Atlas} + 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:-} ports: - "3000:3000" depends_on: @@ -60,6 +67,8 @@ services: dockerfile: Dockerfile ports: - "80:8080" + volumes: + - ${BRANDING_DIR:-./branding}:/usr/share/nginx/html/branding:ro depends_on: - atlas-api restart: unless-stopped diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d753b3b..4470a28 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -22,6 +22,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-api:3000/api/; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93898a3..a97de2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,10 +18,12 @@ import { AddressesPage, } from './pages'; import { ThemeProvider } from './context/ThemeContext'; +import { BrandingProvider } from './context/BrandingContext'; export default function App() { return ( + }> @@ -43,6 +45,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 2d3d63b..83a7d66 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,9 +4,10 @@ import SearchBar from './SearchBar'; import useLatestBlockHeight from '../hooks/useLatestBlockHeight'; import useBlockSSE from '../hooks/useBlockSSE'; import SmoothCounter from './SmoothCounter'; -import logoImg from '../assets/logo.png'; +import defaultLogoImg from '../assets/logo.png'; import { BlockStatsContext } from '../context/BlockStatsContext'; import { useTheme } from '../hooks/useTheme'; +import { useBranding } from '../context/BrandingContext'; export default function Layout() { const location = useLocation(); @@ -113,6 +114,8 @@ export default function Layout() { }`; const { theme, toggleTheme } = useTheme(); const isDark = theme === 'dark'; + const { chainName, logoUrl } = useBranding(); + const logoSrc = logoUrl || defaultLogoImg; return (
@@ -122,8 +125,8 @@ export default function Layout() {
{/* Logo */}
- - Atlas + + {chainName}
@@ -185,7 +188,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..4d298a2 --- /dev/null +++ b/frontend/src/context/BrandingContext.tsx @@ -0,0 +1,102 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { getConfig, type BrandingConfig } from '../api/config'; +import { deriveSurfaceShades, applyPalette } from '../utils/color'; +import { ThemeContext } from './theme-context'; + +interface BrandingContextValue { + chainName: string; + logoUrl: string | null; + loaded: boolean; +} + +const defaults: BrandingContextValue = { + chainName: 'Atlas', + logoUrl: null, + loaded: false, +}; + +const BrandingContext = createContext(defaults); + +export function BrandingProvider({ children }: { children: ReactNode }) { + const [branding, setBranding] = useState(defaults); + 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({ ...defaults, 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', config.accent_color); + } + if (config.success_color) { + root.style.setProperty('--color-accent-success', config.success_color); + } + if (config.error_color) { + root.style.setProperty('--color-accent-error', 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, 'dark'); + } else if (theme === 'light' && config.background_color_light) { + const palette = deriveSurfaceShades(config.background_color_light, 'light'); + applyPalette(palette, 'light'); + } 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} + + ); +} + +export function useBranding() { + return useContext(BrandingContext); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d6f3cd8..061d4a6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,6 +4,9 @@ @layer base { :root { + --color-accent-primary: #dc2626; + --color-accent-success: #22c55e; + --color-accent-error: #dc2626; --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, 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: var(--color-accent-success); + border-color: color-mix(in srgb, var(--color-accent-success) 35%, transparent); + background-color: color-mix(in srgb, 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: var(--color-accent-error); + border-color: color-mix(in srgb, var(--color-accent-error) 40%, transparent); + background-color: color-mix(in srgb, 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, var(--color-accent-success) 80%, black); + border-color: color-mix(in srgb, var(--color-accent-success) 60%, white); + background-color: color-mix(in srgb, 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, var(--color-accent-error) 80%, black); + border-color: color-mix(in srgb, var(--color-accent-error) 60%, white); + background-color: color-mix(in srgb, var(--color-accent-error) 15%, white); } } diff --git a/frontend/src/pages/WelcomePage.tsx b/frontend/src/pages/WelcomePage.tsx index b046ffe..e9b2b5a 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 '../context/BrandingContext'; 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..f44e32b --- /dev/null +++ b/frontend/src/utils/color.ts @@ -0,0 +1,183 @@ +/** + * 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; +} + +function hexToRgb(hex: string): RGB { + const clean = hex.replace('#', ''); + 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 rgbToHex({ r, g, b }: RGB): string { + return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); +} + +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, mode: 'dark' | 'light') { + 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..260b05a 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: 'var(--color-accent-primary, #dc2626)', + secondary: 'var(--color-accent-primary, #dc2626)', + success: 'var(--color-accent-success, #22c55e)', + warning: 'var(--color-accent-primary, #dc2626)', + error: 'var(--color-accent-error, #dc2626)', }, }, }, From 1e044f9b92a1724326d67b2ab594fef37e24d3b8 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:54:28 +0100 Subject: [PATCH 02/11] fix: resolve CI lint and formatting failures - Run cargo fmt to wrap long env var lines in main.rs - Remove unused rgbToHex function and mode parameter in color.ts - Fix react-refresh/only-export-components by separating BrandingContext definition into branding-context.ts and useBranding hook into its own file Co-Authored-By: Claude Opus 4.6 --- backend/crates/atlas-api/src/main.rs | 16 +++++++++---- frontend/src/components/Layout.tsx | 2 +- frontend/src/context/BrandingContext.tsx | 29 +++++------------------- frontend/src/context/branding-context.ts | 15 ++++++++++++ frontend/src/hooks/useBranding.ts | 6 +++++ frontend/src/pages/WelcomePage.tsx | 2 +- frontend/src/utils/color.ts | 6 +---- 7 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 frontend/src/context/branding-context.ts create mode 100644 frontend/src/hooks/useBranding.ts diff --git a/backend/crates/atlas-api/src/main.rs b/backend/crates/atlas-api/src/main.rs index 09181f5..81cbcef 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -51,11 +51,19 @@ async fn main() -> Result<()> { let solc_path = std::env::var("SOLC_PATH").unwrap_or_else(|_| "solc".to_string()); let admin_api_key = std::env::var("ADMIN_API_KEY").ok(); let chain_name = std::env::var("CHAIN_NAME").unwrap_or_else(|_| "Atlas".to_string()); - let chain_logo_url = std::env::var("CHAIN_LOGO_URL").ok().filter(|s| !s.is_empty()); + let chain_logo_url = std::env::var("CHAIN_LOGO_URL") + .ok() + .filter(|s| !s.is_empty()); let accent_color = std::env::var("ACCENT_COLOR").ok().filter(|s| !s.is_empty()); - let background_color_dark = std::env::var("BACKGROUND_COLOR_DARK").ok().filter(|s| !s.is_empty()); - let background_color_light = std::env::var("BACKGROUND_COLOR_LIGHT").ok().filter(|s| !s.is_empty()); - let success_color = std::env::var("SUCCESS_COLOR").ok().filter(|s| !s.is_empty()); + let background_color_dark = std::env::var("BACKGROUND_COLOR_DARK") + .ok() + .filter(|s| !s.is_empty()); + let background_color_light = std::env::var("BACKGROUND_COLOR_LIGHT") + .ok() + .filter(|s| !s.is_empty()); + let success_color = std::env::var("SUCCESS_COLOR") + .ok() + .filter(|s| !s.is_empty()); let error_color = std::env::var("ERROR_COLOR").ok().filter(|s| !s.is_empty()); let host = std::env::var("API_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let port: u16 = std::env::var("API_PORT") diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 83a7d66..d87410f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -7,7 +7,7 @@ import SmoothCounter from './SmoothCounter'; import defaultLogoImg from '../assets/logo.png'; import { BlockStatsContext } from '../context/BlockStatsContext'; import { useTheme } from '../hooks/useTheme'; -import { useBranding } from '../context/BrandingContext'; +import { useBranding } from '../hooks/useBranding'; export default function Layout() { const location = useLocation(); diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index 4d298a2..dfeb5d9 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -1,24 +1,11 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useState, useContext, type ReactNode } from 'react'; import { getConfig, type BrandingConfig } from '../api/config'; import { deriveSurfaceShades, applyPalette } from '../utils/color'; import { ThemeContext } from './theme-context'; - -interface BrandingContextValue { - chainName: string; - logoUrl: string | null; - loaded: boolean; -} - -const defaults: BrandingContextValue = { - chainName: 'Atlas', - logoUrl: null, - loaded: false, -}; - -const BrandingContext = createContext(defaults); +import { BrandingContext, brandingDefaults } from './branding-context'; export function BrandingProvider({ children }: { children: ReactNode }) { - const [branding, setBranding] = useState(defaults); + const [branding, setBranding] = useState(brandingDefaults); const [config, setConfig] = useState(null); const themeCtx = useContext(ThemeContext); const theme = themeCtx?.theme ?? 'dark'; @@ -46,7 +33,7 @@ export function BrandingProvider({ children }: { children: ReactNode }) { } }) .catch(() => { - setBranding({ ...defaults, loaded: true }); + setBranding({ ...brandingDefaults, loaded: true }); }); }, []); @@ -72,10 +59,10 @@ export function BrandingProvider({ children }: { children: ReactNode }) { if (theme === 'dark' && config.background_color_dark) { const palette = deriveSurfaceShades(config.background_color_dark, 'dark'); - applyPalette(palette, 'dark'); + applyPalette(palette); } else if (theme === 'light' && config.background_color_light) { const palette = deriveSurfaceShades(config.background_color_light, 'light'); - applyPalette(palette, 'light'); + applyPalette(palette); } else { // Remove any inline overrides so the CSS defaults take effect const root = document.documentElement; @@ -96,7 +83,3 @@ export function BrandingProvider({ children }: { children: ReactNode }) { ); } - -export function useBranding() { - return useContext(BrandingContext); -} diff --git a/frontend/src/context/branding-context.ts b/frontend/src/context/branding-context.ts new file mode 100644 index 0000000..9b58c8a --- /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: 'Atlas', + 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/pages/WelcomePage.tsx b/frontend/src/pages/WelcomePage.tsx index e9b2b5a..78fa3b7 100644 --- a/frontend/src/pages/WelcomePage.tsx +++ b/frontend/src/pages/WelcomePage.tsx @@ -4,7 +4,7 @@ import useStats from '../hooks/useStats'; import { formatNumber } from '../utils'; import { useContext, useMemo } from 'react'; import { BlockStatsContext } from '../context/BlockStatsContext'; -import { useBranding } from '../context/BrandingContext'; +import { useBranding } from '../hooks/useBranding'; export default function WelcomePage() { const { totals, dailyTx, avgBlockTimeSec, loading } = useStats(); diff --git a/frontend/src/utils/color.ts b/frontend/src/utils/color.ts index f44e32b..bf79304 100644 --- a/frontend/src/utils/color.ts +++ b/frontend/src/utils/color.ts @@ -95,10 +95,6 @@ function rgbTriplet(rgb: RGB): string { return `${rgb.r} ${rgb.g} ${rgb.b}`; } -function rgbToHex({ r, g, b }: RGB): string { - return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); -} - function adjustLightness(hsl: HSL, delta: number): RGB { return hslToRgb({ ...hsl, l: Math.min(100, Math.max(0, hsl.l + delta)) }); } @@ -159,7 +155,7 @@ export function deriveSurfaceShades(baseHex: string, mode: 'dark' | 'light'): De /** * Apply a derived palette to the document root as CSS custom properties. */ -export function applyPalette(palette: DerivedPalette, mode: 'dark' | 'light') { +export function applyPalette(palette: DerivedPalette) { const root = document.documentElement; // For dark mode, set on :root directly. From 74515086ed689c1d1949d026ddf3829179815dd6 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:16:34 +0100 Subject: [PATCH 03/11] docs: add white-labeling documentation Explain how to customize chain name, logo, and color scheme via environment variables. Includes examples for blue, green, and minimal configurations. Co-Authored-By: Claude Opus 4.6 --- README.md | 3 + docs/WHITE_LABELING.md | 127 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 docs/WHITE_LABELING.md diff --git a/README.md b/README.md index 092c42f..1bd793f 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,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/docs/WHITE_LABELING.md b/docs/WHITE_LABELING.md new file mode 100644 index 0000000..bff664e --- /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, Atlas uses its default branding. + +## Configuration + +Add these variables to your `.env` file alongside `RPC_URL`: + +| Variable | Description | Default | +|----------|-------------|---------| +| `CHAIN_NAME` | Displayed in the navbar, page title, and welcome page | `Atlas` | +| `CHAIN_LOGO_URL` | URL or path to your logo (e.g. `/branding/logo.svg`) | Bundled Atlas 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 fall back to the Atlas 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: + +``` +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 Atlas 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. From 9574e84000ed7a4fd2a93a27387f9bb7776eda57 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:31:56 +0100 Subject: [PATCH 04/11] docs: fix branding references from Atlas to ev-node The default branding comes from ev-node, not Atlas. --- .env.example | 4 ++-- docs/WHITE_LABELING.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index eed29de..0115dcd 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,9 @@ FETCH_WORKERS=10 RPC_BATCH_SIZE=20 # Branding / White-label (all optional) -# If not set, the default Atlas branding is used. +# If not set, the default ev-node branding is used. CHAIN_NAME=Atlas -CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.png). Default: bundled Atlas logo +CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.png). Default: bundled ev-node logo ACCENT_COLOR=#dc2626 # Primary accent color (links, buttons, active states) BACKGROUND_COLOR_DARK=#050505 # Dark mode base background BACKGROUND_COLOR_LIGHT=#f4ede6 # Light mode base background diff --git a/docs/WHITE_LABELING.md b/docs/WHITE_LABELING.md index bff664e..735c5fd 100644 --- a/docs/WHITE_LABELING.md +++ b/docs/WHITE_LABELING.md @@ -2,7 +2,7 @@ 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, Atlas uses its default branding. +All branding is configured through environment variables. When none are set, the explorer uses the default ev-node branding. ## Configuration @@ -11,14 +11,14 @@ Add these variables to your `.env` file alongside `RPC_URL`: | Variable | Description | Default | |----------|-------------|---------| | `CHAIN_NAME` | Displayed in the navbar, page title, and welcome page | `Atlas` | -| `CHAIN_LOGO_URL` | URL or path to your logo (e.g. `/branding/logo.svg`) | Bundled Atlas logo | +| `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 fall back to the Atlas defaults shown above. +All variables are optional. Unset variables fall back to the default ev-node branding shown above. ## Custom Logo @@ -114,7 +114,7 @@ ERROR_COLOR=#dc2626 CHAIN_NAME=MyChain ``` -Everything else stays default Atlas branding. +Everything else stays default ev-node branding. ## How It Works From 2c07324cdfe092e9ba7eecc9024cd2bb7ea43b37 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:04:17 +0100 Subject: [PATCH 05/11] fix: handle empty CHAIN_NAME and validate hex color input - Treat empty/whitespace CHAIN_NAME as unset, falling back to "Atlas" - Validate hex input in hexToRgb: trim, support 3-char shorthand, reject malformed values to avoid NaN CSS variables --- backend/crates/atlas-api/src/main.rs | 5 ++++- frontend/src/utils/color.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/crates/atlas-api/src/main.rs b/backend/crates/atlas-api/src/main.rs index 81cbcef..2aa6956 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -50,7 +50,10 @@ async fn main() -> Result<()> { let rpc_url = std::env::var("RPC_URL").expect("RPC_URL must be set"); let solc_path = std::env::var("SOLC_PATH").unwrap_or_else(|_| "solc".to_string()); let admin_api_key = std::env::var("ADMIN_API_KEY").ok(); - let chain_name = std::env::var("CHAIN_NAME").unwrap_or_else(|_| "Atlas".to_string()); + let chain_name = std::env::var("CHAIN_NAME") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "Atlas".to_string()); let chain_logo_url = std::env::var("CHAIN_LOGO_URL") .ok() .filter(|s| !s.is_empty()); diff --git a/frontend/src/utils/color.ts b/frontend/src/utils/color.ts index bf79304..3c40cf3 100644 --- a/frontend/src/utils/color.ts +++ b/frontend/src/utils/color.ts @@ -32,7 +32,15 @@ interface DerivedPalette { } function hexToRgb(hex: string): RGB { - const clean = hex.replace('#', ''); + 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), From 9f60aee0ae5cdb774de4fd1ab558f10b48f03325 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:07:34 +0100 Subject: [PATCH 06/11] docs: clarify that Atlas is the ev-node default chain name Make the table header and intro consistent so operators understand that "Atlas" is the default ev-node branding, not a separate brand. --- docs/WHITE_LABELING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/WHITE_LABELING.md b/docs/WHITE_LABELING.md index 735c5fd..6f4388d 100644 --- a/docs/WHITE_LABELING.md +++ b/docs/WHITE_LABELING.md @@ -2,14 +2,14 @@ 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. +All branding is configured through environment variables. When none are set, the explorer uses the default ev-node branding (name "Atlas", red accent, dark/warm-beige backgrounds). ## Configuration Add these variables to your `.env` file alongside `RPC_URL`: -| Variable | Description | Default | -|----------|-------------|---------| +| Variable | Description | Default (ev-node) | +|----------|-------------|--------------------| | `CHAIN_NAME` | Displayed in the navbar, page title, and welcome page | `Atlas` | | `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` | @@ -18,7 +18,7 @@ Add these variables to your `.env` file alongside `RPC_URL`: | `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 fall back to the default ev-node branding shown above. +All variables are optional. Unset variables use the ev-node defaults shown above. ## Custom Logo From a812deaf0f43c75e3ef622de4e4c815d5b37ab43 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:30:05 +0100 Subject: [PATCH 07/11] fix: address CodeRabbit review comments - Use block body in forEach to avoid implicit return (Biome rule) - Add language tag to fenced code block in docs (MD040) - Update CLAUDE.md to reflect "Atlas" as CHAIN_NAME default --- CLAUDE.md | 4 ++-- docs/WHITE_LABELING.md | 2 +- frontend/src/context/BrandingContext.tsx | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b94f2c..c9cf773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ pub struct AppState { pub solc_path: String, pub admin_api_key: Option, pub chain_id: u64, // fetched from RPC once at startup via eth_chainId - pub chain_name: String, // from CHAIN_NAME env var, defaults to "Unknown" + pub chain_name: String, // from CHAIN_NAME env var, defaults to "Atlas" } ``` @@ -100,7 +100,7 @@ Key vars (see `.env.example` for full list): |---|---|---| | `DATABASE_URL` | all | required | | `RPC_URL` | indexer, api | required | -| `CHAIN_NAME` | api | `"Unknown"` | +| `CHAIN_NAME` | api | `"Atlas"` | | `DB_MAX_CONNECTIONS` | indexer | `20` | | `BATCH_SIZE` | indexer | `100` | | `FETCH_WORKERS` | indexer | `10` | diff --git a/docs/WHITE_LABELING.md b/docs/WHITE_LABELING.md index 6f4388d..7f0e2e1 100644 --- a/docs/WHITE_LABELING.md +++ b/docs/WHITE_LABELING.md @@ -24,7 +24,7 @@ All variables are optional. Unset variables use the ev-node defaults shown above 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 diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index dfeb5d9..44f280e 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -73,7 +73,9 @@ export function BrandingProvider({ children }: { children: ReactNode }) { '--color-text-secondary', '--color-text-muted', '--color-text-subtle', '--color-text-faint', ]; - vars.forEach(v => root.style.removeProperty(v)); + vars.forEach((v) => { + root.style.removeProperty(v); + }); } }, [config, theme]); From 881620187d06744d4dbe9c57ac492af54f95630c Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:32:50 +0100 Subject: [PATCH 08/11] fix: default CHAIN_NAME to "Unknown" instead of "Atlas" Atlas is the indexer name, not a chain name. Deployers should always set CHAIN_NAME to their chain's actual name. --- .env.example | 2 +- CLAUDE.md | 4 ++-- backend/crates/atlas-api/src/main.rs | 2 +- docker-compose.yml | 2 +- docs/WHITE_LABELING.md | 4 ++-- frontend/src/context/branding-context.ts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 0115dcd..e29e311 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ RPC_BATCH_SIZE=20 # Branding / White-label (all optional) # If not set, the default ev-node branding is used. -CHAIN_NAME=Atlas +CHAIN_NAME=MyChain CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.png). Default: bundled ev-node logo ACCENT_COLOR=#dc2626 # Primary accent color (links, buttons, active states) BACKGROUND_COLOR_DARK=#050505 # Dark mode base background diff --git a/CLAUDE.md b/CLAUDE.md index c9cf773..4b94f2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ pub struct AppState { pub solc_path: String, pub admin_api_key: Option, pub chain_id: u64, // fetched from RPC once at startup via eth_chainId - pub chain_name: String, // from CHAIN_NAME env var, defaults to "Atlas" + pub chain_name: String, // from CHAIN_NAME env var, defaults to "Unknown" } ``` @@ -100,7 +100,7 @@ Key vars (see `.env.example` for full list): |---|---|---| | `DATABASE_URL` | all | required | | `RPC_URL` | indexer, api | required | -| `CHAIN_NAME` | api | `"Atlas"` | +| `CHAIN_NAME` | api | `"Unknown"` | | `DB_MAX_CONNECTIONS` | indexer | `20` | | `BATCH_SIZE` | indexer | `100` | | `FETCH_WORKERS` | indexer | `10` | diff --git a/backend/crates/atlas-api/src/main.rs b/backend/crates/atlas-api/src/main.rs index 2aa6956..57f13cd 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -53,7 +53,7 @@ async fn main() -> Result<()> { let chain_name = std::env::var("CHAIN_NAME") .ok() .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "Atlas".to_string()); + .unwrap_or_else(|| "Unknown".to_string()); let chain_logo_url = std::env::var("CHAIN_LOGO_URL") .ok() .filter(|s| !s.is_empty()); diff --git a/docker-compose.yml b/docker-compose.yml index 4a94ed4..e1464a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,7 @@ services: API_HOST: 0.0.0.0 API_PORT: 3000 RUST_LOG: atlas_api=info,tower_http=info - CHAIN_NAME: ${CHAIN_NAME:-Atlas} + CHAIN_NAME: ${CHAIN_NAME:-Unknown} CHAIN_LOGO_URL: ${CHAIN_LOGO_URL:-} ACCENT_COLOR: ${ACCENT_COLOR:-} BACKGROUND_COLOR_DARK: ${BACKGROUND_COLOR_DARK:-} diff --git a/docs/WHITE_LABELING.md b/docs/WHITE_LABELING.md index 7f0e2e1..9edf813 100644 --- a/docs/WHITE_LABELING.md +++ b/docs/WHITE_LABELING.md @@ -2,7 +2,7 @@ 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 (name "Atlas", red accent, dark/warm-beige backgrounds). +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 @@ -10,7 +10,7 @@ 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 | `Atlas` | +| `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` | diff --git a/frontend/src/context/branding-context.ts b/frontend/src/context/branding-context.ts index 9b58c8a..d772081 100644 --- a/frontend/src/context/branding-context.ts +++ b/frontend/src/context/branding-context.ts @@ -7,7 +7,7 @@ export interface BrandingContextValue { } export const brandingDefaults: BrandingContextValue = { - chainName: 'Atlas', + chainName: 'Unknown', logoUrl: null, loaded: false, }; From 610df4a91f0d0bf25c73fb1866a181f255bb85be Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:35:39 +0100 Subject: [PATCH 09/11] fix: address review feedback for white-label branding - Fix Tailwind accent colors to use rgb(var(...) / ) pattern so opacity modifiers (e.g. accent-primary/40) work correctly - Store accent CSS vars as RGB triplets instead of hex values - Convert hex to RGB triplets in BrandingContext via hexToRgbTriplet helper - Fix symlink frontend/public/branding to use relative path ../../branding - Add unit tests for BrandingConfig serialization in config.rs - Extract env-parsing into testable parse_chain_name/parse_optional_env helpers - Add unit tests for env-parsing (unset, empty, whitespace, valid values) - Trim whitespace on all optional branding env vars before filtering empties - Quote hex color values in .env.example for cross-tool compatibility - Clean up CHAIN_LOGO_URL whitespace placeholder in .env.example --- .env.example | 13 +-- .../crates/atlas-api/src/handlers/config.rs | 48 ++++++++++ backend/crates/atlas-api/src/main.rs | 91 +++++++++++++++---- frontend/public/branding | 1 + frontend/src/context/BrandingContext.tsx | 8 +- frontend/src/index.css | 32 +++---- frontend/src/utils/color.ts | 5 + frontend/tailwind.config.js | 10 +- 8 files changed, 159 insertions(+), 49 deletions(-) create mode 120000 frontend/public/branding diff --git a/.env.example b/.env.example index e29e311..0cc9525 100644 --- a/.env.example +++ b/.env.example @@ -19,9 +19,10 @@ RPC_BATCH_SIZE=20 # Branding / White-label (all optional) # If not set, the default ev-node branding is used. CHAIN_NAME=MyChain -CHAIN_LOGO_URL= # URL or path to logo (e.g., /branding/logo.png). Default: bundled ev-node logo -ACCENT_COLOR=#dc2626 # Primary accent color (links, buttons, active states) -BACKGROUND_COLOR_DARK=#050505 # Dark mode base background -BACKGROUND_COLOR_LIGHT=#f4ede6 # Light mode base background -SUCCESS_COLOR=#22c55e # Success indicator color -ERROR_COLOR=#dc2626 # Error indicator color +# URL or path to logo (e.g., /branding/logo.png). Default: bundled ev-node logo +CHAIN_LOGO_URL= +ACCENT_COLOR="#dc2626" # Primary accent color (links, buttons, active states) +BACKGROUND_COLOR_DARK="#050505" # Dark mode base background +BACKGROUND_COLOR_LIGHT="#f4ede6" # Light mode base background +SUCCESS_COLOR="#22c55e" # Success indicator color +ERROR_COLOR="#dc2626" # Error indicator color diff --git a/backend/crates/atlas-api/src/handlers/config.rs b/backend/crates/atlas-api/src/handlers/config.rs index b13e156..7599cf0 100644 --- a/backend/crates/atlas-api/src/handlers/config.rs +++ b/backend/crates/atlas-api/src/handlers/config.rs @@ -34,3 +34,51 @@ pub async fn get_config(State(state): State>) -> Json Result<()> { let rpc_url = std::env::var("RPC_URL").expect("RPC_URL must be set"); let solc_path = std::env::var("SOLC_PATH").unwrap_or_else(|_| "solc".to_string()); let admin_api_key = std::env::var("ADMIN_API_KEY").ok(); - let chain_name = std::env::var("CHAIN_NAME") - .ok() - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "Unknown".to_string()); - let chain_logo_url = std::env::var("CHAIN_LOGO_URL") - .ok() - .filter(|s| !s.is_empty()); - let accent_color = std::env::var("ACCENT_COLOR").ok().filter(|s| !s.is_empty()); - let background_color_dark = std::env::var("BACKGROUND_COLOR_DARK") - .ok() - .filter(|s| !s.is_empty()); - let background_color_light = std::env::var("BACKGROUND_COLOR_LIGHT") - .ok() - .filter(|s| !s.is_empty()); - let success_color = std::env::var("SUCCESS_COLOR") - .ok() - .filter(|s| !s.is_empty()); - let error_color = std::env::var("ERROR_COLOR").ok().filter(|s| !s.is_empty()); + let chain_name = parse_chain_name(std::env::var("CHAIN_NAME").ok()); + let chain_logo_url = parse_optional_env(std::env::var("CHAIN_LOGO_URL").ok()); + let accent_color = parse_optional_env(std::env::var("ACCENT_COLOR").ok()); + let background_color_dark = parse_optional_env(std::env::var("BACKGROUND_COLOR_DARK").ok()); + let background_color_light = parse_optional_env(std::env::var("BACKGROUND_COLOR_LIGHT").ok()); + let success_color = parse_optional_env(std::env::var("SUCCESS_COLOR").ok()); + let error_color = parse_optional_env(std::env::var("ERROR_COLOR").ok()); let host = std::env::var("API_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let port: u16 = std::env::var("API_PORT") .unwrap_or_else(|_| "3000".to_string()) @@ -286,3 +275,69 @@ async fn main() -> Result<()> { Ok(()) } + +fn parse_chain_name(val: Option) -> String { + val.filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "Unknown".to_string()) +} + +fn parse_optional_env(val: Option) -> Option { + val.map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chain_name_defaults_to_unknown_when_unset() { + assert_eq!(parse_chain_name(None), "Unknown"); + } + + #[test] + fn chain_name_defaults_to_unknown_when_empty() { + assert_eq!(parse_chain_name(Some("".to_string())), "Unknown"); + } + + #[test] + fn chain_name_defaults_to_unknown_when_whitespace_only() { + assert_eq!(parse_chain_name(Some(" ".to_string())), "Unknown"); + } + + #[test] + fn chain_name_uses_provided_value() { + assert_eq!(parse_chain_name(Some("MyChain".to_string())), "MyChain"); + } + + #[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 optional_env_returns_value_as_is_when_no_whitespace() { + assert_eq!( + parse_optional_env(Some("#3b82f6".to_string())), + Some("#3b82f6".to_string()) + ); + } +} diff --git a/frontend/public/branding b/frontend/public/branding new file mode 120000 index 0000000..409bcd3 --- /dev/null +++ b/frontend/public/branding @@ -0,0 +1 @@ +../../branding \ No newline at end of file diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index 44f280e..f7ea843 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useContext, type ReactNode } from 'react'; import { getConfig, type BrandingConfig } from '../api/config'; -import { deriveSurfaceShades, applyPalette } from '../utils/color'; +import { deriveSurfaceShades, applyPalette, hexToRgbTriplet } from '../utils/color'; import { ThemeContext } from './theme-context'; import { BrandingContext, brandingDefaults } from './branding-context'; @@ -43,13 +43,13 @@ export function BrandingProvider({ children }: { children: ReactNode }) { const root = document.documentElement; if (config.accent_color) { - root.style.setProperty('--color-accent-primary', config.accent_color); + root.style.setProperty('--color-accent-primary', hexToRgbTriplet(config.accent_color)); } if (config.success_color) { - root.style.setProperty('--color-accent-success', config.success_color); + root.style.setProperty('--color-accent-success', hexToRgbTriplet(config.success_color)); } if (config.error_color) { - root.style.setProperty('--color-accent-error', config.error_color); + root.style.setProperty('--color-accent-error', hexToRgbTriplet(config.error_color)); } }, [config]); diff --git a/frontend/src/index.css b/frontend/src/index.css index 061d4a6..5861530 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,9 +4,9 @@ @layer base { :root { - --color-accent-primary: #dc2626; - --color-accent-success: #22c55e; - --color-accent-error: #dc2626; + --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; @@ -77,7 +77,7 @@ .btn { @apply px-3 py-1.5 font-medium rounded-lg transition-all duration-150; @apply focus-visible:outline-none focus-visible:ring-2; - --tw-ring-color: color-mix(in srgb, var(--color-accent-primary) 50%, transparent); + --tw-ring-color: color-mix(in srgb, rgb(var(--color-accent-primary)) 50%, transparent); } .btn-primary { @@ -125,15 +125,15 @@ } .status-badge--success { - color: var(--color-accent-success); - border-color: color-mix(in srgb, var(--color-accent-success) 35%, transparent); - background-color: color-mix(in srgb, var(--color-accent-success) 12%, transparent); + 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: var(--color-accent-error); - border-color: color-mix(in srgb, var(--color-accent-error) 40%, transparent); - background-color: color-mix(in srgb, var(--color-accent-error) 12%, transparent); + 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 { @@ -143,15 +143,15 @@ } [data-theme='light'] .status-badge--success { - color: color-mix(in srgb, var(--color-accent-success) 80%, black); - border-color: color-mix(in srgb, var(--color-accent-success) 60%, white); - background-color: color-mix(in srgb, var(--color-accent-success) 15%, white); + 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: color-mix(in srgb, var(--color-accent-error) 80%, black); - border-color: color-mix(in srgb, var(--color-accent-error) 60%, white); - background-color: color-mix(in srgb, var(--color-accent-error) 15%, white); + 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/utils/color.ts b/frontend/src/utils/color.ts index 3c40cf3..6c86384 100644 --- a/frontend/src/utils/color.ts +++ b/frontend/src/utils/color.ts @@ -31,6 +31,11 @@ interface DerivedPalette { 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 diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 260b05a..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: 'var(--color-accent-primary, #dc2626)', - secondary: 'var(--color-accent-primary, #dc2626)', - success: 'var(--color-accent-success, #22c55e)', - warning: 'var(--color-accent-primary, #dc2626)', - error: 'var(--color-accent-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 24fc0bc44ec3e481910727ad8a90cae97c276da8 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:37:41 +0100 Subject: [PATCH 10/11] fix: resolve CI failures in format check and frontend builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix cargo fmt: collapse parse_optional_env chain to single line - Remove frontend/public/branding symlink from git — it breaks CI and Docker builds where the target doesn't exist. Add to .gitignore so developers can create it locally per docs/WHITE_LABELING.md --- backend/crates/atlas-api/src/main.rs | 3 +-- frontend/.gitignore | 3 +++ frontend/public/branding | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) delete mode 120000 frontend/public/branding diff --git a/backend/crates/atlas-api/src/main.rs b/backend/crates/atlas-api/src/main.rs index b8be18e..b6a7bdb 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -282,8 +282,7 @@ fn parse_chain_name(val: Option) -> String { } fn parse_optional_env(val: Option) -> Option { - val.map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + val.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) } #[cfg(test)] 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/public/branding b/frontend/public/branding deleted file mode 120000 index 409bcd3..0000000 --- a/frontend/public/branding +++ /dev/null @@ -1 +0,0 @@ -../../branding \ No newline at end of file From 6c1de9776cdc5a571c7f67615535a19489e7f2fa Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:48:25 +0100 Subject: [PATCH 11/11] chore: track branding directory but gitignore its contents The branding/ folder is needed for the docker-compose volume mount but its contents are chain-specific and should not be committed. --- .gitignore | 4 ++++ branding/.gitkeep | 0 2 files changed, 4 insertions(+) create mode 100644 branding/.gitkeep diff --git a/.gitignore b/.gitignore index 6f05858..5dbf1fa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ logs/ frontend/node_modules/ frontend/dist/ frontend/.env.local + +# Branding assets (user-provided, not shared) +branding/* +!branding/.gitkeep diff --git a/branding/.gitkeep b/branding/.gitkeep new file mode 100644 index 0000000..e69de29