feat(machine): operator branding — local-file source (issue #47 V1)

Read /var/lib/bitspire/branding/{logo.png,branding.json} on startup and
apply across the renderer. branding.json may set title, theme (one of
the 6 built-ins or "custom"), and a custom_colors map (with optional
.dark overlay) — unset CSS vars fall back to gruvbox.

Wiring:
- electron/main.ts:loadBranding() reads + validates the JSON and
  base64-encodes logo.png; surfaced via the existing get-config IPC
- composables/useBranding.ts holds reactive logoUrl/title refs and a
  single setBranding() setter — the seam where #48's Nostr-event
  source will eventually overlay the local-file source
- composables/useTheme.ts:applyBrandingTheme() handles built-in theme
  swap and injects a <style#branding-custom-theme> block for custom
- IdleView binds :src/title; App.vue calls setBranding() before the
  maintenance screen renders so "Under Service" wears operator branding

Provisioning: new deploy/nixos/provision-branding.sh rsyncs a local dir
to /var/lib/bitspire/branding/ via sudo-on-the-far-side and restarts
bitspire.service. The existing provision-atm.sh stays focused on .env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-25 08:03:32 +02:00
commit c3353c409b
10 changed files with 268 additions and 3 deletions

View file

@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme'
import { setBranding } from '@/composables/useBranding'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next'
@ -38,6 +39,11 @@ onMounted(async () => {
// Check maintenance mode before initializing anything
const config = isElectron ? await window.electronAPI?.getConfig() : null
// Apply operator branding before maintenance screen renders, so the
// "Under Service" page already wears the operator's logo/title/theme.
setBranding(config?.branding ?? null)
const maintenanceMode =
config?.maintenanceMode || import.meta.env.VITE_MAINTENANCE_MODE === 'true'

View file

@ -0,0 +1,37 @@
import { ref } from 'vue'
import type { BrandingConfig } from '@/types/electron'
import { applyBrandingTheme } from './useTheme'
// Reactive branding state — sourced from a BrandingSource (V1: local file
// via Electron IPC; V2 per issue #48: replaceable Nostr event from
// satmachineadmin, which wins when both are present).
const DEFAULT_LOGO = '/logo.png'
const DEFAULT_TITLE = 'Bitcoinmat'
const logoUrl = ref<string>(DEFAULT_LOGO)
const title = ref<string>(DEFAULT_TITLE)
/**
* 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
* a replaceable event arrives, so live updates work without a reload.
*/
export function setBranding(b: BrandingConfig | null) {
if (!b) {
logoUrl.value = DEFAULT_LOGO
title.value = DEFAULT_TITLE
applyBrandingTheme(null)
return
}
logoUrl.value = b.logoDataUrl || DEFAULT_LOGO
title.value = b.title || DEFAULT_TITLE
applyBrandingTheme(
b.theme as Parameters<typeof applyBrandingTheme>[0],
b.customColors,
b.customColorsDark
)
}
export function useBranding() {
return { logoUrl, title }
}

View file

@ -58,6 +58,48 @@ function applyTheme(id: ThemeId) {
}
}
const CUSTOM_THEME_STYLE_ID = 'branding-custom-theme'
/**
* Apply operator-supplied branding theme. Built-in id → swap data-theme.
* 'custom' → strip data-theme (fall through to gruvbox baseline) and
* inject a <style> block setting the supplied CSS vars on :root and .dark.
* Unset vars fall back to gruvbox defaults per issue #47.
*/
export function applyBrandingTheme(
theme: ThemeId | 'custom' | null,
customColors?: Record<string, string>,
customColorsDark?: Record<string, string>
) {
// Remove any prior injected custom theme — operator can switch to a
// built-in theme and the override should disappear without a reload.
const prior = document.getElementById(CUSTOM_THEME_STYLE_ID)
if (prior) prior.remove()
if (theme === 'custom') {
document.documentElement.removeAttribute('data-theme')
const styleEl = document.createElement('style')
styleEl.id = CUSTOM_THEME_STYLE_ID
const root = customColors ? formatVars(':root', customColors) : ''
const dark = customColorsDark ? formatVars(':root.dark', customColorsDark) : ''
styleEl.textContent = `${root}\n${dark}`.trim()
document.head.appendChild(styleEl)
current.value = 'gruvbox' // baseline beneath the custom overrides
return
}
if (theme && themes.some((t) => t.id === theme)) {
current.value = theme as ThemeId
}
}
function formatVars(selector: string, vars: Record<string, string>): string {
const decls = Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n')
return `${selector} {\n${decls}\n}`
}
function applyColorMode(mode: ColorMode) {
const el = document.documentElement
if (mode === 'system') {

View file

@ -23,6 +23,21 @@ export interface RuntimeConfig {
maintenanceMode: boolean
cashInFeeRate: number
cashOutFeeRate: number
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
branding: BrandingConfig | null
}
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
export interface BrandingConfig {
title: string | null
/** One of the 6 built-in theme ids, or 'custom'. Main process validates against allowlist. */
theme: string | null
/** When theme === 'custom': flat map of CSS-var → hex value (e.g. `'--primary': '#ff8019'`). */
customColors?: Record<string, string>
/** Optional dark-mode override map; same shape as customColors. */
customColorsDark?: Record<string, string>
/** Base64 data URL of operator-supplied logo.png. Null when no file present. */
logoDataUrl: string | null
}
export interface AtmSecrets {

View file

@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { nip19 } from 'nostr-tools'
import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding'
import { initialContext } from '@bitSpire/state-machine'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -11,6 +12,7 @@ import QRCode from '@/components/QRCode.vue'
const router = useRouter()
const atmStore = useAtmStore()
const { logoUrl, title: brandTitle } = useBranding()
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
const showZeusQR = ref(false)
const showLpQR = ref(false)
@ -65,12 +67,12 @@ function handleCashOut() {
class="flex flex-col items-center justify-center gap-1.5 lg:gap-2 pt-6 lg:pt-[4vh] pb-2 lg:pb-[2vh]"
>
<img
src="/logo.png"
alt="Lamassu"
:src="logoUrl"
alt="bitSpire"
class="h-20 w-20 lg:h-[12vh] lg:w-[12vh] drop-shadow-[0_0_24px_var(--primary)]"
:class="{ 'animate-float': atmStore.machineModel !== 'sintra' }"
/>
<h1 class="text-3xl lg:text-[5vh] font-bold text-primary leading-tight">Bitcoinmat</h1>
<h1 class="text-3xl lg:text-[5vh] font-bold text-primary leading-tight">{{ brandTitle }}</h1>
<div class="flex flex-wrap items-center justify-center gap-2 lg:gap-3">
<Badge variant="outline" class="border-success text-success text-[11px] lg:text-lg"
>No KYC</Badge