feat(machine): add 6 switchable color themes

Add theme system with Gruvbox (default), Catppuccin, Dracula, Nord,
Tokyo Night, and Cyberpunk palettes — each with light/dark variants.
Theme picker on idle screen and debug overlay, persisted to localStorage.

Also fixes @custom-variant dark to use :where() per Tailwind v4 best
practice, and replaces Buy Bitcoin emoji with BitcoinIcon component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-16 21:03:20 -05:00
commit b0fc5cf585
4 changed files with 452 additions and 2 deletions

View file

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
const atmStore = useAtmStore() const atmStore = useAtmStore()
const { current: currentTheme, themes } = useTheme()
function formatSats(sats: number): string { function formatSats(sats: number): string {
return sats.toLocaleString() return sats.toLocaleString()
@ -68,6 +70,19 @@ function toggleLiveServices() {
<Button variant="outline" size="sm" class="h-6 text-xs" @click="toggleLiveServices"> <Button variant="outline" size="sm" class="h-6 text-xs" @click="toggleLiveServices">
{{ atmStore.useLiveServices ? 'Switch to Mock' : 'Connect Live' }} {{ atmStore.useLiveServices ? 'Switch to Mock' : 'Connect Live' }}
</Button> </Button>
<span class="text-muted-foreground">|</span>
<div class="flex items-center gap-1">
<Button
v-for="theme in themes"
:key="theme.id"
:variant="currentTheme === theme.id ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="currentTheme = theme.id"
>
{{ theme.label }}
</Button>
</div>
</div> </div>
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre> <pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
</div> </div>

View file

@ -0,0 +1,62 @@
import { ref, watch } from 'vue'
export type ThemeId = 'gruvbox' | 'catppuccin' | 'cyberpunk' | 'dracula' | 'nord' | 'tokyo-night'
export interface ThemeOption {
id: ThemeId
label: string
}
export const themes: ThemeOption[] = [
{ id: 'gruvbox', label: 'Gruvbox' },
{ id: 'catppuccin', label: 'Catppuccin' },
{ id: 'dracula', label: 'Dracula' },
{ id: 'nord', label: 'Nord' },
{ id: 'tokyo-night', label: 'Tokyo Night' },
{ id: 'cyberpunk', label: 'Cyberpunk' },
]
const STORAGE_KEY = 'lamassu-theme'
const current = ref<ThemeId>(loadTheme())
function loadTheme(): ThemeId {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && themes.some((t) => t.id === stored)) {
return stored as ThemeId
}
} catch {
// localStorage unavailable (e.g. SSR)
}
return 'gruvbox'
}
function applyTheme(id: ThemeId) {
const el = document.documentElement
if (id === 'gruvbox') {
el.removeAttribute('data-theme')
} else {
el.setAttribute('data-theme', id)
}
}
// Apply on load
applyTheme(current.value)
// Persist and apply on change
watch(current, (id) => {
applyTheme(id)
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
// localStorage unavailable
}
})
export function useTheme() {
return {
current,
themes,
}
}

View file

@ -1,7 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:where(.dark, .dark *));
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
@ -113,6 +113,361 @@
--qr-foreground: #3c3836; /* gruvbox fg - maintains contrast */ --qr-foreground: #3c3836; /* gruvbox fg - maintains contrast */
} }
/*
* Catppuccin Palette
* https://github.com/catppuccin/catppuccin
*/
[data-theme='catppuccin'] {
--radius: 0.625rem;
/* Catppuccin Latte (light) */
--background: #eff1f5; /* base */
--foreground: #4c4f69; /* text */
--card: #e6e9ef; /* mantle */
--card-foreground: #4c4f69;
--popover: #e6e9ef;
--popover-foreground: #4c4f69;
--primary: #8839ef; /* mauve */
--primary-foreground: #eff1f5;
--secondary: #ccd0da; /* surface0 */
--secondary-foreground: #4c4f69;
--muted: #ccd0da;
--muted-foreground: #6c6f85; /* subtext0 */
--accent: #1e66f5; /* blue */
--accent-foreground: #eff1f5;
--destructive: #d20f39; /* red */
--border: #bcc0cc; /* surface1 */
--input: #bcc0cc;
--ring: #8839ef;
--success: #40a02b; /* green */
--success-foreground: #eff1f5;
--warning: #df8e1d; /* yellow */
--warning-foreground: #4c4f69;
--bitcoin: #fe640b; /* peach */
--bitcoin-foreground: #eff1f5;
--qr: #eff1f5;
--qr-foreground: #4c4f69;
}
[data-theme='catppuccin'].dark {
/* Catppuccin Mocha (dark) */
--background: #1e1e2e; /* base */
--foreground: #cdd6f4; /* text */
--card: #181825; /* mantle */
--card-foreground: #cdd6f4;
--popover: #181825;
--popover-foreground: #cdd6f4;
--primary: #cba6f7; /* mauve */
--primary-foreground: #1e1e2e;
--secondary: #313244; /* surface0 */
--secondary-foreground: #cdd6f4;
--muted: #313244;
--muted-foreground: #a6adc8; /* subtext0 */
--accent: #89b4fa; /* blue */
--accent-foreground: #1e1e2e;
--destructive: #f38ba8; /* red */
--border: #45475a; /* surface1 */
--input: #45475a;
--ring: #cba6f7;
--success: #a6e3a1; /* green */
--success-foreground: #1e1e2e;
--warning: #f9e2af; /* yellow */
--warning-foreground: #1e1e2e;
--bitcoin: #fab387; /* peach */
--bitcoin-foreground: #1e1e2e;
--qr: #eff1f5;
--qr-foreground: #1e1e2e;
}
/*
* Cyberpunk Palette
* Neon accents on deep dark backgrounds
*/
[data-theme='cyberpunk'] {
--radius: 0.375rem;
/* Cyberpunk Light (corporate dystopia) */
--background: #f0f0f8;
--foreground: #1a1a2e;
--card: #e4e4f0;
--card-foreground: #1a1a2e;
--popover: #e4e4f0;
--popover-foreground: #1a1a2e;
--primary: #d6006e; /* magenta */
--primary-foreground: #f0f0f8;
--secondary: #d0d0e0;
--secondary-foreground: #1a1a2e;
--muted: #d0d0e0;
--muted-foreground: #6b6b8a;
--accent: #0099b3; /* teal */
--accent-foreground: #f0f0f8;
--destructive: #cc0033;
--border: #b8b8d0;
--input: #b8b8d0;
--ring: #d6006e;
--success: #00b359;
--success-foreground: #f0f0f8;
--warning: #cc9900;
--warning-foreground: #1a1a2e;
--bitcoin: #f7931a;
--bitcoin-foreground: #f0f0f8;
--qr: #f0f0f8;
--qr-foreground: #1a1a2e;
}
[data-theme='cyberpunk'].dark {
/* Cyberpunk Dark (neon noir) */
--background: #0a0a0f;
--foreground: #e0e0ff;
--card: #12121f;
--card-foreground: #e0e0ff;
--popover: #12121f;
--popover-foreground: #e0e0ff;
--primary: #ff2a6d; /* neon magenta */
--primary-foreground: #0a0a0f;
--secondary: #1a1a2e;
--secondary-foreground: #e0e0ff;
--muted: #1a1a2e;
--muted-foreground: #6b6b8a;
--accent: #05d9e8; /* neon cyan */
--accent-foreground: #0a0a0f;
--destructive: #ff0040;
--border: #2a2a4a;
--input: #2a2a4a;
--ring: #ff2a6d;
--success: #01ff70; /* neon green */
--success-foreground: #0a0a0f;
--warning: #ffdd00; /* neon yellow */
--warning-foreground: #0a0a0f;
--bitcoin: #f7931a;
--bitcoin-foreground: #0a0a0f;
--qr: #e0e0ff;
--qr-foreground: #0a0a0f;
}
/*
* Dracula Palette
* https://draculatheme.com
*/
[data-theme='dracula'] {
--radius: 0.625rem;
/* Dracula Light (adapted) */
--background: #f8f8f2;
--foreground: #282a36;
--card: #e8e8e2;
--card-foreground: #282a36;
--popover: #e8e8e2;
--popover-foreground: #282a36;
--primary: #7c3aed; /* purple */
--primary-foreground: #f8f8f2;
--secondary: #d8d8d2;
--secondary-foreground: #282a36;
--muted: #d8d8d2;
--muted-foreground: #6272a4; /* comment */
--accent: #0891b2; /* cyan */
--accent-foreground: #f8f8f2;
--destructive: #dc2626;
--border: #c8c8c2;
--input: #c8c8c2;
--ring: #7c3aed;
--success: #16a34a;
--success-foreground: #f8f8f2;
--warning: #ca8a04;
--warning-foreground: #282a36;
--bitcoin: #ea580c;
--bitcoin-foreground: #f8f8f2;
--qr: #f8f8f2;
--qr-foreground: #282a36;
}
[data-theme='dracula'].dark {
/* Dracula Dark */
--background: #282a36; /* background */
--foreground: #f8f8f2; /* foreground */
--card: #44475a; /* current line */
--card-foreground: #f8f8f2;
--popover: #44475a;
--popover-foreground: #f8f8f2;
--primary: #bd93f9; /* purple */
--primary-foreground: #282a36;
--secondary: #44475a;
--secondary-foreground: #f8f8f2;
--muted: #44475a;
--muted-foreground: #6272a4; /* comment */
--accent: #8be9fd; /* cyan */
--accent-foreground: #282a36;
--destructive: #ff5555; /* red */
--border: #6272a4;
--input: #6272a4;
--ring: #bd93f9;
--success: #50fa7b; /* green */
--success-foreground: #282a36;
--warning: #f1fa8c; /* yellow */
--warning-foreground: #282a36;
--bitcoin: #ffb86c; /* orange */
--bitcoin-foreground: #282a36;
--qr: #f8f8f2;
--qr-foreground: #282a36;
}
/*
* Nord Palette
* https://www.nordtheme.com
*/
[data-theme='nord'] {
--radius: 0.5rem;
/* Nord Light (Snow Storm) */
--background: #eceff4; /* snow storm 3 */
--foreground: #2e3440; /* polar night 1 */
--card: #e5e9f0; /* snow storm 2 */
--card-foreground: #2e3440;
--popover: #e5e9f0;
--popover-foreground: #2e3440;
--primary: #5e81ac; /* frost 4 */
--primary-foreground: #eceff4;
--secondary: #d8dee9; /* snow storm 1 */
--secondary-foreground: #2e3440;
--muted: #d8dee9;
--muted-foreground: #4c566a; /* polar night 4 */
--accent: #88c0d0; /* frost 2 */
--accent-foreground: #2e3440;
--destructive: #bf616a; /* aurora red */
--border: #d8dee9;
--input: #d8dee9;
--ring: #5e81ac;
--success: #a3be8c; /* aurora green */
--success-foreground: #2e3440;
--warning: #ebcb8b; /* aurora yellow */
--warning-foreground: #2e3440;
--bitcoin: #d08770; /* aurora orange */
--bitcoin-foreground: #eceff4;
--qr: #eceff4;
--qr-foreground: #2e3440;
}
[data-theme='nord'].dark {
/* Nord Dark (Polar Night) */
--background: #2e3440; /* polar night 1 */
--foreground: #eceff4; /* snow storm 3 */
--card: #3b4252; /* polar night 2 */
--card-foreground: #eceff4;
--popover: #3b4252;
--popover-foreground: #eceff4;
--primary: #88c0d0; /* frost 2 */
--primary-foreground: #2e3440;
--secondary: #434c5e; /* polar night 3 */
--secondary-foreground: #eceff4;
--muted: #434c5e;
--muted-foreground: #d8dee9; /* snow storm 1 */
--accent: #81a1c1; /* frost 3 */
--accent-foreground: #2e3440;
--destructive: #bf616a; /* aurora red */
--border: #434c5e;
--input: #434c5e;
--ring: #88c0d0;
--success: #a3be8c; /* aurora green */
--success-foreground: #2e3440;
--warning: #ebcb8b; /* aurora yellow */
--warning-foreground: #2e3440;
--bitcoin: #d08770; /* aurora orange */
--bitcoin-foreground: #2e3440;
--qr: #eceff4;
--qr-foreground: #2e3440;
}
/*
* Tokyo Night Palette
* https://github.com/enkia/tokyo-night-vscode-theme
*/
[data-theme='tokyo-night'] {
--radius: 0.5rem;
/* Tokyo Night Day (light) */
--background: #e1e2e7; /* bg */
--foreground: #3760bf; /* fg */
--card: #d5d6db;
--card-foreground: #3760bf;
--popover: #d5d6db;
--popover-foreground: #3760bf;
--primary: #9854f1; /* magenta */
--primary-foreground: #e1e2e7;
--secondary: #c4c5ca;
--secondary-foreground: #3760bf;
--muted: #c4c5ca;
--muted-foreground: #848cb5; /* comment */
--accent: #2e7de9; /* blue */
--accent-foreground: #e1e2e7;
--destructive: #f52a65; /* red */
--border: #b4b5ba;
--input: #b4b5ba;
--ring: #9854f1;
--success: #587539; /* green */
--success-foreground: #e1e2e7;
--warning: #8c6c3e; /* yellow */
--warning-foreground: #e1e2e7;
--bitcoin: #b15c00; /* orange */
--bitcoin-foreground: #e1e2e7;
--qr: #e1e2e7;
--qr-foreground: #3760bf;
}
[data-theme='tokyo-night'].dark {
/* Tokyo Night Storm (dark) */
--background: #1a1b26; /* bg */
--foreground: #c0caf5; /* fg */
--card: #24283b; /* bg_highlight */
--card-foreground: #c0caf5;
--popover: #24283b;
--popover-foreground: #c0caf5;
--primary: #bb9af7; /* magenta */
--primary-foreground: #1a1b26;
--secondary: #292e42;
--secondary-foreground: #c0caf5;
--muted: #292e42;
--muted-foreground: #565f89; /* comment */
--accent: #7aa2f7; /* blue */
--accent-foreground: #1a1b26;
--destructive: #f7768e; /* red */
--border: #3b4261;
--input: #3b4261;
--ring: #bb9af7;
--success: #9ece6a; /* green */
--success-foreground: #1a1b26;
--warning: #e0af68; /* yellow */
--warning-foreground: #1a1b26;
--bitcoin: #ff9e64; /* orange */
--bitcoin-foreground: #1a1b26;
--qr: #c0caf5;
--qr-foreground: #1a1b26;
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;

View file

@ -2,12 +2,15 @@
import { watch } from 'vue' import { watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import BitcoinIcon from '@/components/BitcoinIcon.vue'
const router = useRouter() const router = useRouter()
const atmStore = useAtmStore() const atmStore = useAtmStore()
const { current: currentTheme, themes } = useTheme()
// Watch for state changes and navigate accordingly // Watch for state changes and navigate accordingly
watch( watch(
@ -45,7 +48,7 @@ function handleCashOut() {
@click="handleCashIn" @click="handleCashIn"
> >
<CardContent class="flex h-full flex-col items-center justify-center gap-2 p-6"> <CardContent class="flex h-full flex-col items-center justify-center gap-2 p-6">
<span class="text-5xl">💵</span> <BitcoinIcon :size="96" />
<span class="text-xl font-bold text-success">Buy Bitcoin</span> <span class="text-xl font-bold text-success">Buy Bitcoin</span>
<span class="text-sm text-foreground/70">Insert cash, receive sats</span> <span class="text-sm text-foreground/70">Insert cash, receive sats</span>
</CardContent> </CardContent>
@ -71,6 +74,21 @@ function handleCashOut() {
<Badge variant="outline" class="border-bitcoin text-bitcoin">Just Bitcoin</Badge> <Badge variant="outline" class="border-bitcoin text-bitcoin">Just Bitcoin</Badge>
</div> </div>
<!-- Theme switcher -->
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Theme:</span>
<Button
v-for="theme in themes"
:key="theme.id"
:variant="currentTheme === theme.id ? 'default' : 'outline'"
size="sm"
class="h-7 text-xs"
@click="currentTheme = theme.id"
>
{{ theme.label }}
</Button>
</div>
<!-- Debug toggle --> <!-- Debug toggle -->
<Button <Button
variant="ghost" variant="ghost"