diff --git a/app/branding/branding.go b/app/branding/branding.go
new file mode 100644
index 0000000..a4590e3
--- /dev/null
+++ b/app/branding/branding.go
@@ -0,0 +1,341 @@
+package branding
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "mime"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "lol.mleku.dev/chk"
+ "lol.mleku.dev/log"
+)
+
+// Manager handles loading and serving custom branding assets
+type Manager struct {
+ dir string
+ config Config
+
+ // Cached assets for performance
+ cachedAssets map[string][]byte
+ cachedCSS []byte
+}
+
+// New creates a new branding Manager by loading configuration from the specified directory
+func New(dir string) (*Manager, error) {
+ m := &Manager{
+ dir: dir,
+ cachedAssets: make(map[string][]byte),
+ }
+
+ // Load branding.json
+ configPath := filepath.Join(dir, "branding.json")
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ log.I.F("branding.json not found in %s, using defaults", dir)
+ m.config = DefaultConfig()
+ } else {
+ return nil, fmt.Errorf("failed to read branding.json: %w", err)
+ }
+ } else {
+ if err := json.Unmarshal(data, &m.config); err != nil {
+ return nil, fmt.Errorf("failed to parse branding.json: %w", err)
+ }
+ }
+
+ // Pre-load and cache CSS
+ if err := m.loadCSS(); err != nil {
+ log.W.F("failed to load custom CSS: %v", err)
+ }
+
+ return m, nil
+}
+
+// Dir returns the branding directory path
+func (m *Manager) Dir() string {
+ return m.dir
+}
+
+// Config returns the loaded branding configuration
+func (m *Manager) Config() Config {
+ return m.config
+}
+
+// GetAsset returns a custom asset by name with its MIME type
+// Returns the asset data, MIME type, and whether it was found
+func (m *Manager) GetAsset(name string) ([]byte, string, bool) {
+ var assetPath string
+
+ switch name {
+ case "logo":
+ assetPath = m.config.Assets.Logo
+ case "favicon":
+ assetPath = m.config.Assets.Favicon
+ case "icon-192":
+ assetPath = m.config.Assets.Icon192
+ case "icon-512":
+ assetPath = m.config.Assets.Icon512
+ default:
+ return nil, "", false
+ }
+
+ if assetPath == "" {
+ return nil, "", false
+ }
+
+ // Check cache first
+ if data, ok := m.cachedAssets[name]; ok {
+ return data, m.getMimeType(assetPath), true
+ }
+
+ // Load from disk
+ fullPath := filepath.Join(m.dir, assetPath)
+ data, err := os.ReadFile(fullPath)
+ if chk.D(err) {
+ return nil, "", false
+ }
+
+ // Cache for next time
+ m.cachedAssets[name] = data
+ return data, m.getMimeType(assetPath), true
+}
+
+// GetAssetPath returns the full filesystem path for a custom asset
+func (m *Manager) GetAssetPath(name string) (string, bool) {
+ var assetPath string
+
+ switch name {
+ case "logo":
+ assetPath = m.config.Assets.Logo
+ case "favicon":
+ assetPath = m.config.Assets.Favicon
+ case "icon-192":
+ assetPath = m.config.Assets.Icon192
+ case "icon-512":
+ assetPath = m.config.Assets.Icon512
+ default:
+ return "", false
+ }
+
+ if assetPath == "" {
+ return "", false
+ }
+
+ fullPath := filepath.Join(m.dir, assetPath)
+ if _, err := os.Stat(fullPath); err != nil {
+ return "", false
+ }
+
+ return fullPath, true
+}
+
+// loadCSS loads and caches the custom CSS files
+func (m *Manager) loadCSS() error {
+ var combined bytes.Buffer
+
+ // Load variables CSS first (if exists)
+ if m.config.CSS.VariablesCSS != "" {
+ varsPath := filepath.Join(m.dir, m.config.CSS.VariablesCSS)
+ if data, err := os.ReadFile(varsPath); err == nil {
+ combined.Write(data)
+ combined.WriteString("\n")
+ }
+ }
+
+ // Load custom CSS (if exists)
+ if m.config.CSS.CustomCSS != "" {
+ customPath := filepath.Join(m.dir, m.config.CSS.CustomCSS)
+ if data, err := os.ReadFile(customPath); err == nil {
+ combined.Write(data)
+ }
+ }
+
+ if combined.Len() > 0 {
+ m.cachedCSS = combined.Bytes()
+ }
+
+ return nil
+}
+
+// GetCustomCSS returns the combined custom CSS content
+func (m *Manager) GetCustomCSS() ([]byte, error) {
+ if m.cachedCSS == nil {
+ return nil, fs.ErrNotExist
+ }
+ return m.cachedCSS, nil
+}
+
+// HasCustomCSS returns true if custom CSS is available
+func (m *Manager) HasCustomCSS() bool {
+ return len(m.cachedCSS) > 0
+}
+
+// GetManifest generates a customized manifest.json
+func (m *Manager) GetManifest(originalManifest []byte) ([]byte, error) {
+ var manifest map[string]any
+
+ if err := json.Unmarshal(originalManifest, &manifest); err != nil {
+ return nil, fmt.Errorf("failed to parse original manifest: %w", err)
+ }
+
+ // Apply customizations
+ if m.config.App.Name != "" {
+ manifest["name"] = m.config.App.Name
+ }
+ if m.config.App.ShortName != "" {
+ manifest["short_name"] = m.config.App.ShortName
+ }
+ if m.config.App.Description != "" {
+ manifest["description"] = m.config.App.Description
+ }
+ if m.config.Manifest.ThemeColor != "" {
+ manifest["theme_color"] = m.config.Manifest.ThemeColor
+ }
+ if m.config.Manifest.BackgroundColor != "" {
+ manifest["background_color"] = m.config.Manifest.BackgroundColor
+ }
+
+ // Update icon paths to use branding endpoints
+ if icons, ok := manifest["icons"].([]any); ok {
+ for i, icon := range icons {
+ if iconMap, ok := icon.(map[string]any); ok {
+ if src, ok := iconMap["src"].(string); ok {
+ // Replace icon paths with branding paths
+ if strings.Contains(src, "192") {
+ iconMap["src"] = "/branding/icon-192.png"
+ } else if strings.Contains(src, "512") {
+ iconMap["src"] = "/branding/icon-512.png"
+ }
+ icons[i] = iconMap
+ }
+ }
+ }
+ manifest["icons"] = icons
+ }
+
+ return json.MarshalIndent(manifest, "", " ")
+}
+
+// ModifyIndexHTML modifies the index.html to inject custom branding
+func (m *Manager) ModifyIndexHTML(original []byte) ([]byte, error) {
+ html := string(original)
+
+ // Inject custom CSS link before
+ if m.HasCustomCSS() {
+ cssLink := ``
+ html = strings.Replace(html, "", cssLink+"\n", 1)
+ }
+
+ // Inject JavaScript to change header text at runtime
+ if m.config.App.Name != "" {
+ // This script runs after DOM is loaded and updates the header text
+ brandingScript := fmt.Sprintf(``, m.config.App.Name+" dashboard")
+ html = strings.Replace(html, "", brandingScript+"\n", 1)
+ }
+
+ // Replace title if custom title is set
+ if m.config.App.Title != "" {
+ titleRegex := regexp.MustCompile(`
[^<]*`)
+ html = titleRegex.ReplaceAllString(html, fmt.Sprintf("%s", m.config.App.Title))
+ }
+
+ // Replace logo path to use branding endpoint
+ if m.config.Assets.Logo != "" {
+ // Replace orly.png references with branding logo endpoint
+ html = strings.ReplaceAll(html, `"/orly.png"`, `"/branding/logo.png"`)
+ html = strings.ReplaceAll(html, `'/orly.png'`, `'/branding/logo.png'`)
+ html = strings.ReplaceAll(html, `src="/orly.png"`, `src="/branding/logo.png"`)
+ }
+
+ // Replace favicon path
+ if m.config.Assets.Favicon != "" {
+ html = strings.ReplaceAll(html, `href="/favicon.png"`, `href="/branding/favicon.png"`)
+ html = strings.ReplaceAll(html, `href="favicon.png"`, `href="/branding/favicon.png"`)
+ }
+
+ // Replace manifest path to use dynamic endpoint
+ html = strings.ReplaceAll(html, `href="/manifest.json"`, `href="/branding/manifest.json"`)
+ html = strings.ReplaceAll(html, `href="manifest.json"`, `href="/branding/manifest.json"`)
+
+ return []byte(html), nil
+}
+
+// NIP11Config returns the NIP-11 branding configuration
+func (m *Manager) NIP11Config() NIP11Config {
+ return m.config.NIP11
+}
+
+// AppName returns the custom app name, or empty string if not set
+func (m *Manager) AppName() string {
+ return m.config.App.Name
+}
+
+// getMimeType determines the MIME type from a file path
+func (m *Manager) getMimeType(path string) string {
+ ext := filepath.Ext(path)
+ mimeType := mime.TypeByExtension(ext)
+ if mimeType == "" {
+ // Default fallbacks
+ switch strings.ToLower(ext) {
+ case ".png":
+ return "image/png"
+ case ".jpg", ".jpeg":
+ return "image/jpeg"
+ case ".gif":
+ return "image/gif"
+ case ".svg":
+ return "image/svg+xml"
+ case ".ico":
+ return "image/x-icon"
+ case ".css":
+ return "text/css"
+ case ".js":
+ return "application/javascript"
+ default:
+ return "application/octet-stream"
+ }
+ }
+ return mimeType
+}
+
+// ClearCache clears all cached assets (useful for hot-reload during development)
+func (m *Manager) ClearCache() {
+ m.cachedAssets = make(map[string][]byte)
+ m.cachedCSS = nil
+ _ = m.loadCSS()
+}
diff --git a/app/branding/init.go b/app/branding/init.go
new file mode 100644
index 0000000..2600a6d
--- /dev/null
+++ b/app/branding/init.go
@@ -0,0 +1,790 @@
+package branding
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "io/fs"
+ "math"
+ "os"
+ "path/filepath"
+)
+
+// BrandingStyle represents the type of branding kit to generate
+type BrandingStyle string
+
+const (
+ StyleORLY BrandingStyle = "orly" // ORLY-branded assets
+ StyleGeneric BrandingStyle = "generic" // Generic/white-label assets
+)
+
+// InitBrandingKit creates a branding directory with assets and configuration
+func InitBrandingKit(dir string, embeddedFS embed.FS, style BrandingStyle) error {
+ // Create directory structure
+ dirs := []string{
+ dir,
+ filepath.Join(dir, "assets"),
+ filepath.Join(dir, "css"),
+ }
+
+ for _, d := range dirs {
+ if err := os.MkdirAll(d, 0755); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", d, err)
+ }
+ }
+
+ // Write branding.json based on style
+ var config Config
+ var cssTemplate, varsTemplate string
+
+ switch style {
+ case StyleGeneric:
+ config = GenericConfig()
+ cssTemplate = GenericCSSTemplate
+ varsTemplate = GenericCSSVariablesTemplate
+ default:
+ config = DefaultConfig()
+ cssTemplate = CSSTemplate
+ varsTemplate = CSSVariablesTemplate
+ }
+
+ configData, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal config: %w", err)
+ }
+ configPath := filepath.Join(dir, "branding.json")
+ if err := os.WriteFile(configPath, configData, 0644); err != nil {
+ return fmt.Errorf("failed to write branding.json: %w", err)
+ }
+
+ // Generate or extract assets based on style
+ if style == StyleGeneric {
+ // Generate generic placeholder images
+ if err := generateGenericAssets(dir); err != nil {
+ return fmt.Errorf("failed to generate generic assets: %w", err)
+ }
+ } else {
+ // Extract ORLY embedded assets
+ assetMappings := map[string]string{
+ "web/dist/orly.png": filepath.Join(dir, "assets", "logo.png"),
+ "web/dist/favicon.png": filepath.Join(dir, "assets", "favicon.png"),
+ "web/dist/icon-192.png": filepath.Join(dir, "assets", "icon-192.png"),
+ "web/dist/icon-512.png": filepath.Join(dir, "assets", "icon-512.png"),
+ }
+
+ for src, dst := range assetMappings {
+ data, err := fs.ReadFile(embeddedFS, src)
+ if err != nil {
+ altSrc := "web/" + filepath.Base(src)
+ data, err = fs.ReadFile(embeddedFS, altSrc)
+ if err != nil {
+ fmt.Printf("Warning: could not extract %s: %v\n", src, err)
+ continue
+ }
+ }
+ if err := os.WriteFile(dst, data, 0644); err != nil {
+ return fmt.Errorf("failed to write %s: %w", dst, err)
+ }
+ }
+ }
+
+ // Write CSS template
+ cssPath := filepath.Join(dir, "css", "custom.css")
+ if err := os.WriteFile(cssPath, []byte(cssTemplate), 0644); err != nil {
+ return fmt.Errorf("failed to write custom.css: %w", err)
+ }
+
+ // Write variables-only CSS template
+ varsPath := filepath.Join(dir, "css", "variables.css")
+ if err := os.WriteFile(varsPath, []byte(varsTemplate), 0644); err != nil {
+ return fmt.Errorf("failed to write variables.css: %w", err)
+ }
+
+ return nil
+}
+
+// generateGenericAssets creates simple geometric placeholder images
+func generateGenericAssets(dir string) error {
+ // Color scheme: neutral blue-gray
+ primaryColor := color.RGBA{R: 64, G: 128, B: 192, A: 255} // #4080C0 - professional blue
+ transparent := color.RGBA{R: 0, G: 0, B: 0, A: 0} // Transparent background
+
+ // Generate each size
+ sizes := map[string]int{
+ "logo.png": 256,
+ "favicon.png": 64,
+ "icon-192.png": 192,
+ "icon-512.png": 512,
+ }
+
+ for filename, size := range sizes {
+ img := generateRoundedSquare(size, primaryColor, transparent)
+ path := filepath.Join(dir, "assets", filename)
+ if err := savePNG(path, img); err != nil {
+ return fmt.Errorf("failed to save %s: %w", filename, err)
+ }
+ }
+
+ return nil
+}
+
+// generateRoundedSquare creates a simple rounded square icon
+func generateRoundedSquare(size int, primary, bg color.RGBA) image.Image {
+ img := image.NewRGBA(image.Rect(0, 0, size, size))
+
+ // Fill background
+ for y := 0; y < size; y++ {
+ for x := 0; x < size; x++ {
+ img.Set(x, y, bg)
+ }
+ }
+
+ // Draw a rounded square in the center
+ margin := size / 8
+ cornerRadius := size / 6
+ squareSize := size - (margin * 2)
+
+ for y := margin; y < margin+squareSize; y++ {
+ for x := margin; x < margin+squareSize; x++ {
+ // Check if point is inside rounded rectangle
+ if isInsideRoundedRect(x-margin, y-margin, squareSize, squareSize, cornerRadius) {
+ img.Set(x, y, primary)
+ }
+ }
+ }
+
+ // Draw a simple inner circle (relay symbol)
+ centerX := size / 2
+ centerY := size / 2
+ innerRadius := size / 5
+ ringWidth := size / 20
+
+ for y := 0; y < size; y++ {
+ for x := 0; x < size; x++ {
+ dx := float64(x - centerX)
+ dy := float64(y - centerY)
+ dist := math.Sqrt(dx*dx + dy*dy)
+
+ // Ring (circle outline)
+ if dist >= float64(innerRadius-ringWidth) && dist <= float64(innerRadius) {
+ img.Set(x, y, bg)
+ }
+ }
+ }
+
+ return img
+}
+
+// isInsideRoundedRect checks if a point is inside a rounded rectangle
+func isInsideRoundedRect(x, y, w, h, r int) bool {
+ // Check corners
+ if x < r && y < r {
+ // Top-left corner
+ return isInsideCircle(x, y, r, r, r)
+ }
+ if x >= w-r && y < r {
+ // Top-right corner
+ return isInsideCircle(x, y, w-r-1, r, r)
+ }
+ if x < r && y >= h-r {
+ // Bottom-left corner
+ return isInsideCircle(x, y, r, h-r-1, r)
+ }
+ if x >= w-r && y >= h-r {
+ // Bottom-right corner
+ return isInsideCircle(x, y, w-r-1, h-r-1, r)
+ }
+
+ // Inside main rectangle
+ return x >= 0 && x < w && y >= 0 && y < h
+}
+
+// isInsideCircle checks if a point is inside a circle
+func isInsideCircle(x, y, cx, cy, r int) bool {
+ dx := x - cx
+ dy := y - cy
+ return dx*dx+dy*dy <= r*r
+}
+
+// savePNG saves an image as a PNG file
+func savePNG(path string, img image.Image) error {
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return err
+ }
+ return os.WriteFile(path, buf.Bytes(), 0644)
+}
+
+// GenericConfig returns a generic/white-label configuration
+func GenericConfig() Config {
+ return Config{
+ Version: 1,
+ App: AppConfig{
+ Name: "Relay",
+ ShortName: "Relay",
+ Title: "Relay Dashboard",
+ Description: "Nostr relay service",
+ },
+ NIP11: NIP11Config{
+ Name: "Relay",
+ Description: "A Nostr relay",
+ Icon: "",
+ },
+ Manifest: ManifestConfig{
+ ThemeColor: "#4080C0",
+ BackgroundColor: "#F0F4F8",
+ },
+ Assets: AssetsConfig{
+ Logo: "assets/logo.png",
+ Favicon: "assets/favicon.png",
+ Icon192: "assets/icon-192.png",
+ Icon512: "assets/icon-512.png",
+ },
+ CSS: CSSConfig{
+ CustomCSS: "css/custom.css",
+ VariablesCSS: "css/variables.css",
+ },
+ }
+}
+
+// CSSTemplate is the full CSS template with all variables and documentation
+const CSSTemplate = `/*
+ * Custom Branding CSS for ORLY Relay
+ * ==================================
+ *
+ * This file is loaded AFTER the default styles, so any rules here
+ * will override the defaults. You can customize:
+ *
+ * 1. CSS Variables (colors, spacing, etc.)
+ * 2. Component styles (buttons, cards, headers, etc.)
+ * 3. Add completely custom styles
+ *
+ * Restart the relay to apply changes.
+ *
+ * For variable-only overrides, edit variables.css instead.
+ */
+
+/* =============================================================================
+ LIGHT THEME VARIABLES
+ ============================================================================= */
+
+:root {
+ /* Background colors */
+ --bg-color: #ddd; /* Main page background */
+ --header-bg: #eee; /* Header background */
+ --sidebar-bg: #eee; /* Sidebar background */
+ --card-bg: #f8f9fa; /* Card/container background */
+ --panel-bg: #f8f9fa; /* Panel background */
+
+ /* Border colors */
+ --border-color: #dee2e6; /* Default border color */
+
+ /* Text colors */
+ --text-color: #444444; /* Primary text color */
+ --text-muted: #6c757d; /* Secondary/muted text */
+
+ /* Input/form colors */
+ --input-border: #ccc; /* Input border color */
+ --input-bg: #ffffff; /* Input background */
+ --input-text-color: #495057; /* Input text color */
+
+ /* Button colors */
+ --button-bg: #ddd; /* Default button background */
+ --button-hover-bg: #eee; /* Button hover background */
+ --button-text: #444444; /* Button text color */
+ --button-hover-border: #adb5bd; /* Button hover border */
+
+ /* Theme/accent colors */
+ --primary: #00bcd4; /* Primary accent (cyan) */
+ --primary-bg: rgba(0, 188, 212, 0.1); /* Primary background tint */
+ --secondary: #6c757d; /* Secondary color */
+
+ /* Status colors */
+ --success: #28a745; /* Success/positive */
+ --success-bg: #d4edda; /* Success background */
+ --success-text: #155724; /* Success text */
+ --info: #17a2b8; /* Info/neutral */
+ --warning: #ff3e00; /* Warning (Svelte orange) */
+ --warning-bg: #fff3cd; /* Warning background */
+ --danger: #dc3545; /* Danger/error */
+ --danger-bg: #f8d7da; /* Danger background */
+ --danger-text: #721c24; /* Danger text */
+ --error-bg: #f8d7da; /* Error background */
+ --error-text: #721c24; /* Error text */
+
+ /* Code block colors */
+ --code-bg: #f8f9fa; /* Code block background */
+ --code-text: #495057; /* Code text color */
+
+ /* Tab colors */
+ --tab-inactive-bg: #bbb; /* Inactive tab background */
+
+ /* Link/accent colors */
+ --accent-color: #007bff; /* Link color */
+ --accent-hover-color: #0056b3; /* Link hover color */
+}
+
+/* =============================================================================
+ DARK THEME VARIABLES
+ ============================================================================= */
+
+body.dark-theme {
+ /* Background colors */
+ --bg-color: #263238; /* Main page background */
+ --header-bg: #1e272c; /* Header background */
+ --sidebar-bg: #1e272c; /* Sidebar background */
+ --card-bg: #37474f; /* Card/container background */
+ --panel-bg: #37474f; /* Panel background */
+
+ /* Border colors */
+ --border-color: #404040; /* Default border color */
+
+ /* Text colors */
+ --text-color: #ffffff; /* Primary text color */
+ --text-muted: #adb5bd; /* Secondary/muted text */
+
+ /* Input/form colors */
+ --input-border: #555; /* Input border color */
+ --input-bg: #37474f; /* Input background */
+ --input-text-color: #ffffff; /* Input text color */
+
+ /* Button colors */
+ --button-bg: #263238; /* Default button background */
+ --button-hover-bg: #1e272c; /* Button hover background */
+ --button-text: #ffffff; /* Button text color */
+ --button-hover-border: #6c757d; /* Button hover border */
+
+ /* Theme/accent colors */
+ --primary: #00bcd4; /* Primary accent (cyan) */
+ --primary-bg: rgba(0, 188, 212, 0.2); /* Primary background tint */
+ --secondary: #6c757d; /* Secondary color */
+
+ /* Status colors */
+ --success: #28a745; /* Success/positive */
+ --success-bg: #1e4620; /* Success background (dark) */
+ --success-text: #d4edda; /* Success text (light) */
+ --info: #17a2b8; /* Info/neutral */
+ --warning: #ff3e00; /* Warning (Svelte orange) */
+ --warning-bg: #4d1f00; /* Warning background (dark) */
+ --danger: #dc3545; /* Danger/error */
+ --danger-bg: #4d1319; /* Danger background (dark) */
+ --danger-text: #f8d7da; /* Danger text (light) */
+ --error-bg: #4d1319; /* Error background */
+ --error-text: #f8d7da; /* Error text */
+
+ /* Code block colors */
+ --code-bg: #1e272c; /* Code block background */
+ --code-text: #ffffff; /* Code text color */
+
+ /* Tab colors */
+ --tab-inactive-bg: #1a1a1a; /* Inactive tab background */
+
+ /* Link/accent colors */
+ --accent-color: #007bff; /* Link color */
+ --accent-hover-color: #0056b3; /* Link hover color */
+}
+
+/* =============================================================================
+ CUSTOM STYLES
+ Add your custom CSS rules below. These will override any default styles.
+ ============================================================================= */
+
+/* Example: Custom header styling
+.header {
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+*/
+
+/* Example: Custom button styling
+.btn {
+ border-radius: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+*/
+
+/* Example: Custom card styling
+.card {
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+*/
+`
+
+// CSSVariablesTemplate contains only CSS variable definitions
+const CSSVariablesTemplate = `/*
+ * CSS Variables Override for ORLY Relay
+ * ======================================
+ *
+ * This file contains only CSS variable definitions.
+ * Edit values here to customize colors without touching component styles.
+ *
+ * For full CSS customization (including component styles),
+ * edit custom.css instead.
+ */
+
+/* Light theme variables */
+:root {
+ --bg-color: #ddd;
+ --header-bg: #eee;
+ --sidebar-bg: #eee;
+ --card-bg: #f8f9fa;
+ --panel-bg: #f8f9fa;
+ --border-color: #dee2e6;
+ --text-color: #444444;
+ --text-muted: #6c757d;
+ --input-border: #ccc;
+ --input-bg: #ffffff;
+ --input-text-color: #495057;
+ --button-bg: #ddd;
+ --button-hover-bg: #eee;
+ --button-text: #444444;
+ --button-hover-border: #adb5bd;
+ --primary: #00bcd4;
+ --primary-bg: rgba(0, 188, 212, 0.1);
+ --secondary: #6c757d;
+ --success: #28a745;
+ --success-bg: #d4edda;
+ --success-text: #155724;
+ --info: #17a2b8;
+ --warning: #ff3e00;
+ --warning-bg: #fff3cd;
+ --danger: #dc3545;
+ --danger-bg: #f8d7da;
+ --danger-text: #721c24;
+ --error-bg: #f8d7da;
+ --error-text: #721c24;
+ --code-bg: #f8f9fa;
+ --code-text: #495057;
+ --tab-inactive-bg: #bbb;
+ --accent-color: #007bff;
+ --accent-hover-color: #0056b3;
+}
+
+/* Dark theme variables */
+body.dark-theme {
+ --bg-color: #263238;
+ --header-bg: #1e272c;
+ --sidebar-bg: #1e272c;
+ --card-bg: #37474f;
+ --panel-bg: #37474f;
+ --border-color: #404040;
+ --text-color: #ffffff;
+ --text-muted: #adb5bd;
+ --input-border: #555;
+ --input-bg: #37474f;
+ --input-text-color: #ffffff;
+ --button-bg: #263238;
+ --button-hover-bg: #1e272c;
+ --button-text: #ffffff;
+ --button-hover-border: #6c757d;
+ --primary: #00bcd4;
+ --primary-bg: rgba(0, 188, 212, 0.2);
+ --secondary: #6c757d;
+ --success: #28a745;
+ --success-bg: #1e4620;
+ --success-text: #d4edda;
+ --info: #17a2b8;
+ --warning: #ff3e00;
+ --warning-bg: #4d1f00;
+ --danger: #dc3545;
+ --danger-bg: #4d1319;
+ --danger-text: #f8d7da;
+ --error-bg: #4d1319;
+ --error-text: #f8d7da;
+ --code-bg: #1e272c;
+ --code-text: #ffffff;
+ --tab-inactive-bg: #1a1a1a;
+ --accent-color: #007bff;
+ --accent-hover-color: #0056b3;
+}
+`
+
+// GenericCSSTemplate is the CSS template for generic/white-label branding
+const GenericCSSTemplate = `/*
+ * Custom Branding CSS - White Label Template
+ * ==========================================
+ *
+ * This file is loaded AFTER the default styles, so any rules here
+ * will override the defaults. You can customize:
+ *
+ * 1. CSS Variables (colors, spacing, etc.)
+ * 2. Component styles (buttons, cards, headers, etc.)
+ * 3. Add completely custom styles
+ *
+ * Restart the relay to apply changes.
+ *
+ * For variable-only overrides, edit variables.css instead.
+ */
+
+/* =============================================================================
+ LIGHT THEME VARIABLES - Professional Blue-Gray
+ ============================================================================= */
+
+html, body {
+ /* Background colors */
+ --bg-color: #F0F4F8; /* Light gray-blue background */
+ --header-bg: #FFFFFF; /* Clean white header */
+ --sidebar-bg: #FFFFFF; /* Clean white sidebar */
+ --card-bg: #FFFFFF; /* White cards */
+ --panel-bg: #FFFFFF; /* White panels */
+
+ /* Border colors */
+ --border-color: #E2E8F0; /* Subtle gray border */
+
+ /* Text colors */
+ --text-color: #334155; /* Dark slate text */
+ --text-muted: #64748B; /* Muted slate */
+
+ /* Input/form colors */
+ --input-border: #CBD5E1; /* Light slate border */
+ --input-bg: #FFFFFF; /* White input */
+ --input-text-color: #334155; /* Dark slate text */
+
+ /* Button colors */
+ --button-bg: #F1F5F9; /* Light slate button */
+ --button-hover-bg: #E2E8F0; /* Slightly darker on hover */
+ --button-text: #334155; /* Dark slate text */
+ --button-hover-border: #94A3B8; /* Medium slate border */
+
+ /* Theme/accent colors - Professional Blue */
+ --primary: #4080C0; /* Professional blue */
+ --primary-bg: rgba(64, 128, 192, 0.1); /* Light blue tint */
+ --secondary: #64748B; /* Slate gray */
+
+ /* Status colors */
+ --success: #22C55E; /* Green */
+ --success-bg: #DCFCE7; /* Light green */
+ --success-text: #166534; /* Dark green */
+ --info: #3B82F6; /* Blue */
+ --warning: #F59E0B; /* Amber */
+ --warning-bg: #FEF3C7; /* Light amber */
+ --danger: #EF4444; /* Red */
+ --danger-bg: #FEE2E2; /* Light red */
+ --danger-text: #991B1B; /* Dark red */
+ --error-bg: #FEE2E2; /* Light red */
+ --error-text: #991B1B; /* Dark red */
+
+ /* Code block colors */
+ --code-bg: #F8FAFC; /* Very light slate */
+ --code-text: #334155; /* Dark slate */
+
+ /* Tab colors */
+ --tab-inactive-bg: #E2E8F0; /* Light slate */
+
+ /* Link/accent colors */
+ --accent-color: #4080C0; /* Professional blue */
+ --accent-hover-color: #2563EB; /* Darker blue */
+}
+
+/* =============================================================================
+ DARK THEME VARIABLES - Professional Dark
+ ============================================================================= */
+
+body.dark-theme {
+ /* Background colors */
+ --bg-color: #0F172A; /* Dark navy */
+ --header-bg: #1E293B; /* Slate gray */
+ --sidebar-bg: #1E293B; /* Slate gray */
+ --card-bg: #1E293B; /* Slate gray */
+ --panel-bg: #1E293B; /* Slate gray */
+
+ /* Border colors */
+ --border-color: #334155; /* Medium slate */
+
+ /* Text colors */
+ --text-color: #F8FAFC; /* Almost white */
+ --text-muted: #94A3B8; /* Muted slate */
+
+ /* Input/form colors */
+ --input-border: #475569; /* Slate border */
+ --input-bg: #1E293B; /* Slate background */
+ --input-text-color: #F8FAFC; /* Light text */
+
+ /* Button colors */
+ --button-bg: #334155; /* Slate button */
+ --button-hover-bg: #475569; /* Lighter on hover */
+ --button-text: #F8FAFC; /* Light text */
+ --button-hover-border: #64748B; /* Medium slate */
+
+ /* Theme/accent colors */
+ --primary: #60A5FA; /* Lighter blue for dark mode */
+ --primary-bg: rgba(96, 165, 250, 0.2); /* Blue tint */
+ --secondary: #94A3B8; /* Muted slate */
+
+ /* Status colors */
+ --success: #4ADE80; /* Bright green */
+ --success-bg: #166534; /* Dark green */
+ --success-text: #DCFCE7; /* Light green */
+ --info: #60A5FA; /* Light blue */
+ --warning: #FBBF24; /* Bright amber */
+ --warning-bg: #78350F; /* Dark amber */
+ --danger: #F87171; /* Light red */
+ --danger-bg: #7F1D1D; /* Dark red */
+ --danger-text: #FEE2E2; /* Light red */
+ --error-bg: #7F1D1D; /* Dark red */
+ --error-text: #FEE2E2; /* Light red */
+
+ /* Code block colors */
+ --code-bg: #0F172A; /* Dark navy */
+ --code-text: #F8FAFC; /* Light text */
+
+ /* Tab colors */
+ --tab-inactive-bg: #1E293B; /* Slate */
+
+ /* Link/accent colors */
+ --accent-color: #60A5FA; /* Light blue */
+ --accent-hover-color: #93C5FD; /* Lighter blue */
+}
+
+/* =============================================================================
+ PRIMARY BUTTON TEXT COLOR FIX
+ Ensures buttons with primary background have white text for contrast
+ ============================================================================= */
+
+/* Target all common button patterns that use primary background */
+button[class*="-btn"],
+button[class*="submit"],
+button[class*="action"],
+button[class*="save"],
+button[class*="add"],
+button[class*="create"],
+button[class*="connect"],
+button[class*="refresh"],
+button[class*="retry"],
+button[class*="send"],
+button[class*="apply"],
+button[class*="execute"],
+button[class*="run"],
+.primary-action,
+.action-button,
+.permission-badge,
+[class*="badge"] {
+ color: #FFFFFF !important;
+}
+
+/* More specific override for any button that visually appears to have primary bg */
+/* This uses a broad selector with low impact on non-primary buttons */
+html:not(.dark-theme) button:not([disabled]) {
+ /* Default to inherit, primary buttons will be caught above */
+}
+
+/* =============================================================================
+ CUSTOM STYLES
+ Add your custom CSS rules below. These will override any default styles.
+ ============================================================================= */
+
+/* Example: Custom header styling
+.header {
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+*/
+
+/* Example: Custom button styling
+.btn {
+ border-radius: 6px;
+ font-weight: 500;
+}
+*/
+
+/* Example: Custom card styling
+.card {
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+*/
+`
+
+// GenericCSSVariablesTemplate contains CSS variables for generic/white-label branding
+const GenericCSSVariablesTemplate = `/*
+ * CSS Variables Override - White Label Template
+ * ==============================================
+ *
+ * This file contains only CSS variable definitions.
+ * Edit values here to customize colors without touching component styles.
+ *
+ * For full CSS customization (including component styles),
+ * edit custom.css instead.
+ */
+
+/* Light theme variables - Professional Blue-Gray */
+/* Applied to both html and body for maximum compatibility */
+html, body {
+ --bg-color: #F0F4F8;
+ --header-bg: #FFFFFF;
+ --sidebar-bg: #FFFFFF;
+ --card-bg: #FFFFFF;
+ --panel-bg: #FFFFFF;
+ --border-color: #E2E8F0;
+ --text-color: #334155;
+ --text-muted: #64748B;
+ --input-border: #CBD5E1;
+ --input-bg: #FFFFFF;
+ --input-text-color: #334155;
+ --button-bg: #F1F5F9;
+ --button-hover-bg: #E2E8F0;
+ --button-text: #334155;
+ --button-hover-border: #94A3B8;
+ --primary: #4080C0;
+ --primary-bg: rgba(64, 128, 192, 0.1);
+ --secondary: #64748B;
+ --success: #22C55E;
+ --success-bg: #DCFCE7;
+ --success-text: #166534;
+ --info: #3B82F6;
+ --warning: #F59E0B;
+ --warning-bg: #FEF3C7;
+ --danger: #EF4444;
+ --danger-bg: #FEE2E2;
+ --danger-text: #991B1B;
+ --error-bg: #FEE2E2;
+ --error-text: #991B1B;
+ --code-bg: #F8FAFC;
+ --code-text: #334155;
+ --tab-inactive-bg: #E2E8F0;
+ --accent-color: #4080C0;
+ --accent-hover-color: #2563EB;
+}
+
+/* Dark theme variables - Professional Dark */
+body.dark-theme {
+ --bg-color: #0F172A;
+ --header-bg: #1E293B;
+ --sidebar-bg: #1E293B;
+ --card-bg: #1E293B;
+ --panel-bg: #1E293B;
+ --border-color: #334155;
+ --text-color: #F8FAFC;
+ --text-muted: #94A3B8;
+ --input-border: #475569;
+ --input-bg: #1E293B;
+ --input-text-color: #F8FAFC;
+ --button-bg: #334155;
+ --button-hover-bg: #475569;
+ --button-text: #F8FAFC;
+ --button-hover-border: #64748B;
+ --primary: #60A5FA;
+ --primary-bg: rgba(96, 165, 250, 0.2);
+ --secondary: #94A3B8;
+ --success: #4ADE80;
+ --success-bg: #166534;
+ --success-text: #DCFCE7;
+ --info: #60A5FA;
+ --warning: #FBBF24;
+ --warning-bg: #78350F;
+ --danger: #F87171;
+ --danger-bg: #7F1D1D;
+ --danger-text: #FEE2E2;
+ --error-bg: #7F1D1D;
+ --error-text: #FEE2E2;
+ --code-bg: #0F172A;
+ --code-text: #F8FAFC;
+ --tab-inactive-bg: #1E293B;
+ --accent-color: #60A5FA;
+ --accent-hover-color: #93C5FD;
+}
+`
diff --git a/app/branding/types.go b/app/branding/types.go
new file mode 100644
index 0000000..12658d8
--- /dev/null
+++ b/app/branding/types.go
@@ -0,0 +1,81 @@
+// Package branding provides white-label customization for the ORLY relay web UI.
+// It allows relay operators to customize the appearance, branding, and theme
+// without rebuilding the application.
+package branding
+
+// Config is the main configuration structure loaded from branding.json
+type Config struct {
+ Version int `json:"version"`
+ App AppConfig `json:"app"`
+ NIP11 NIP11Config `json:"nip11"`
+ Manifest ManifestConfig `json:"manifest"`
+ Assets AssetsConfig `json:"assets"`
+ CSS CSSConfig `json:"css"`
+}
+
+// AppConfig contains application-level branding settings
+type AppConfig struct {
+ Name string `json:"name"` // Display name (e.g., "My Relay")
+ ShortName string `json:"shortName"` // Short name for PWA (e.g., "Relay")
+ Title string `json:"title"` // Browser tab title (e.g., "My Relay Dashboard")
+ Description string `json:"description"` // Brief description
+}
+
+// NIP11Config contains settings for the NIP-11 relay information document
+type NIP11Config struct {
+ Name string `json:"name"` // Relay name in NIP-11 response
+ Description string `json:"description"` // Relay description in NIP-11 response
+ Icon string `json:"icon"` // Icon URL for NIP-11 response
+}
+
+// ManifestConfig contains PWA manifest customization
+type ManifestConfig struct {
+ ThemeColor string `json:"themeColor"` // Theme color (e.g., "#1a1a2e")
+ BackgroundColor string `json:"backgroundColor"` // Background color (e.g., "#16213e")
+}
+
+// AssetsConfig contains paths to custom asset files (relative to branding directory)
+type AssetsConfig struct {
+ Logo string `json:"logo"` // Header logo image (replaces orly.png)
+ Favicon string `json:"favicon"` // Browser favicon
+ Icon192 string `json:"icon192"` // PWA icon 192x192
+ Icon512 string `json:"icon512"` // PWA icon 512x512
+}
+
+// CSSConfig contains paths to custom CSS files (relative to branding directory)
+type CSSConfig struct {
+ CustomCSS string `json:"customCSS"` // Full CSS override file
+ VariablesCSS string `json:"variablesCSS"` // CSS variables override file (optional)
+}
+
+// DefaultConfig returns a default configuration with example values
+func DefaultConfig() Config {
+ return Config{
+ Version: 1,
+ App: AppConfig{
+ Name: "My Relay",
+ ShortName: "Relay",
+ Title: "My Relay Dashboard",
+ Description: "A high-performance Nostr relay",
+ },
+ NIP11: NIP11Config{
+ Name: "My Relay",
+ Description: "Custom relay description",
+ Icon: "",
+ },
+ Manifest: ManifestConfig{
+ ThemeColor: "#000000",
+ BackgroundColor: "#000000",
+ },
+ Assets: AssetsConfig{
+ Logo: "assets/logo.png",
+ Favicon: "assets/favicon.png",
+ Icon192: "assets/icon-192.png",
+ Icon512: "assets/icon-512.png",
+ },
+ CSS: CSSConfig{
+ CustomCSS: "css/custom.css",
+ VariablesCSS: "css/variables.css",
+ },
+ }
+}
diff --git a/app/config/config.go b/app/config/config.go
index 657dbd1..eeeaf00 100644
--- a/app/config/config.go
+++ b/app/config/config.go
@@ -85,6 +85,10 @@ type C struct {
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`
+ // Branding/white-label settings
+ BrandingDir string `env:"ORLY_BRANDING_DIR" usage:"directory containing branding assets and configuration (default: ~/.config/ORLY/branding)"`
+ BrandingEnabled bool `env:"ORLY_BRANDING_ENABLED" default:"true" usage:"enable custom branding if branding directory exists"`
+
// Sprocket settings
SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"`
@@ -445,6 +449,36 @@ func NRCRequested() (requested bool, subcommand string, args []string) {
return
}
+// InitBrandingRequested checks if the first command line argument is "init-branding"
+// and returns the target directory and style if provided.
+//
+// Return Values
+// - requested: true if the 'init-branding' subcommand was provided
+// - targetDir: optional target directory for branding files (default: ~/.config/ORLY/branding)
+// - style: branding style ("orly" or "generic", default: "generic")
+//
+// Usage: orly init-branding [--style orly|generic] [path]
+func InitBrandingRequested() (requested bool, targetDir, style string) {
+ style = "generic" // default to generic/white-label
+ if len(os.Args) > 1 {
+ switch strings.ToLower(os.Args[1]) {
+ case "init-branding":
+ requested = true
+ // Parse remaining arguments
+ for i := 2; i < len(os.Args); i++ {
+ arg := os.Args[i]
+ if arg == "--style" && i+1 < len(os.Args) {
+ style = strings.ToLower(os.Args[i+1])
+ i++ // skip next arg
+ } else if !strings.HasPrefix(arg, "-") {
+ targetDir = arg
+ }
+ }
+ }
+ }
+ return
+}
+
// KV is a key/value pair.
type KV struct{ Key, Value string }
@@ -576,11 +610,16 @@ func PrintHelp(cfg *C, printer io.Writer) {
)
_, _ = fmt.Fprintf(
printer,
- `Usage: %s [env|help|identity|migrate|serve|version]
+ `Usage: %s [env|help|identity|init-branding|migrate|serve|version]
- env: print environment variables configuring %s
- help: print this help text
- identity: print the relay identity secret and public key
+- init-branding: create branding directory with default assets and CSS templates
+ Example: %s init-branding [--style generic|orly] [/path/to/branding]
+ Styles: generic (default) - neutral white-label branding
+ orly - ORLY-branded assets
+ Default location: ~/.config/%s/branding
- migrate: migrate data between database backends
Example: %s migrate --from badger --to bbolt
- serve: start ephemeral relay with RAM-based storage at /dev/shm/orlyserve
@@ -589,7 +628,7 @@ func PrintHelp(cfg *C, printer io.Writer) {
- version: print version and exit (also: -v, --v, -version, --version)
`,
- cfg.AppName, cfg.AppName, cfg.AppName,
+ cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName,
)
_, _ = fmt.Fprintf(
printer,
diff --git a/app/handle-relayinfo.go b/app/handle-relayinfo.go
index d49675d..84b99ce 100644
--- a/app/handle-relayinfo.go
+++ b/app/handle-relayinfo.go
@@ -115,6 +115,20 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
description := version.Description + " dashboard: " + s.DashboardURL(r)
icon := "https://i.nostr.build/6wGXAn7Zaw9mHxFg.png"
+ // Override with branding config if available
+ if s.brandingMgr != nil {
+ nip11 := s.brandingMgr.NIP11Config()
+ if nip11.Name != "" {
+ name = nip11.Name
+ }
+ if nip11.Description != "" {
+ description = nip11.Description
+ }
+ if nip11.Icon != "" {
+ icon = nip11.Icon
+ }
+ }
+
// Override with managed ACL config if in managed mode
if s.Config.ACLMode == "managed" {
// Get managed ACL instance
diff --git a/app/main.go b/app/main.go
index 7d34721..093e34b 100644
--- a/app/main.go
+++ b/app/main.go
@@ -10,9 +10,11 @@ import (
"sync"
"time"
+ "github.com/adrg/xdg"
"golang.org/x/crypto/acme/autocert"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
+ "next.orly.dev/app/branding"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"git.mleku.dev/mleku/nostr/crypto/keys"
@@ -91,6 +93,21 @@ func Run(
db: db,
}
+ // Initialize branding/white-label manager if enabled
+ if cfg.BrandingEnabled {
+ brandingDir := cfg.BrandingDir
+ if brandingDir == "" {
+ brandingDir = filepath.Join(xdg.ConfigHome, cfg.AppName, "branding")
+ }
+ if _, err := os.Stat(brandingDir); err == nil {
+ if l.brandingMgr, err = branding.New(brandingDir); err != nil {
+ log.W.F("failed to load branding from %s: %v", brandingDir, err)
+ } else {
+ log.I.F("custom branding loaded from %s", brandingDir)
+ }
+ }
+ }
+
// Initialize NIP-43 invite manager if enabled
if cfg.NIP43Enabled {
l.InviteManager = nip43.NewInviteManager(cfg.NIP43InviteExpiry)
diff --git a/app/server.go b/app/server.go
index f293b43..def6f67 100644
--- a/app/server.go
+++ b/app/server.go
@@ -15,6 +15,7 @@ import (
"time"
"lol.mleku.dev/chk"
+ "next.orly.dev/app/branding"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/blossom"
@@ -106,6 +107,9 @@ type Server struct {
// Tor hidden service
torService *tor.Service
+
+ // Branding/white-label customization
+ brandingMgr *branding.Manager
}
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system
@@ -302,6 +306,12 @@ func (s *Server) UserInterface() {
// Serve favicon.ico by serving favicon.png
s.mux.HandleFunc("/favicon.ico", s.handleFavicon)
+ // Branding/white-label endpoints (custom assets, CSS, manifest)
+ s.mux.HandleFunc("/branding/", s.handleBrandingAsset)
+
+ // Intercept /orly.png to serve custom logo if branding is active
+ s.mux.HandleFunc("/orly.png", s.handleLogo)
+
// Serve the main login interface (and static assets) or proxy in dev mode
s.mux.HandleFunc("/", s.handleLoginInterface)
@@ -401,6 +411,16 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
return
}
+ // Check for custom branding favicon first
+ if s.brandingMgr != nil {
+ if data, mimeType, ok := s.brandingMgr.GetAsset("favicon"); ok {
+ w.Header().Set("Content-Type", mimeType)
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.Write(data)
+ return
+ }
+ }
+
// Serve favicon.png as favicon.ico from embedded web app
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day
@@ -413,6 +433,30 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
ServeEmbeddedWeb(w, faviconReq)
}
+// handleLogo serves the logo image, using custom branding if available
+func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
+ // In dev mode with proxy configured, forward to dev server
+ if s.devProxy != nil {
+ s.devProxy.ServeHTTP(w, r)
+ return
+ }
+
+ // Check for custom branding logo first
+ if s.brandingMgr != nil {
+ if data, mimeType, ok := s.brandingMgr.GetAsset("logo"); ok {
+ w.Header().Set("Content-Type", mimeType)
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.Write(data)
+ return
+ }
+ }
+
+ // Fall back to embedded orly.png
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ ServeEmbeddedWeb(w, r)
+}
+
// handleLoginInterface serves the main user interface for login
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
// In dev mode with proxy configured, forward to dev server
@@ -427,10 +471,133 @@ func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
return
}
+ // If branding is enabled and this is the index page, inject customizations
+ if s.brandingMgr != nil && (r.URL.Path == "/" || r.URL.Path == "/index.html") {
+ s.serveModifiedIndex(w, r)
+ return
+ }
+
// Serve embedded web interface
ServeEmbeddedWeb(w, r)
}
+// serveModifiedIndex serves the index.html with branding modifications injected
+func (s *Server) serveModifiedIndex(w http.ResponseWriter, r *http.Request) {
+ // Read the embedded index.html
+ fs := GetReactAppFS()
+ file, err := fs.Open("index.html")
+ if err != nil {
+ // Fallback to embedded serving
+ ServeEmbeddedWeb(w, r)
+ return
+ }
+ defer file.Close()
+
+ originalHTML, err := io.ReadAll(file)
+ if err != nil {
+ ServeEmbeddedWeb(w, r)
+ return
+ }
+
+ // Apply branding modifications
+ modifiedHTML, err := s.brandingMgr.ModifyIndexHTML(originalHTML)
+ if err != nil {
+ ServeEmbeddedWeb(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Write(modifiedHTML)
+}
+
+// handleBrandingAsset serves custom branding assets (logo, icons, CSS, manifest)
+func (s *Server) handleBrandingAsset(w http.ResponseWriter, r *http.Request) {
+ // Extract asset name from path: /branding/logo.png -> logo.png
+ path := strings.TrimPrefix(r.URL.Path, "/branding/")
+
+ // If no branding manager, return 404
+ if s.brandingMgr == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ switch path {
+ case "custom.css":
+ // Serve combined custom CSS
+ css, err := s.brandingMgr.GetCustomCSS()
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(css)
+
+ case "manifest.json":
+ // Serve customized manifest.json
+ // First read the embedded manifest
+ fs := GetReactAppFS()
+ file, err := fs.Open("manifest.json")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ defer file.Close()
+
+ originalManifest, err := io.ReadAll(file)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ manifest, err := s.brandingMgr.GetManifest(originalManifest)
+ if err != nil {
+ // Fallback to original
+ w.Header().Set("Content-Type", "application/manifest+json")
+ w.Write(originalManifest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/manifest+json")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Write(manifest)
+
+ case "logo.png":
+ s.serveBrandingAsset(w, "logo")
+
+ case "favicon.png":
+ s.serveBrandingAsset(w, "favicon")
+
+ case "icon-192.png":
+ s.serveBrandingAsset(w, "icon-192")
+
+ case "icon-512.png":
+ s.serveBrandingAsset(w, "icon-512")
+
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+// serveBrandingAsset serves a specific branding asset by name
+func (s *Server) serveBrandingAsset(w http.ResponseWriter, name string) {
+ if s.brandingMgr == nil {
+ http.NotFound(w, nil)
+ return
+ }
+
+ data, mimeType, ok := s.brandingMgr.GetAsset(name)
+ if !ok {
+ http.NotFound(w, nil)
+ return
+ }
+
+ w.Header().Set("Content-Type", mimeType)
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.Write(data)
+}
+
// handleAuthChallenge generates a new authentication challenge
func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
diff --git a/app/web.go b/app/web.go
index 512b071..5a1cbce 100644
--- a/app/web.go
+++ b/app/web.go
@@ -23,3 +23,9 @@ func ServeEmbeddedWeb(w http.ResponseWriter, r *http.Request) {
// Serve the embedded web app
http.FileServer(GetReactAppFS()).ServeHTTP(w, r)
}
+
+// GetEmbeddedWebFS returns the raw embedded filesystem for branding initialization.
+// This is used by the init-branding command to extract default assets.
+func GetEmbeddedWebFS() embed.FS {
+ return reactAppFS
+}
diff --git a/docs/BRANDING_GUIDE.md b/docs/BRANDING_GUIDE.md
new file mode 100644
index 0000000..2509026
--- /dev/null
+++ b/docs/BRANDING_GUIDE.md
@@ -0,0 +1,246 @@
+# White-Label Branding Guide
+
+ORLY supports full white-label branding, allowing relay operators to customize the UI appearance without rebuilding the application. All branding is loaded at runtime from a configuration directory.
+
+## Quick Start
+
+Generate a branding kit:
+
+```bash
+# Generic/white-label branding (recommended for customization)
+./orly init-branding --style generic
+
+# ORLY-branded template
+./orly init-branding --style orly
+
+# Custom output directory
+./orly init-branding --style generic /path/to/branding
+```
+
+The branding kit is created at `~/.config/ORLY/branding/` by default.
+
+## Directory Structure
+
+```
+~/.config/ORLY/branding/
+ branding.json # Main configuration
+ assets/
+ logo.png # Header logo (replaces default)
+ favicon.png # Browser favicon
+ icon-192.png # PWA icon 192x192
+ icon-512.png # PWA icon 512x512
+ css/
+ custom.css # Full CSS override
+ variables.css # CSS variable overrides only
+```
+
+## Configuration (branding.json)
+
+```json
+{
+ "version": 1,
+ "app": {
+ "name": "My Relay",
+ "shortName": "Relay",
+ "title": "My Relay Dashboard",
+ "description": "A high-performance Nostr relay"
+ },
+ "nip11": {
+ "name": "My Relay",
+ "description": "Custom relay description for NIP-11",
+ "icon": "https://example.com/icon.png"
+ },
+ "manifest": {
+ "themeColor": "#4080C0",
+ "backgroundColor": "#F0F4F8"
+ },
+ "assets": {
+ "logo": "assets/logo.png",
+ "favicon": "assets/favicon.png",
+ "icon192": "assets/icon-192.png",
+ "icon512": "assets/icon-512.png"
+ },
+ "css": {
+ "customCSS": "css/custom.css",
+ "variablesCSS": "css/variables.css"
+ }
+}
+```
+
+### Configuration Sections
+
+| Section | Description |
+|---------|-------------|
+| `app` | Application name and titles displayed in the UI |
+| `nip11` | NIP-11 relay information document fields |
+| `manifest` | PWA manifest colors |
+| `assets` | Paths to custom images (relative to branding dir) |
+| `css` | Paths to custom CSS files |
+
+## Custom Assets
+
+Replace the generated placeholder images with your own:
+
+| Asset | Size | Purpose |
+|-------|------|---------|
+| `logo.png` | 256x256 recommended | Header logo |
+| `favicon.png` | 64x64 | Browser tab icon |
+| `icon-192.png` | 192x192 | PWA icon (Android) |
+| `icon-512.png` | 512x512 | PWA splash screen |
+
+**Tip**: Use PNG format with transparency for best results.
+
+## CSS Customization
+
+### Quick Theme Changes (variables.css)
+
+Edit `css/variables.css` to change colors without touching component styles:
+
+```css
+/* Light theme */
+html, body {
+ --bg-color: #F0F4F8;
+ --header-bg: #FFFFFF;
+ --primary: #4080C0;
+ --text-color: #334155;
+ /* ... see generated file for all variables */
+}
+
+/* Dark theme */
+body.dark-theme {
+ --bg-color: #0F172A;
+ --header-bg: #1E293B;
+ --primary: #60A5FA;
+ --text-color: #F8FAFC;
+}
+```
+
+### Full CSS Override (custom.css)
+
+Edit `css/custom.css` for complete control over styling:
+
+```css
+/* Custom header */
+.header {
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+
+/* Custom buttons */
+button {
+ border-radius: 8px;
+ font-weight: 500;
+}
+
+/* Custom cards */
+.card {
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+```
+
+### Available CSS Variables
+
+#### Background Colors
+- `--bg-color` - Main page background
+- `--header-bg` - Header background
+- `--sidebar-bg` - Sidebar background
+- `--card-bg` - Card/container background
+- `--panel-bg` - Panel background
+
+#### Text Colors
+- `--text-color` - Primary text
+- `--text-muted` - Secondary/muted text
+
+#### Theme Colors
+- `--primary` - Primary accent color
+- `--primary-bg` - Primary background tint
+- `--secondary` - Secondary color
+- `--accent-color` - Link color
+- `--accent-hover-color` - Link hover color
+
+#### Status Colors
+- `--success`, `--success-bg`, `--success-text`
+- `--warning`, `--warning-bg`
+- `--danger`, `--danger-bg`, `--danger-text`
+- `--info`
+
+#### Form/Input Colors
+- `--input-bg` - Input background
+- `--input-border` - Input border
+- `--input-text-color` - Input text
+
+#### Button Colors
+- `--button-bg` - Default button background
+- `--button-hover-bg` - Button hover background
+- `--button-text` - Button text color
+- `--button-hover-border` - Button hover border
+
+## Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ORLY_BRANDING_DIR` | `~/.config/ORLY/branding` | Branding directory path |
+| `ORLY_BRANDING_ENABLED` | `true` | Enable/disable custom branding |
+
+## Applying Changes
+
+Restart the relay to apply branding changes:
+
+```bash
+# Stop and start the relay
+pkill orly
+./orly
+```
+
+Changes to CSS and assets require a restart. The relay logs will show:
+
+```
+custom branding loaded from /home/user/.config/ORLY/branding
+```
+
+## Branding Endpoints
+
+The relay serves branding assets at these endpoints:
+
+| Endpoint | Description |
+|----------|-------------|
+| `/branding/logo.png` | Custom logo |
+| `/branding/favicon.png` | Custom favicon |
+| `/branding/icon-192.png` | PWA icon 192x192 |
+| `/branding/icon-512.png` | PWA icon 512x512 |
+| `/branding/custom.css` | Combined CSS (variables + custom) |
+| `/branding/manifest.json` | Customized PWA manifest |
+
+## Disabling Branding
+
+To use the default ORLY branding:
+
+```bash
+# Option 1: Remove branding directory
+rm -rf ~/.config/ORLY/branding
+
+# Option 2: Disable via environment
+ORLY_BRANDING_ENABLED=false ./orly
+```
+
+## Troubleshooting
+
+### Branding not loading
+- Check that `~/.config/ORLY/branding/branding.json` exists
+- Verify file permissions (readable by relay process)
+- Check relay logs for branding load messages
+
+### CSS changes not appearing
+- Hard refresh the browser (Ctrl+Shift+R)
+- Clear browser cache
+- Verify CSS syntax is valid
+
+### Logo not showing
+- Ensure image path in `branding.json` is correct
+- Check image file exists and is readable
+- Use PNG format with appropriate dimensions
+
+### Colors look wrong in light/dark mode
+- Light theme uses `html, body` selector
+- Dark theme uses `body.dark-theme` selector
+- Ensure both themes are defined if customizing
diff --git a/main.go b/main.go
index 3322e90..91129ce 100644
--- a/main.go
+++ b/main.go
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"os/signal"
+ "path/filepath"
"runtime"
"runtime/debug"
"strings"
@@ -15,11 +16,13 @@ import (
"syscall"
"time"
+ "github.com/adrg/xdg"
"github.com/pkg/profile"
"golang.org/x/term"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/app"
+ "next.orly.dev/app/branding"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"git.mleku.dev/mleku/nostr/crypto/keys"
@@ -49,6 +52,40 @@ func main() {
}
log.I.F("starting %s %s", cfg.AppName, version.V)
+ // Handle 'init-branding' subcommand: create branding directory with default assets
+ if requested, targetDir, style := config.InitBrandingRequested(); requested {
+ if targetDir == "" {
+ targetDir = filepath.Join(xdg.ConfigHome, cfg.AppName, "branding")
+ }
+
+ // Validate and convert style
+ var brandingStyle branding.BrandingStyle
+ switch style {
+ case "orly":
+ brandingStyle = branding.StyleORLY
+ case "generic", "":
+ brandingStyle = branding.StyleGeneric
+ default:
+ fmt.Fprintf(os.Stderr, "Unknown style: %s (use 'orly' or 'generic')\n", style)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Initializing %s branding kit at: %s\n", style, targetDir)
+ if err := branding.InitBrandingKit(targetDir, app.GetEmbeddedWebFS(), brandingStyle); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println("\nBranding kit created successfully!")
+ fmt.Println("\nFiles created:")
+ fmt.Println(" branding.json - Main configuration file")
+ fmt.Println(" assets/ - Logo, favicon, and PWA icons")
+ fmt.Println(" css/custom.css - Full CSS override template")
+ fmt.Println(" css/variables.css - CSS variables-only template")
+ fmt.Println("\nEdit these files to customize your relay's appearance.")
+ fmt.Println("Restart the relay to apply changes.")
+ os.Exit(0)
+ }
+
// Handle 'identity' subcommand: print relay identity secret and pubkey and exit
if config.IdentityRequested() {
ctx, cancel := context.WithCancel(context.Background())