Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ev-node branding is used.
CHAIN_NAME=Atlas
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions backend/crates/atlas-api/src/handlers/config.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub accent_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_color_dark: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_color_light: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub success_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_color: Option<String>,
}

/// 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<Arc<AppState>>) -> Json<BrandingConfig> {
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(),
})
}
1 change: 1 addition & 0 deletions backend/crates/atlas-api/src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
32 changes: 32 additions & 0 deletions backend/crates/atlas-api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ pub struct AppState {
pub rpc_url: String,
pub solc_path: String,
pub admin_api_key: Option<String>,
// White-label branding
pub chain_name: String,
pub chain_logo_url: Option<String>,
pub accent_color: Option<String>,
pub background_color_dark: Option<String>,
pub background_color_light: Option<String>,
pub success_color: Option<String>,
pub error_color: Option<String>,
}

#[tokio::main]
Expand All @@ -40,6 +48,21 @@ 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())
Expand All @@ -58,6 +81,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
Expand Down Expand Up @@ -209,6 +239,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(
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
127 changes: 127 additions & 0 deletions docs/WHITE_LABELING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# White Labeling

Atlas supports white-labeling so each L2 chain can customize the explorer's appearance — name, logo, and color scheme — without rebuilding the frontend.

All branding is configured through environment variables. When none are set, the explorer uses the default ev-node branding.

## 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 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 default ev-node branding 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 ev-node branding.

## How It Works

1. The backend reads branding env vars at startup and serves them via `GET /api/config`
2. The frontend fetches this config once on page load
3. CSS custom properties are set on the document root, overriding the defaults
4. Background surface shades are derived automatically using HSL color manipulation
5. The page title, navbar logo, and favicon are updated dynamically

No frontend rebuild is needed — just change the env vars and restart the API.
6 changes: 6 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
AddressesPage,
} from './pages';
import { ThemeProvider } from './context/ThemeContext';
import { BrandingProvider } from './context/BrandingContext';

export default function App() {
return (
<ThemeProvider>
<BrandingProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
Expand All @@ -43,6 +45,7 @@ export default function App() {
</Route>
</Routes>
</BrowserRouter>
</BrandingProvider>
</ThemeProvider>
);
}
16 changes: 16 additions & 0 deletions frontend/src/api/config.ts
Original file line number Diff line number Diff line change
@@ -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<BrandingConfig> {
const response = await client.get<BrandingConfig>('/config');
return response.data;
}
11 changes: 7 additions & 4 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '../hooks/useBranding';

export default function Layout() {
const location = useLocation();
Expand Down Expand Up @@ -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 (
<div className="min-h-screen flex flex-col">
Expand All @@ -112,8 +115,8 @@ export default function Layout() {
<div className="grid grid-cols-3 items-center h-16">
{/* Logo */}
<div className="flex md:justify-start justify-center">
<Link to="/" className="flex items-center" aria-label="Atlas Home">
<img src={logoImg} alt="Atlas" className="h-12 w-auto rounded-lg" />
<Link to="/" className="flex items-center" aria-label={`${chainName} Home`}>
<img src={logoSrc} alt={chainName} className="h-12 w-auto rounded-lg" />
</Link>
</div>

Expand Down Expand Up @@ -175,7 +178,7 @@ export default function Layout() {
</button>
<div className="flex items-center gap-3 text-sm text-gray-300">
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${recentlyUpdated ? 'bg-red-500 live-dot' : 'bg-gray-600'}`}
className={`inline-block w-2.5 h-2.5 rounded-full ${recentlyUpdated ? 'bg-accent-primary live-dot' : 'bg-gray-600'}`}
title={recentlyUpdated ? 'Live updates' : 'Idle'}
/>
<SmoothCounter value={displayedHeight} />
Expand Down
Loading