-
-
Total Blocks
-
{totals ? formatNumber(totals.blocksTotal) : (loading ? '…' : '—')}
-
-
-
Avg Block Time
-
- {headerAvgSec != null
- ? (headerAvgSec < 1 ? `${Math.round(headerAvgSec * 1000)} ms` : `${headerAvgSec.toFixed(1)} s`)
- : (avgBlockTimeSec != null
- ? (avgBlockTimeSec < 1 ? `${Math.round(avgBlockTimeSec * 1000)} ms` : `${avgBlockTimeSec.toFixed(1)} s`)
- : (loading ? '…' : '—'))}
-
-
-
-
Total Transactions
-
{totals ? formatNumber(totals.transactionsTotal) : (loading ? '…' : '—')}
-
-
-
Total Addresses
-
{totals ? formatNumber(totals.addressesTotal) : (loading ? '…' : '—')}
-
-
-
Transactions (24h)
-
{dailyTx != null ? formatNumber(dailyTx) : (loading ? '…' : '—')}
-
+
+
+

+
+
+
+
+
+ {stats.map((stat) => (
+
+ ))}
+
+
+
);
}
diff --git a/frontend/src/utils/color.test.ts b/frontend/src/utils/color.test.ts
new file mode 100644
index 0000000..87d3139
--- /dev/null
+++ b/frontend/src/utils/color.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from 'bun:test';
+import { deriveBrandTokens, deriveSurfaceShades, hexToRgbTriplet } from './color';
+
+describe('hexToRgbTriplet', () => {
+ test('converts hex into a CSS rgb triplet', () => {
+ expect(hexToRgbTriplet('#94EEFF')).toBe('148 238 255');
+ expect(hexToRgbTriplet('#000000')).toBe('0 0 0');
+ });
+});
+
+describe('deriveSurfaceShades', () => {
+ test('derives a readable light palette from a base hex color', () => {
+ const palette = deriveSurfaceShades('#F3F4F4', 'light');
+
+ expect(palette.bodyBg).toBe('#F3F4F4');
+ expect(palette.bodyText).toBe('#1f1f1f');
+ expect(palette.textPrimary).toBe('31 31 31');
+ expect(palette.surface900).not.toBe('');
+ });
+
+ test('derives a readable dark palette from a base hex color', () => {
+ const palette = deriveSurfaceShades('#090B0C', 'dark');
+
+ expect(palette.bodyBg).toBe('#090B0C');
+ expect(palette.bodyText).toBe('#f8fafc');
+ expect(palette.textPrimary).toBe('248 250 252');
+ expect(palette.surface900).not.toBe('');
+ });
+});
+
+describe('deriveBrandTokens', () => {
+ test('derives motif colors and gradients from light branding inputs', () => {
+ const tokens = deriveBrandTokens('#ECF3EF', '#F1BE5A', 'light');
+
+ expect(tokens.brandLemon).not.toBe('');
+ expect(tokens.brandLemon).not.toBe(tokens.brandLavender);
+ expect(tokens.brandAqua).not.toBe('');
+ expect(tokens.pageGradient).toContain('linear-gradient(180deg');
+ expect(tokens.spectralGradient).toContain('linear-gradient');
+ });
+
+ test('derives motif colors and gradients from dark branding inputs', () => {
+ const tokens = deriveBrandTokens('#245636', '#F1BE5A', 'dark');
+
+ expect(tokens.brandLavender).not.toBe(tokens.brandLemon);
+ expect(tokens.pageGradient).toContain('linear-gradient(180deg');
+ expect(tokens.spectralGradient).toContain('rgb(');
+ });
+});
diff --git a/frontend/src/utils/color.ts b/frontend/src/utils/color.ts
index 6c86384..f7244f9 100644
--- a/frontend/src/utils/color.ts
+++ b/frontend/src/utils/color.ts
@@ -31,6 +31,15 @@ interface DerivedPalette {
textFaint: string;
}
+interface DerivedBrandTokens {
+ brandAqua: string;
+ brandLavender: string;
+ brandLemon: string;
+ brandBlush: string;
+ pageGradient: string;
+ spectralGradient: string;
+}
+
export function hexToRgbTriplet(hex: string): string {
const { r, g, b } = hexToRgb(hex);
return `${r} ${g} ${b}`;
@@ -108,26 +117,66 @@ function rgbTriplet(rgb: RGB): string {
return `${rgb.r} ${rgb.g} ${rgb.b}`;
}
+function rgbToHex({ r, g, b }: RGB): string {
+ return `#${[r, g, b]
+ .map((value) => value.toString(16).padStart(2, "0"))
+ .join("")}`;
+}
+
+function mixRgb(a: RGB, b: RGB, weightToB: number): RGB {
+ const clamped = Math.max(0, Math.min(1, weightToB));
+ const weightToA = 1 - clamped;
+ return {
+ r: Math.round(a.r * weightToA + b.r * clamped),
+ g: Math.round(a.g * weightToA + b.g * clamped),
+ b: Math.round(a.b * weightToA + b.b * clamped),
+ };
+}
+
function adjustLightness(hsl: HSL, delta: number): RGB {
return hslToRgb({ ...hsl, l: Math.min(100, Math.max(0, hsl.l + delta)) });
}
+function shiftLightness(rgb: RGB, delta: number): RGB {
+ return adjustLightness(rgbToHsl(rgb), 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 {
+export function deriveSurfaceShades(baseHex: string, mode: 'dark' | 'light', accentHex?: string): 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);
+ let surface900 = adjustLightness(baseHsl, dir * 1);
+ let surface800 = adjustLightness(baseHsl, dir * 3);
+ let surface700 = adjustLightness(baseHsl, dir * 6);
+ let surface600 = adjustLightness(baseHsl, dir * 11);
+ let surface500 = adjustLightness(baseHsl, dir * 17);
+ let border = adjustLightness(baseHsl, dir * 14);
+
+ // Tint surfaces with accent to avoid icy/gray look
+ if (accentHex) {
+ const accent = hexToRgb(accentHex);
+ if (mode === 'light') {
+ surface900 = mixRgb(surface900, accent, 0.09);
+ surface800 = mixRgb(surface800, accent, 0.13);
+ surface700 = mixRgb(surface700, accent, 0.16);
+ surface600 = mixRgb(surface600, accent, 0.18);
+ surface500 = mixRgb(surface500, accent, 0.20);
+ border = mixRgb(border, accent, 0.22);
+ } else {
+ surface900 = mixRgb(surface900, accent, 0.06);
+ surface800 = mixRgb(surface800, accent, 0.09);
+ surface700 = mixRgb(surface700, accent, 0.11);
+ surface600 = mixRgb(surface600, accent, 0.13);
+ surface500 = mixRgb(surface500, accent, 0.15);
+ border = mixRgb(border, accent, 0.16);
+ }
+ }
// Text colors: neutral grays with good contrast
if (mode === 'dark') {
@@ -190,3 +239,72 @@ export function applyPalette(palette: DerivedPalette) {
setVar('--color-text-subtle', palette.textSubtle);
setVar('--color-text-faint', palette.textFaint);
}
+
+export function deriveBrandTokens(
+ backgroundHex: string,
+ accentHex: string,
+ mode: 'dark' | 'light',
+): DerivedBrandTokens {
+ const background = hexToRgb(backgroundHex);
+ const accent = hexToRgb(accentHex);
+ const white = hexToRgb('#ffffff');
+ const black = hexToRgb('#000000');
+
+ const brandAqua = mode === 'dark'
+ ? shiftLightness(mixRgb(background, accent, 0.48), 12)
+ : shiftLightness(mixRgb(background, accent, 0.52), -2);
+ const brandLavender = mode === 'dark'
+ ? shiftLightness(mixRgb(background, accent, 0.26), 8)
+ : shiftLightness(mixRgb(background, accent, 0.22), -4);
+ const brandLemon = mode === 'dark'
+ ? shiftLightness(mixRgb(accent, white, 0.12), 6)
+ : shiftLightness(mixRgb(accent, white, 0.28), 2);
+ const brandBlush = mode === 'dark'
+ ? shiftLightness(mixRgb(accent, background, 0.46), 10)
+ : shiftLightness(mixRgb(background, accent, 0.18), 6);
+
+ const pageStart = mode === 'dark'
+ ? rgbToHex(shiftLightness(background, -2))
+ : backgroundHex;
+ const pageEnd = mode === 'dark'
+ ? rgbToHex(shiftLightness(background, 4))
+ : rgbToHex(shiftLightness(background, -4));
+ const pageGradient = mode === 'dark'
+ ? `radial-gradient(circle at 18% 85%, rgb(${rgbTriplet(brandLavender)} / 0.26), transparent 30%), radial-gradient(circle at 82% 20%, rgb(${rgbTriplet(brandLemon)} / 0.20), transparent 26%), radial-gradient(circle at 30% 14%, rgb(${rgbTriplet(brandAqua)} / 0.28), transparent 28%), linear-gradient(180deg, ${pageStart} 0%, ${pageEnd} 100%)`
+ : `radial-gradient(circle at 8% 78%, rgb(${rgbTriplet(brandLavender)} / 0.28), transparent 28%), radial-gradient(circle at 80% 18%, rgb(${rgbTriplet(brandLemon)} / 0.24), transparent 24%), radial-gradient(circle at 32% 18%, rgb(${rgbTriplet(brandAqua)} / 0.5), transparent 30%), linear-gradient(180deg, ${pageStart} 0%, ${pageEnd} 100%)`;
+ const spectralGradient = `linear-gradient(120deg, rgb(${rgbTriplet(brandAqua)} / 0.95) 0%, rgb(${rgbTriplet(brandAqua)} / 0.72) 25%, rgb(${rgbTriplet(brandLemon)} / 0.88) 55%, rgb(${rgbTriplet(brandBlush)} / 0.72) 82%, rgb(${rgbTriplet(brandLavender)} / 0.84) 100%)`;
+
+ return {
+ brandAqua: rgbTriplet(brandAqua),
+ brandLavender: rgbTriplet(brandLavender),
+ brandLemon: rgbTriplet(brandLemon),
+ brandBlush: rgbTriplet(brandBlush),
+ pageGradient,
+ spectralGradient,
+ };
+}
+
+export function applyBrandTokens(tokens: DerivedBrandTokens) {
+ const root = document.documentElement;
+
+ root.style.setProperty('--color-brand-aqua', tokens.brandAqua);
+ root.style.setProperty('--color-brand-lavender', tokens.brandLavender);
+ root.style.setProperty('--color-brand-lemon', tokens.brandLemon);
+ root.style.setProperty('--color-brand-blush', tokens.brandBlush);
+ root.style.setProperty('--page-gradient', tokens.pageGradient);
+ root.style.setProperty('--spectral-gradient', tokens.spectralGradient);
+}
+
+export function clearBrandTokens() {
+ const root = document.documentElement;
+ const vars = [
+ '--color-brand-aqua',
+ '--color-brand-lavender',
+ '--color-brand-lemon',
+ '--color-brand-blush',
+ '--page-gradient',
+ '--spectral-gradient',
+ ];
+
+ vars.forEach((cssVar) => root.style.removeProperty(cssVar));
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index c70c67a..a46b5cd 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -7,10 +7,18 @@ export default {
theme: {
extend: {
fontFamily: {
- mono: ['JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'monospace'],
+ mono: ['JetBrains Mono', 'Geist Mono', 'SFMono-Regular', 'Menlo', 'monospace'],
sans: ['Inter', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
+ display: ['Inter', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
+ brandmono: ['Geist Mono', 'JetBrains Mono', 'monospace'],
},
colors: {
+ brand: {
+ aqua: 'rgb(var(--color-brand-aqua) /
)',
+ lavender: 'rgb(var(--color-brand-lavender) / )',
+ lemon: 'rgb(var(--color-brand-lemon) / )',
+ blush: 'rgb(var(--color-brand-blush) / )',
+ },
dark: {
900: 'rgb(var(--color-surface-900) / )',
800: 'rgb(var(--color-surface-800) / )',
From 9350f3c57680f7eb4693ecb603c4ce9f4ff36dee Mon Sep 17 00:00:00 2001
From: pthmas <9058370+pthmas@users.noreply.github.com>
Date: Mon, 20 Apr 2026 18:47:05 +0200
Subject: [PATCH 2/3] Fix CI and make Docker stack portable
---
.env.example | 7 +++++++
.github/workflows/ci.yml | 18 +++++++++++++-----
.../src/api/handlers/contracts.rs | 19 +++++++++++--------
docker-compose.yml | 3 ---
frontend/src/hooks/useChartColors.ts | 4 +---
frontend/src/pages/BlocksPage.tsx | 15 ++++++++++++++-
frontend/src/utils/color.ts | 1 -
7 files changed, 46 insertions(+), 21 deletions(-)
diff --git a/.env.example b/.env.example
index f5c3dea..e42af6f 100644
--- a/.env.example
+++ b/.env.example
@@ -63,6 +63,13 @@ ENABLE_DA_TRACKING=false
# FAUCET_AMOUNT=0.01
# FAUCET_COOLDOWN_MINUTES=30
+# Optional: force Docker to emulate/build a specific architecture.
+# Leave unset for native host builds.
+# Common values:
+# DOCKER_DEFAULT_PLATFORM=linux/amd64
+# DOCKER_DEFAULT_PLATFORM=linux/arm64
+# DOCKER_DEFAULT_PLATFORM=linux/arm64/v8
+
# Optional snapshot feature (daily pg_dump backups)
# SNAPSHOT_ENABLED=false
# SNAPSHOT_TIME=03:00 # UTC time (HH:MM) to run daily pg_dump
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07a31a6..5c2a749 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,8 +22,9 @@ jobs:
uses: actions/checkout@v4
- name: Setup Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
with:
+ toolchain: stable
components: rustfmt
- name: Format
@@ -40,8 +41,9 @@ jobs:
uses: actions/checkout@v4
- name: Setup Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
with:
+ toolchain: stable
components: clippy
- name: Cache Cargo
@@ -63,7 +65,9 @@ jobs:
uses: actions/checkout@v4
- name: Setup Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: stable
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
@@ -102,7 +106,9 @@ jobs:
uses: actions/checkout@v4
- name: Setup Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: stable
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
@@ -194,7 +200,9 @@ jobs:
fetch-depth: 0
- name: Setup Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: stable
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
diff --git a/backend/crates/atlas-server/src/api/handlers/contracts.rs b/backend/crates/atlas-server/src/api/handlers/contracts.rs
index 1493791..3da0dd3 100644
--- a/backend/crates/atlas-server/src/api/handlers/contracts.rs
+++ b/backend/crates/atlas-server/src/api/handlers/contracts.rs
@@ -399,13 +399,14 @@ async fn get_solc_binary(version: &str, cache_dir: &str) -> Result Result<&'static str, AtlasError> {
match (os, arch) {
("linux", "x86_64") => Ok("linux-amd64"),
+ ("linux", "aarch64") => Ok("linux-arm64"),
// Solidity's official static macOS binaries are currently published under
// macosx-amd64. Apple Silicon can execute them natively via Rosetta.
("macos", "x86_64") | ("macos", "aarch64") => Ok("macosx-amd64"),
_ => Err(AtlasError::Verification(format!(
"unsupported platform for native solc download: {os}/{arch}. \
- Official Solidity static binaries are currently available for linux/x86_64 \
- and macOS. For Docker on Apple Silicon, run atlas-server as linux/amd64."
+ Official Solidity static binaries are currently available for \
+ linux/x86_64, linux/aarch64, and macOS."
))),
}
}
@@ -1010,17 +1011,19 @@ mod tests {
}
#[test]
- fn solc_binary_target_supports_macos_arm64_via_rosetta() {
+ fn solc_binary_target_supports_linux_arm64() {
assert_eq!(
- solc_binary_target("macos", "aarch64").unwrap(),
- "macosx-amd64"
+ solc_binary_target("linux", "aarch64").unwrap(),
+ "linux-arm64"
);
}
#[test]
- fn solc_binary_target_rejects_linux_arm64() {
- let err = solc_binary_target("linux", "aarch64").unwrap_err();
- assert!(matches!(err, AtlasError::Verification(_)));
+ fn solc_binary_target_supports_macos_arm64_via_rosetta() {
+ assert_eq!(
+ solc_binary_target("macos", "aarch64").unwrap(),
+ "macosx-amd64"
+ );
}
#[test]
diff --git a/docker-compose.yml b/docker-compose.yml
index 905a992..4a15c8e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,9 +16,6 @@ services:
retries: 5
atlas-server:
- # Contract verification downloads Solidity's official linux-amd64 binary.
- # Keep atlas-server on amd64 locally so verification works on Apple Silicon.
- platform: linux/amd64
build:
context: ./backend
dockerfile: Dockerfile
diff --git a/frontend/src/hooks/useChartColors.ts b/frontend/src/hooks/useChartColors.ts
index b541882..6f69622 100644
--- a/frontend/src/hooks/useChartColors.ts
+++ b/frontend/src/hooks/useChartColors.ts
@@ -2,8 +2,6 @@ import { useContext, useMemo } from 'react';
import { BrandingContext } from '../context/branding-context';
import { ThemeContext } from '../context/theme-context';
-const DEFAULT_ACCENT = '#000000';
-
function cssVar(name: string): string {
return `rgb(${getComputedStyle(document.documentElement).getPropertyValue(name).trim()})`;
}
@@ -14,7 +12,7 @@ export function useChartColors() {
const theme = themeCtx?.theme ?? 'dark';
return useMemo(() => ({
- accent: accentHex ?? DEFAULT_ACCENT,
+ accent: accentHex ?? cssVar('--color-accent-primary'),
grid: cssVar('--color-surface-600'),
axisText: cssVar('--color-text-subtle'),
tooltipBg: cssVar('--color-surface-800'),
diff --git a/frontend/src/pages/BlocksPage.tsx b/frontend/src/pages/BlocksPage.tsx
index 7d8192d..7dfc753 100644
--- a/frontend/src/pages/BlocksPage.tsx
+++ b/frontend/src/pages/BlocksPage.tsx
@@ -40,6 +40,7 @@ export default function BlocksPage() {
const ssePrependRafRef = useRef(null);
const pendingSseBlocksRef = useRef([]);
const sseFilterRafRef = useRef(null);
+ const freshBlocksResetRafRef = useRef(null);
const [freshBlocks, setFreshBlocks] = useState>(new Set());
const freshBlockTimeoutsRef = useRef