From 93025762016a46a9f1fefe30add93245b2141415 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 1/3] 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 3bd5818..811f717 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 3e471f0..676112e 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -19,6 +19,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] @@ -40,6 +48,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()) @@ -58,6 +73,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, }); // Build router @@ -209,6 +231,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 13901ed..ec3f14b 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 d173d92..a15790f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,6 +9,12 @@ server { try_files $uri $uri/ /index.html; } + # 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 39642e1..fbedf52 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,9 +3,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import SearchBar from './SearchBar'; import useLatestBlockHeight from '../hooks/useLatestBlockHeight'; 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(); @@ -103,6 +104,8 @@ export default function Layout() { }`; const { theme, toggleTheme } = useTheme(); const isDark = theme === 'dark'; + const { chainName, logoUrl } = useBranding(); + const logoSrc = logoUrl || defaultLogoImg; return (
@@ -112,8 +115,8 @@ export default function Layout() {
{/* Logo */}
- - Atlas + + {chainName}
@@ -175,7 +178,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 a27bebcd5f8af782a1a744fa7694aebd1effafd7 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 2/3] 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 676112e..a7d9335 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -49,11 +49,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 fbedf52..6dc083a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -6,7 +6,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 777eb7cd481c5428c3d181de951be998ed75f744 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 3/3] 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.