diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index c154505..70370c9 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -60,16 +60,20 @@ function loadEnvFile() { loadEnvFile() -// Branding loader — reads /var/lib/bitspire/branding/{logo.png,branding.json} -// and surfaces them on get-config. Per issue #47: local-file is the V1 -// source; V2 will overlay a higher-priority Nostr-event source from -// satmachineadmin (issue #48). Renderer applies via useBranding(). +// Branding loader — reads /var/lib/bitspire/branding/{logo.png, +// logo-dark.png,branding.json} and surfaces them on get-config. Per +// issue #47: local-file is the V1 source; V2 will overlay a +// higher-priority Nostr-event source from satmachineadmin (issue #48). +// Renderer applies via useBranding(). type BrandingConfig = { title: string | null theme: string | null customColors?: Record customColorsDark?: Record logoDataUrl: string | null + /** Optional dark-mode logo variant. Renderer falls back to logoDataUrl + * when null. */ + logoDarkDataUrl: string | null } const VALID_THEMES = new Set([ @@ -94,6 +98,7 @@ function loadBranding(): BrandingConfig | null { let customColors: Record | undefined let customColorsDark: Record | undefined let logoDataUrl: string | null = null + let logoDarkDataUrl: string | null = null const jsonPath = path.join(brandingDir, 'branding.json') if (fs.existsSync(jsonPath)) { @@ -131,16 +136,27 @@ function loadBranding(): BrandingConfig | null { } } + const logoDarkPath = path.join(brandingDir, 'logo-dark.png') + if (fs.existsSync(logoDarkPath)) { + try { + const buf = fs.readFileSync(logoDarkPath) + logoDarkDataUrl = `data:image/png;base64,${buf.toString('base64')}` + } catch (e) { + console.warn('[Electron] Failed to read logo-dark.png:', e) + } + } + if ( title === null && theme === null && !customColors && !customColorsDark && - logoDataUrl === null + logoDataUrl === null && + logoDarkDataUrl === null ) { return null } - return { title, theme, customColors, customColorsDark, logoDataUrl } + return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl } } // Determine if we're in development diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index cdaf6ae..600023a 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -41,6 +41,7 @@ export interface BrandingConfig { customColors?: Record customColorsDark?: Record logoDataUrl: string | null + logoDarkDataUrl: string | null } /** diff --git a/apps/machine/src/composables/useBranding.ts b/apps/machine/src/composables/useBranding.ts index 5e48136..2e57813 100644 --- a/apps/machine/src/composables/useBranding.ts +++ b/apps/machine/src/composables/useBranding.ts @@ -1,6 +1,6 @@ -import { ref } from 'vue' +import { ref, computed } from 'vue' import type { BrandingConfig } from '@/types/electron' -import { applyBrandingTheme } from './useTheme' +import { applyBrandingTheme, useTheme } from './useTheme' // Reactive branding state — sourced from a BrandingSource (V1: local file // via Electron IPC; V2 per issue #48: replaceable Nostr event from @@ -8,9 +8,18 @@ import { applyBrandingTheme } from './useTheme' const DEFAULT_LOGO = '/logo.png' const DEFAULT_TITLE = 'Bitcoinmat' -const logoUrl = ref(DEFAULT_LOGO) +const logoLightUrl = ref(DEFAULT_LOGO) +const logoDarkUrl = ref(null) const title = ref(DEFAULT_TITLE) +const { isDark } = useTheme() + +/** Effective logo: dark variant when dark mode is active AND operator + * supplied one, otherwise the regular logo. */ +const logoUrl = computed(() => + isDark.value && logoDarkUrl.value ? logoDarkUrl.value : logoLightUrl.value +) + /** * Apply a branding payload to the live UI. Pass null to revert to defaults. * Single mutation point — V2's Nostr-event source calls the same setter when @@ -18,12 +27,14 @@ const title = ref(DEFAULT_TITLE) */ export function setBranding(b: BrandingConfig | null) { if (!b) { - logoUrl.value = DEFAULT_LOGO + logoLightUrl.value = DEFAULT_LOGO + logoDarkUrl.value = null title.value = DEFAULT_TITLE applyBrandingTheme(null) return } - logoUrl.value = b.logoDataUrl || DEFAULT_LOGO + logoLightUrl.value = b.logoDataUrl || DEFAULT_LOGO + logoDarkUrl.value = b.logoDarkDataUrl || null title.value = b.title || DEFAULT_TITLE applyBrandingTheme( b.theme as Parameters[0], diff --git a/apps/machine/src/composables/useTheme.ts b/apps/machine/src/composables/useTheme.ts index 2735a94..e6ef993 100644 --- a/apps/machine/src/composables/useTheme.ts +++ b/apps/machine/src/composables/useTheme.ts @@ -1,4 +1,4 @@ -import { ref, watch } from 'vue' +import { ref, computed, watch } from 'vue' export type ThemeId = 'gruvbox' | 'catppuccin' | 'cyberpunk' | 'dracula' | 'nord' | 'tokyo-night' export type ColorMode = 'light' | 'dark' | 'system' @@ -112,12 +112,22 @@ function applyColorMode(mode: ColorMode) { // Listen for system preference changes when mode is 'system' const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') -mediaQuery.addEventListener('change', () => { +const prefersDark = ref(mediaQuery.matches) +mediaQuery.addEventListener('change', (e) => { + prefersDark.value = e.matches if (colorMode.value === 'system') { applyColorMode('system') } }) +/** True when the effective color mode is dark (resolves 'system' via media query). + * Consumed by useBranding to pick logo-dark.png over logo.png. */ +const isDark = computed(() => { + if (colorMode.value === 'dark') return true + if (colorMode.value === 'light') return false + return prefersDark.value +}) + // Apply on load applyTheme(current.value) applyColorMode(colorMode.value) @@ -146,5 +156,6 @@ export function useTheme() { current, themes, colorMode, + isDark, } } diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 7ca85fe..3be9e14 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -38,6 +38,9 @@ export interface BrandingConfig { customColorsDark?: Record /** Base64 data URL of operator-supplied logo.png. Null when no file present. */ logoDataUrl: string | null + /** Base64 data URL of operator-supplied logo-dark.png; renderer uses this + * when dark mode is active, falling back to logoDataUrl when null. */ + logoDarkDataUrl: string | null } export interface AtmSecrets { diff --git a/deploy/nixos/provision-branding.sh b/deploy/nixos/provision-branding.sh index 438a399..ea91de7 100755 --- a/deploy/nixos/provision-branding.sh +++ b/deploy/nixos/provision-branding.sh @@ -10,9 +10,10 @@ # # Expected files in : # logo.png — operator logo (any reasonable size; rendered ~12vh) +# logo-dark.png — optional dark-mode variant; falls back to logo.png if absent # branding.json — { title, theme, custom_colors{...}, custom_colors.dark{...} } # -# Either file is optional; the on-disk schema in /var/lib/bitspire/branding/ +# Any file is optional; the on-disk schema in /var/lib/bitspire/branding/ # matches whatever contains (rsync --delete). set -euo pipefail