-
Notifications
You must be signed in to change notification settings - Fork 0
feat: white-label branding support #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9302576
a27bebc
777eb7c
bc045f9
fde4830
318f121
451a21d
3b2e1b5
6090c62
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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=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 | ||||||||||||||||||||||
|
Comment on lines
+23
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In Dotenvy,
So, to safely store hex colors (and avoid any “inline comment” ambiguity across tools), put them in quotes: PRIMARY_COLOR="#ff00aa"
ACCENT_COLOR='#09f'If you leave it unquoted, it generally works as long as you don’t introduce a comment-style Citations:
🌐 Web query:
💡 Result: In Docker Compose
So a hex color is safe as: COLOR=#ff00aaBut if you add an inline comment, you must quote (or ensure there’s no space before COLOR="#ff00aa" # this is fine
# or
COLOR=#ff00aa#not-a-commentAlso note: if you put a hex color directly in environment:
COLOR: "#ff00aa"[1] Sources: Citations: 🌐 Web query:
💡 Result: Core rule:
|
||||||||||||||||||||||
| 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 | |
| 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 |
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 23-23: [UnorderedKey] The ACCENT_COLOR key should go before the CHAIN_LOGO_URL key
(UnorderedKey)
[warning] 23-23: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
[warning] 24-24: [UnorderedKey] The BACKGROUND_COLOR_DARK key should go before the CHAIN_LOGO_URL key
(UnorderedKey)
[warning] 24-24: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
[warning] 25-25: [UnorderedKey] The BACKGROUND_COLOR_LIGHT key should go before the CHAIN_LOGO_URL key
(UnorderedKey)
[warning] 25-25: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
[warning] 26-26: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
[warning] 27-27: [UnorderedKey] The ERROR_COLOR key should go before the SUCCESS_COLOR key
(UnorderedKey)
[warning] 27-27: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.env.example around lines 23 - 27, Update the .env.example so the hex color
values are quoted to avoid parser/linter issues: wrap the values for
ACCENT_COLOR, BACKGROUND_COLOR_DARK, BACKGROUND_COLOR_LIGHT, SUCCESS_COLOR, and
ERROR_COLOR in quotes (e.g., " #... " or ' #... ') while keeping the existing
inline comments intact; this ensures cross-tool compatibility for dotenv parsers
and linters without changing the variable names or comments.
| 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(), | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -40,6 +48,24 @@ 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") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .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()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+51
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add unit tests for the new env-parsing logic in this file. Branding parsing behavior (unset/empty/whitespace handling and defaults) is new logic and should be covered by a local As per coding guidelines: "Add unit tests for new logic in a 🤖 Prompt for AI Agents
Comment on lines
+55
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trim optional branding env vars before filtering empties.
Proposed fix+ let optional_env = |key: &str| {
+ std::env::var(key)
+ .ok()
+ .map(|s| s.trim().to_string())
+ .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 error_color = std::env::var("ERROR_COLOR").ok().filter(|s| !s.is_empty());
+ let chain_logo_url = optional_env("CHAIN_LOGO_URL");
+ let accent_color = optional_env("ACCENT_COLOR");
+ let background_color_dark = optional_env("BACKGROUND_COLOR_DARK");
+ let background_color_light = optional_env("BACKGROUND_COLOR_LIGHT");
+ let success_color = optional_env("SUCCESS_COLOR");
+ let error_color = optional_env("ERROR_COLOR");📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 +84,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 +242,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( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 (red accent, dark/warm-beige backgrounds). `CHAIN_NAME` defaults to "Unknown" — deployers should always set it. | ||
|
|
||
| ## Configuration | ||
|
|
||
| Add these variables to your `.env` file alongside `RPC_URL`: | ||
|
|
||
| | Variable | Description | Default (ev-node) | | ||
| |----------|-------------|--------------------| | ||
| | `CHAIN_NAME` | Displayed in the navbar, page title, and welcome page | `Unknown` | | ||
| | `CHAIN_LOGO_URL` | URL or path to your logo (e.g. `/branding/logo.svg`) | Bundled ev-node logo | | ||
| | `ACCENT_COLOR` | Primary accent hex for links, buttons, active states | `#dc2626` | | ||
| | `BACKGROUND_COLOR_DARK` | Dark mode base background hex | `#050505` | | ||
| | `BACKGROUND_COLOR_LIGHT` | Light mode base background hex | `#f4ede6` | | ||
| | `SUCCESS_COLOR` | Success indicator hex (e.g. confirmed badges) | `#22c55e` | | ||
| | `ERROR_COLOR` | Error indicator hex (e.g. failed badges) | `#dc2626` | | ||
|
|
||
| All variables are optional. Unset variables use the ev-node defaults shown above. | ||
|
|
||
| ## Custom Logo | ||
|
|
||
| To use a custom logo, place your image file in a `branding/` directory at the project root and set `CHAIN_LOGO_URL` to its path: | ||
|
|
||
| ```text | ||
| atlas/ | ||
| ├── branding/ | ||
| │ └── logo.svg # Your custom logo | ||
| ├── .env | ||
| ├── docker-compose.yml | ||
| └── ... | ||
| ``` | ||
|
|
||
| ```env | ||
| CHAIN_LOGO_URL=/branding/logo.svg | ||
| ``` | ||
|
|
||
| The logo appears in the navbar, the welcome page, and as the browser favicon. | ||
|
|
||
| ### Docker | ||
|
|
||
| In Docker, the `branding/` directory is mounted into the frontend container as a read-only volume. This is configured automatically in `docker-compose.yml`: | ||
|
|
||
| ```yaml | ||
| atlas-frontend: | ||
| volumes: | ||
| - ${BRANDING_DIR:-./branding}:/usr/share/nginx/html/branding:ro | ||
| ``` | ||
|
|
||
| To use a different directory, set `BRANDING_DIR` in your `.env`: | ||
|
|
||
| ```env | ||
| BRANDING_DIR=/path/to/my/assets | ||
| ``` | ||
|
|
||
| ### Local Development | ||
|
|
||
| For `bun run dev`, create a symlink so Vite's dev server can serve the branding files: | ||
|
|
||
| ```bash | ||
| cd frontend/public | ||
| ln -s ../../branding branding | ||
| ``` | ||
|
|
||
| ## Color System | ||
|
|
||
| ### Accent Color | ||
|
|
||
| `ACCENT_COLOR` sets the primary interactive color used for links, buttons, focus rings, and active indicators throughout the UI. | ||
|
|
||
| ### Background Colors | ||
|
|
||
| Each theme (dark and light) takes a single base color. The frontend automatically derives a full surface palette from it: | ||
|
|
||
| - **5 surface shades** (from darkest to lightest for dark mode, reversed for light mode) | ||
| - **Border color** | ||
| - **Text hierarchy** (primary, secondary, muted, subtle, faint) | ||
|
|
||
| This means you only need to set one color per theme to get a cohesive palette. | ||
|
|
||
| ### Success and Error Colors | ||
|
|
||
| `SUCCESS_COLOR` and `ERROR_COLOR` control status badges and indicators. For example, "Success" transaction badges use the success color, and "Failed" badges use the error color. | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Blue theme | ||
|
|
||
| ```env | ||
| CHAIN_NAME=MegaChain | ||
| CHAIN_LOGO_URL=/branding/logo.png | ||
| ACCENT_COLOR=#3b82f6 | ||
| BACKGROUND_COLOR_DARK=#0a0a1a | ||
| BACKGROUND_COLOR_LIGHT=#e6f0f4 | ||
| ``` | ||
|
|
||
| ### Green theme (Eden) | ||
|
|
||
| ```env | ||
| CHAIN_NAME=Eden | ||
| CHAIN_LOGO_URL=/branding/logo.svg | ||
| ACCENT_COLOR=#4ade80 | ||
| BACKGROUND_COLOR_DARK=#0a1f0a | ||
| BACKGROUND_COLOR_LIGHT=#e8f5e8 | ||
| SUCCESS_COLOR=#22c55e | ||
| ERROR_COLOR=#dc2626 | ||
| ``` | ||
|
|
||
| ### Minimal — just rename | ||
|
|
||
| ```env | ||
| CHAIN_NAME=MyChain | ||
| ``` | ||
|
|
||
| Everything else stays default ev-node branding. | ||
|
|
||
| ## How It Works | ||
|
|
||
| 1. The backend reads branding env vars at startup and serves them via `GET /api/config` | ||
| 2. The frontend fetches this config once on page load | ||
| 3. CSS custom properties are set on the document root, overriding the defaults | ||
| 4. Background surface shades are derived automatically using HSL color manipulation | ||
| 5. The page title, navbar logo, and favicon are updated dynamically | ||
|
|
||
| No frontend rebuild is needed — just change the env vars and restart the API. |
| 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid whitespace placeholder values for
CHAIN_LOGO_URL.This line currently assigns spaces rather than a clean empty value. Use
CHAIN_LOGO_URL=and keep the description in a separate comment.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 22-22: [SpaceCharacter] The line has spaces around equal sign
(SpaceCharacter)
[warning] 22-22: [UnorderedKey] The CHAIN_LOGO_URL key should go before the CHAIN_NAME key
(UnorderedKey)
[warning] 22-22: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
🤖 Prompt for AI Agents