feat: per-chain branding — logo, favicon, brand name customizable

- Add ChainBranding interface with logoSvg, faviconSvg, brandName per chain
- Each chain (Lux, Zoo, Hanzo, SPC, Pars) gets distinct icon + brand name
- TopBar renders chain-specific logo and brand name from registry
- ChainSwitcher shows per-chain icons in dropdown items
- Dynamic favicon via useChainFavicon hook (swaps SVG favicon on mount)
- Page title uses chain branding via getNetworkTitle()
This commit is contained in:
2026-02-27 13:04:06 -08:00
parent 58334edcf6
commit d6fe894153
6 changed files with 104 additions and 12 deletions
+57
View File
@@ -1,6 +1,18 @@
// Multi-tenant chain registry. Maps hostnames to chain and network configuration.
// One Docker image serves all chains — the hostname determines the active chain.
export interface ChainBranding {
/** Display name in the header (e.g. "Lux Network", "Zoo Chain") */
readonly brandName: string;
/** SVG path data for the 18×18 logo icon (viewBox "0 0 50 50") */
readonly logoSvg: string;
/** SVG path data for the favicon circle icon (viewBox "0 0 512 512") */
readonly faviconSvg: string;
}
export interface ChainEntry {
readonly name: string;
readonly label: string;
@@ -9,6 +21,7 @@ export interface ChainEntry {
readonly hostnames: ReadonlyArray<string>;
readonly explorerUrl: string;
readonly apiUrl: string;
readonly branding: ChainBranding;
}
export interface NetworkEntry {
@@ -19,6 +32,43 @@ export interface NetworkEntry {
readonly explorerUrl: string;
}
// ── Per-chain branding definitions ──
/** Lux triangle — downward-pointing triangle */
const LUX_BRANDING: ChainBranding = {
brandName: 'Lux Network',
logoSvg: 'M25 46.65 50 3.35H0z',
faviconSvg: '<circle cx="256" cy="256" r="256"/><path fill="#fff" d="m256 410 179-308H77z"/>',
};
/** Zoo — hexagon (beehive / ecosystem) */
const ZOO_BRANDING: ChainBranding = {
brandName: 'Zoo Chain',
logoSvg: 'M25 3 46.65 14.5v21L25 47 3.35 35.5v-21z',
faviconSvg: '<circle cx="256" cy="256" r="256"/><path fill="#fff" d="M256 82l150 87v174l-150 87-150-87V169z"/>',
};
/** Hanzo — four-pointed star */
const HANZO_BRANDING: ChainBranding = {
brandName: 'Hanzo AI',
logoSvg: 'M25 3 31 19h16l-13 10 5 16-14-10-14 10 5-16L3 19h16z',
faviconSvg: '<circle cx="256" cy="256" r="256"/><path fill="#fff" d="M256 62l50 144h152l-123 90 47 144-126-91-126 91 47-144L54 206h152z"/>',
};
/** SPC — diamond / gem */
const SPC_BRANDING: ChainBranding = {
brandName: 'SPC Chain',
logoSvg: 'M25 3 47 25 25 47 3 25z',
faviconSvg: '<circle cx="256" cy="256" r="256"/><path fill="#fff" d="M256 62l194 194-194 194L62 256z"/>',
};
/** Pars — octagram / eight-pointed star */
const PARS_BRANDING: ChainBranding = {
brandName: 'Pars Network',
logoSvg: 'M25 3l6 15h16l-13 9 5 16-14-10-14 10 5-16-13-9h16z',
faviconSvg: '<circle cx="256" cy="256" r="256"/><path fill="#fff" d="M256 72l48 128h136l-110 80 42 130-116-84-116 84 42-130-110-80h136z"/>',
};
export const CHAINS: ReadonlyArray<ChainEntry> = [
{
name: 'C-Chain',
@@ -28,6 +78,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore.lux.network', 'localhost' ],
explorerUrl: 'https://explore.lux.network',
apiUrl: 'https://api-explore.lux.network',
branding: LUX_BRANDING,
},
{
name: 'Zoo',
@@ -37,6 +88,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore-zoo.lux.network' ],
explorerUrl: 'https://explore-zoo.lux.network',
apiUrl: 'https://api-explore-zoo.lux.network',
branding: ZOO_BRANDING,
},
{
name: 'Hanzo',
@@ -46,6 +98,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore-hanzo.lux.network' ],
explorerUrl: 'https://explore-hanzo.lux.network',
apiUrl: 'https://api-explore-hanzo.lux.network',
branding: HANZO_BRANDING,
},
{
name: 'SPC',
@@ -55,6 +108,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore-spc.lux.network' ],
explorerUrl: 'https://explore-spc.lux.network',
apiUrl: 'https://api-explore-spc.lux.network',
branding: SPC_BRANDING,
},
{
name: 'Pars',
@@ -64,6 +118,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore-pars.lux.network' ],
explorerUrl: 'https://explore-pars.lux.network',
apiUrl: 'https://api-explore-pars.lux.network',
branding: PARS_BRANDING,
},
// Testnet chains
{
@@ -74,6 +129,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore.lux-test.network' ],
explorerUrl: 'https://explore.lux-test.network',
apiUrl: 'https://api-explore.lux-test.network',
branding: LUX_BRANDING,
},
// Devnet chains
{
@@ -84,6 +140,7 @@ export const CHAINS: ReadonlyArray<ChainEntry> = [
hostnames: [ 'explore.lux-dev.network' ],
explorerUrl: 'https://explore.lux-dev.network',
apiUrl: 'https://api-explore.lux-dev.network',
branding: LUX_BRANDING,
},
];
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import { getCurrentChain } from 'configs/app/chainRegistry';
/**
* Dynamically sets the favicon SVG based on the current chain's branding.
* Generates an inline data: URI SVG favicon so each chain gets its own icon.
*/
export default function useChainFavicon(): void {
React.useEffect(() => {
const chain = getCurrentChain();
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">${ chain.branding.faviconSvg }</svg>`;
const dataUri = `data:image/svg+xml,${ encodeURIComponent(svg) }`;
// Update the SVG favicon link (browsers prefer SVG when available)
const svgLink = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/svg+xml"]');
if (svgLink) {
svgLink.href = dataUri;
}
}, []);
}
+6 -2
View File
@@ -1,6 +1,10 @@
import config from 'configs/app';
import { getCurrentChain } from 'configs/app/chainRegistry';
// TODO delete when page descriptions is refactored
export default function getNetworkTitle() {
return config.chain.name + (config.chain.shortName ? ` (${ config.chain.shortName })` : '') + ' Explorer';
export default function getNetworkTitle(): string {
// Prefer chain registry brand name (hostname-aware) over env var
const chain = getCurrentChain();
const name = chain.branding.brandName || config.chain.name;
return name + (config.chain.shortName ? ` (${ config.chain.shortName })` : '') + ' Explorer';
}
+2
View File
@@ -17,6 +17,7 @@ import { MarketplaceContextProvider } from 'lib/contexts/marketplace';
import { SettingsContextProvider } from 'lib/contexts/settings';
import { initGrowthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
import useChainFavicon from 'lib/hooks/useChainFavicon';
import { clientConfig as rollbarConfig, Provider as RollbarProvider } from 'lib/rollbar';
import { SocketProvider } from 'lib/socket/context';
import { Provider as ChakraProvider } from 'toolkit/chakra/provider';
@@ -61,6 +62,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const growthBook = initGrowthBook(pageProps.uuid);
useLoadFeatures(growthBook);
useChainFavicon();
const queryClient = useQueryClientConfig();
+15 -7
View File
@@ -48,6 +48,9 @@ const ChainSwitcher = () => {
onClick={ handleToggle }
flexShrink={ 0 }
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="14" height="14" style={{ flexShrink: 0 }}>
<path d={ current.branding.logoSvg } fill="currentColor"/>
</svg>
{ current.name }
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="12" height="12">
<path
@@ -84,13 +87,18 @@ const ChainSwitcher = () => {
transition="background 0.15s"
textDecoration="none"
>
<Flex direction="column">
<Text fontSize="sm" fontWeight={ isCurrent ? 600 : 400 } color="text.primary">
{ chain.name }
</Text>
<Text fontSize="xs" color="text.secondary">
{ chain.label }
</Text>
<Flex alignItems="center" gap={ 2 }>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="16" height="16" style={{ flexShrink: 0 }}>
<path d={ chain.branding.logoSvg } fill="currentColor"/>
</svg>
<Flex direction="column">
<Text fontSize="sm" fontWeight={ isCurrent ? 600 : 400 } color="text.primary">
{ chain.branding.brandName }
</Text>
<Text fontSize="xs" color="text.secondary">
{ chain.label }
</Text>
</Flex>
</Flex>
<Box
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.100' }}
+3 -3
View File
@@ -91,13 +91,13 @@ const TopBar = () => {
gap="6px"
flexShrink={ 0 }
mr={ 3 }
aria-label="Lux Network home"
aria-label={ `${ chain.branding.brandName } home` }
textDecoration="none"
_hover={{ textDecoration: 'none', opacity: 0.8 }}
transition="opacity 0.15s"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="18" height="18">
<polygon points="25,46.65 50,3.35 0,3.35" fill="currentColor"/>
<path d={ chain.branding.logoSvg } fill="currentColor"/>
</svg>
<chakra.span
fontWeight={ 700 }
@@ -106,7 +106,7 @@ const TopBar = () => {
whiteSpace="nowrap"
color="text.primary"
>
{ chain.name === 'C-Chain' ? 'Lux Network' : chain.name }
{ chain.branding.brandName }
</chakra.span>
</chakra.a>