Skip to content

Commit 358afa8

Browse files
whoabuddyclaude
andauthored
feat(dashboard): make branding configurable via env vars (#10)
* feat(dashboard): add BrandConfig type and getBrandConfig env reader Phase 1 of configurable branding quest. Defines BrandConfig interface with all brand values, AIBTC defaults, and getBrandConfig() that reads BRAND_* environment variables with automatic accent color derivation and CDN URL composition. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(dashboard): generate brand CSS from BrandConfig Phase 2 of configurable branding quest. Replaces static brandCss with buildBrandCss(config) that generates CSS from BrandConfig values. Updates htmlDocument and header to accept brand config via LayoutOptions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(dashboard): thread BrandConfig through router and pages Phase 3 of configurable branding quest. Resolves brand config from env vars once per request via middleware. All page functions accept BrandConfig and forward it to layout components. Changes: - Add middleware to resolve brand config from env on each request - Update Hono generic to include Variables with brand config - Thread brand config through all page functions (login, overview, app-detail) - Update page functions to pass brand to layout components - Type helper functions to accept contexts with Variables Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(dashboard): add brand env vars to wrangler and brand config tests Add BRAND_NAME, BRAND_ACCENT, and BRAND_CDN_URL environment variables to staging and production wrangler.jsonc environments with AIBTC defaults. Add comprehensive tests for getBrandConfig() covering defaults, env overrides, CDN URL derivation, accent color derivation, and individual URL overrides. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(dashboard): address PR review feedback on brand config - Validate hex colors with regex, fallback to default on invalid input - Remove unused accentRgb variable - Add BrandEnv type to eliminate unsafe cast in router middleware - Escape brand values in HTML attributes (defense-in-depth) - Add tests for invalid hex inputs and accent fallback Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 66d6f28 commit 358afa8

11 files changed

Lines changed: 367 additions & 57 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ dist/
66
*.log
77
.DS_Store
88
worker-configuration.d.ts
9+
.planning/

src/dashboard/brand.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Brand configuration for dashboard customization
3+
*/
4+
5+
const HEX_COLOR_RE = /^#?[0-9a-fA-F]{6}$/
6+
7+
/**
8+
* Environment variables that control branding
9+
*/
10+
export interface BrandEnv {
11+
BRAND_NAME?: string
12+
BRAND_ACCENT?: string
13+
BRAND_CDN_URL?: string
14+
BRAND_FONT_NAME?: string
15+
BRAND_LOGO_URL?: string
16+
BRAND_FAVICON_URL?: string
17+
BRAND_FONT_REGULAR_URL?: string
18+
BRAND_FONT_MEDIUM_URL?: string
19+
BRAND_PATTERN_URL?: string
20+
}
21+
22+
/**
23+
* Complete brand configuration interface
24+
*/
25+
export interface BrandConfig {
26+
name: string
27+
accentColor: string
28+
accentHoverColor: string
29+
accentLightColor: string
30+
accentDimColor: string
31+
accentGlowColor: string
32+
accentBorderColor: string
33+
fontName: string
34+
fontRegularUrl: string
35+
fontMediumUrl: string
36+
logoUrl: string
37+
faviconUrl: string
38+
patternImageUrl: string
39+
cdnHost: string
40+
}
41+
42+
/**
43+
* Default AIBTC brand configuration
44+
*/
45+
export const DEFAULT_BRAND_CONFIG: BrandConfig = {
46+
name: 'AIBTC',
47+
accentColor: '#FF4F03',
48+
accentHoverColor: '#e54400',
49+
accentLightColor: '#ff7033',
50+
accentDimColor: 'rgba(255, 79, 3, 0.12)',
51+
accentGlowColor: 'rgba(255, 79, 3, 0.08)',
52+
accentBorderColor: 'rgba(255, 79, 3, 0.3)',
53+
fontName: 'Roc Grotesk',
54+
fontRegularUrl: 'https://aibtc.com/fonts/RocGrotesk-Regular.woff2',
55+
fontMediumUrl: 'https://aibtc.com/fonts/RocGrotesk-WideMedium.woff2',
56+
logoUrl: 'https://aibtc.com/Primary_Logo/SVG/AIBTC_PrimaryLogo_KO.svg',
57+
faviconUrl: 'https://aibtc.com/favicon-32x32.png',
58+
patternImageUrl: 'https://aibtc.com/Artwork/AIBTC_Pattern1_optimized.jpg',
59+
cdnHost: 'https://aibtc.com',
60+
}
61+
62+
/**
63+
* Convert hex color to rgba string
64+
*/
65+
export function hexToRgba(hex: string, alpha: number): string {
66+
if (!HEX_COLOR_RE.test(hex)) {
67+
return `rgba(0, 0, 0, ${alpha})`
68+
}
69+
70+
const cleanHex = hex.replace(/^#/, '')
71+
const r = parseInt(cleanHex.substring(0, 2), 16)
72+
const g = parseInt(cleanHex.substring(2, 4), 16)
73+
const b = parseInt(cleanHex.substring(4, 6), 16)
74+
75+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
76+
}
77+
78+
/**
79+
* Get brand configuration from environment variables
80+
*
81+
* Supports:
82+
* - BRAND_NAME: Brand display name
83+
* - BRAND_ACCENT: Hex color (derives hover/dim/glow/border/light automatically)
84+
* - BRAND_CDN_URL: Base URL for assets (derives logo/favicon/font/pattern paths)
85+
* - BRAND_FONT_NAME: Font family name
86+
* - BRAND_LOGO_URL: Full logo URL override
87+
* - BRAND_FAVICON_URL: Full favicon URL override
88+
* - BRAND_FONT_REGULAR_URL: Full font regular URL override
89+
* - BRAND_FONT_MEDIUM_URL: Full font medium URL override
90+
* - BRAND_PATTERN_URL: Full pattern image URL override
91+
*/
92+
export function getBrandConfig(env: BrandEnv): BrandConfig {
93+
const cdnUrl = env.BRAND_CDN_URL || DEFAULT_BRAND_CONFIG.cdnHost
94+
const rawAccent = env.BRAND_ACCENT || DEFAULT_BRAND_CONFIG.accentColor
95+
const accentHex = HEX_COLOR_RE.test(rawAccent) ? rawAccent : DEFAULT_BRAND_CONFIG.accentColor
96+
97+
// Derive accent variations from base accent color
98+
const r = parseInt(accentHex.replace(/^#/, '').substring(0, 2), 16)
99+
const g = parseInt(accentHex.replace(/^#/, '').substring(2, 4), 16)
100+
const b = parseInt(accentHex.replace(/^#/, '').substring(4, 6), 16)
101+
102+
// Generate hover color (darken by ~10%)
103+
const hoverR = Math.max(0, Math.floor(r * 0.9))
104+
const hoverG = Math.max(0, Math.floor(g * 0.9))
105+
const hoverB = Math.max(0, Math.floor(b * 0.9))
106+
const accentHoverColor = `#${hoverR.toString(16).padStart(2, '0')}${hoverG.toString(16).padStart(2, '0')}${hoverB.toString(16).padStart(2, '0')}`
107+
108+
// Generate light color (lighten by ~15%)
109+
const lightR = Math.min(255, Math.floor(r + (255 - r) * 0.15))
110+
const lightG = Math.min(255, Math.floor(g + (255 - g) * 0.15))
111+
const lightB = Math.min(255, Math.floor(b + (255 - b) * 0.15))
112+
const accentLightColor = `#${lightR.toString(16).padStart(2, '0')}${lightG.toString(16).padStart(2, '0')}${lightB.toString(16).padStart(2, '0')}`
113+
114+
return {
115+
name: env.BRAND_NAME || DEFAULT_BRAND_CONFIG.name,
116+
accentColor: accentHex,
117+
accentHoverColor: accentHoverColor,
118+
accentLightColor: accentLightColor,
119+
accentDimColor: hexToRgba(accentHex, 0.12),
120+
accentGlowColor: hexToRgba(accentHex, 0.08),
121+
accentBorderColor: hexToRgba(accentHex, 0.3),
122+
fontName: env.BRAND_FONT_NAME || DEFAULT_BRAND_CONFIG.fontName,
123+
fontRegularUrl: env.BRAND_FONT_REGULAR_URL || `${cdnUrl}/fonts/${env.BRAND_FONT_NAME || 'RocGrotesk'}-Regular.woff2`,
124+
fontMediumUrl: env.BRAND_FONT_MEDIUM_URL || `${cdnUrl}/fonts/${env.BRAND_FONT_NAME || 'RocGrotesk'}-WideMedium.woff2`,
125+
logoUrl: env.BRAND_LOGO_URL || `${cdnUrl}/Primary_Logo/SVG/${env.BRAND_NAME || 'AIBTC'}_PrimaryLogo_KO.svg`,
126+
faviconUrl: env.BRAND_FAVICON_URL || `${cdnUrl}/favicon-32x32.png`,
127+
patternImageUrl: env.BRAND_PATTERN_URL || `${cdnUrl}/Artwork/${env.BRAND_NAME || 'AIBTC'}_Pattern1_optimized.jpg`,
128+
cdnHost: cdnUrl,
129+
}
130+
}

src/dashboard/components/layout.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,38 @@
22
* Shared layout components for the dashboard
33
*/
44

5-
import { logLevelCss } from '../styles'
5+
import { logLevelCss, escapeHtml } from '../styles'
6+
import { BrandConfig, DEFAULT_BRAND_CONFIG } from '../brand'
67

78
export interface LayoutOptions {
89
title?: string
910
currentView?: 'overview' | 'app'
1011
currentApp?: string
1112
apps?: string[]
13+
brand?: BrandConfig
1214
}
1315

1416
/**
15-
* Brand CSS: fonts, variables, background, card effects
17+
* Generate brand CSS from BrandConfig
1618
*/
17-
const brandCss = `
19+
export function buildBrandCss(config: BrandConfig): string {
20+
return `
1821
@font-face {
19-
font-family: 'Roc Grotesk';
20-
src: url('https://aibtc.com/fonts/RocGrotesk-Regular.woff2') format('woff2');
22+
font-family: '${config.fontName}';
23+
src: url('${config.fontRegularUrl}') format('woff2');
2124
font-weight: 400;
2225
font-display: swap;
2326
}
2427
@font-face {
25-
font-family: 'Roc Grotesk';
26-
src: url('https://aibtc.com/fonts/RocGrotesk-WideMedium.woff2') format('woff2');
28+
font-family: '${config.fontName}';
29+
src: url('${config.fontMediumUrl}') format('woff2');
2730
font-weight: 500;
2831
font-display: swap;
2932
}
3033
:root {
31-
--accent: #FF4F03;
32-
--accent-hover: #e54400;
33-
--accent-dim: rgba(255, 79, 3, 0.12);
34+
--accent: ${config.accentColor};
35+
--accent-hover: ${config.accentHoverColor};
36+
--accent-dim: ${config.accentDimColor};
3437
--bg-primary: #000;
3538
--bg-card: #0a0a0a;
3639
--bg-table-header: #111111;
@@ -47,7 +50,7 @@ const brandCss = `
4750
}
4851
* { box-sizing: border-box; }
4952
body {
50-
font-family: 'Roc Grotesk', system-ui, -apple-system, sans-serif;
53+
font-family: '${config.fontName}', system-ui, -apple-system, sans-serif;
5154
background: linear-gradient(135deg, #000000, #0a0a0a, #050208);
5255
color: var(--text-primary);
5356
min-height: 100vh;
@@ -57,7 +60,7 @@ const brandCss = `
5760
content: '';
5861
position: fixed;
5962
inset: 0;
60-
background: url('https://aibtc.com/Artwork/AIBTC_Pattern1_optimized.jpg') center/cover;
63+
background: url('${config.patternImageUrl}') center/cover;
6164
opacity: 0.12;
6265
filter: saturate(1.3);
6366
pointer-events: none;
@@ -75,7 +78,7 @@ const brandCss = `
7578
.bg-gray-600 { background-color: var(--bg-active-subtle) !important; }
7679
/* Brand accent overrides: all .text-blue-400 except log-level indicators */
7780
.text-blue-400:not(.log-level) { color: var(--accent) !important; }
78-
.text-blue-400:not(.log-level):hover, .hover\\:text-blue-300:not(.log-level):hover { color: #ff7033 !important; }
81+
.text-blue-400:not(.log-level):hover, .hover\\:text-blue-300:not(.log-level):hover { color: ${config.accentLightColor} !important; }
7982
.bg-blue-600 { background-color: var(--accent) !important; }
8083
.hover\\:bg-blue-700:hover { background-color: var(--accent-hover) !important; }
8184
.focus\\:border-blue-500:focus { border-color: var(--accent) !important; }
@@ -101,8 +104,8 @@ const brandCss = `
101104
}
102105
.brand-card:hover {
103106
transform: translateY(-2px);
104-
box-shadow: 0 4px 20px rgba(255, 79, 3, 0.08);
105-
border-color: rgba(255, 79, 3, 0.3);
107+
box-shadow: 0 4px 20px ${config.accentGlowColor};
108+
border-color: ${config.accentBorderColor};
106109
}
107110
.brand-card::before {
108111
content: '';
@@ -144,6 +147,7 @@ const brandCss = `
144147
}
145148
.header-logo { height: 28px; width: auto; }
146149
`
150+
}
147151

148152
/**
149153
* Card glow mouse tracking script
@@ -166,21 +170,22 @@ const cardGlowScript = `
166170
*/
167171
export function htmlDocument(content: string, options: LayoutOptions = {}): string {
168172
const { title = 'Worker Logs' } = options
173+
const brand = options.brand || DEFAULT_BRAND_CONFIG
169174

170175
return `<!DOCTYPE html>
171176
<html lang="en">
172177
<head>
173178
<meta charset="UTF-8">
174179
<meta name="viewport" content="width=device-width, initial-scale=1.0">
175180
<title>${title}</title>
176-
<link rel="icon" type="image/png" sizes="32x32" href="https://aibtc.com/favicon-32x32.png">
177-
<link rel="dns-prefetch" href="https://aibtc.com">
178-
<link rel="preload" href="https://aibtc.com/Artwork/AIBTC_Pattern1_optimized.jpg" as="image" type="image/jpeg">
181+
<link rel="icon" type="image/png" sizes="32x32" href="${escapeHtml(brand.faviconUrl)}">
182+
<link rel="dns-prefetch" href="${escapeHtml(brand.cdnHost)}">
183+
<link rel="preload" href="${escapeHtml(brand.patternImageUrl)}" as="image" type="image/jpeg">
179184
<script src="https://cdn.tailwindcss.com"></script>
180185
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
181186
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
182187
<style>
183-
${brandCss}
188+
${buildBrandCss(brand)}
184189
${logLevelCss}
185190
[x-cloak] { display: none !important; }
186191
</style>
@@ -193,17 +198,18 @@ ${content}
193198
}
194199

195200
/**
196-
* Dashboard header with navigation and AIBTC logo
201+
* Dashboard header with navigation and brand logo
197202
*/
198203
export function header(options: LayoutOptions = {}): string {
199204
const { currentView = 'overview', currentApp, apps = [] } = options
205+
const brand = options.brand || DEFAULT_BRAND_CONFIG
200206

201207
return `
202208
<header class="brand-header px-6 py-4">
203209
<div class="max-w-7xl mx-auto flex items-center justify-between">
204210
<div class="flex items-center gap-6">
205211
<a href="/dashboard" class="flex items-center gap-3" style="color: inherit; text-decoration: none;">
206-
<img src="https://aibtc.com/Primary_Logo/SVG/AIBTC_PrimaryLogo_KO.svg" alt="AIBTC" class="header-logo">
212+
<img src="${escapeHtml(brand.logoUrl)}" alt="${escapeHtml(brand.name)}" class="header-logo">
207213
<span class="text-lg font-medium" style="color: var(--text-secondary);">Worker Logs</span>
208214
</a>
209215
<nav class="flex gap-1">

src/dashboard/helpers.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@
55
import type { Context } from 'hono'
66
import type { Env } from '../types'
77

8-
type DashboardContext = Context<{ Bindings: Env }>
9-
108
/**
119
* Get list of registered app IDs from KV
1210
*/
13-
export async function getAppList(c: DashboardContext): Promise<string[]> {
11+
export async function getAppList(c: Context<{ Bindings: Env }>): Promise<string[]> {
1412
if (!c.env.LOGS_KV) return []
1513
const data = await c.env.LOGS_KV.get('apps')
1614
if (!data) return []
@@ -20,7 +18,7 @@ export async function getAppList(c: DashboardContext): Promise<string[]> {
2018
/**
2119
* Get app name from KV config
2220
*/
23-
export async function getAppName(c: DashboardContext, appId: string): Promise<string> {
21+
export async function getAppName(c: Context<{ Bindings: Env }>, appId: string): Promise<string> {
2422
if (!c.env.LOGS_KV) return appId
2523
const data = await c.env.LOGS_KV.get(`app:${appId}`)
2624
if (!data) return appId
@@ -31,7 +29,7 @@ export async function getAppName(c: DashboardContext, appId: string): Promise<st
3129
/**
3230
* Get health URLs from KV config
3331
*/
34-
export async function getHealthUrls(c: DashboardContext, appId: string): Promise<string[]> {
32+
export async function getHealthUrls(c: Context<{ Bindings: Env }>, appId: string): Promise<string[]> {
3533
if (!c.env.LOGS_KV) return []
3634
const data = await c.env.LOGS_KV.get(`app:${appId}`)
3735
if (!data) return []

0 commit comments

Comments
 (0)