feat(themes): add theme picker with swatch previews and per-theme fonts

PART 1: Add missing CSS variables to all ui objects
- Add --bg-inset, --bg-muted for component backgrounds
- Add --success, --warning as aliases for --status-success/warning
- Add --warning-muted for warning background states
- Each theme now has 32 CSS variables

PART 2: Per-theme font support
- Move --font-sans and --font-mono from :root to ui objects
- Add fontImports array to theme config (for future custom fonts)
- applyThemeUI() now manages <link> tags for font imports
- Existing themes use empty fontImports (system fonts already loaded)

PART 3: Swatch preview colors
- Add swatch array (3 hex colors) to each theme for visual preview
- light: warm tan, sage green, khaki
- dark: dark brown, sage green, tan
- clean: light gray, Google blue, Google green
- themeList() now returns swatch in result shape

PART 4: Theme picker UI
- New ThemePicker component replaces icon toggle in header
- Palette icon trigger opens popover below
- Shows all themes as circular swatches (conic gradient)
- Active theme has accent ring indicator
- Click swatch to apply theme, closes popover
- Click outside or Escape closes popover
- Styled with current theme CSS variables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-01 17:42:51 +00:00
commit a7fd4e4e8c
5 changed files with 263 additions and 53 deletions

View file

@ -1,6 +1,6 @@
import { useRef, useCallback, useEffect, useState } from 'react' import { useRef, useCallback, useEffect, useState } from 'react'
import { Sun, Moon, Sparkles, LogIn, LogOut } from 'lucide-react' import { LogIn, LogOut } from 'lucide-react'
import { themeList } from '../themes/registry' import ThemePicker from './ThemePicker'
import { useStore, usePanelState } from '../store' import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config' import { hasFeature } from '../config'
import SearchBar from './SearchBar' import SearchBar from './SearchBar'
@ -55,32 +55,6 @@ export default function Panel({ onManeuverClick }) {
return () => window.removeEventListener('resize', check) return () => window.removeEventListener('resize', check)
}, []) }, [])
// Theme toggle - cycles through all available themes
const toggleTheme = () => {
const themes = themeList()
const currentIdx = themes.findIndex(t => t.id === theme)
const nextIdx = (currentIdx + 1) % themes.length
setThemeOverride(themes[nextIdx].id)
}
// Get theme icon based on current theme
const getThemeIcon = () => {
switch (theme) {
case 'dark': return <Moon size={16} />
case 'light': return <Sun size={16} />
case 'clean': return <Sparkles size={16} />
default: return <Sun size={16} />
}
}
// Get next theme name for tooltip
const getNextThemeName = () => {
const themes = themeList()
const currentIdx = themes.findIndex(t => t.id === theme)
const nextIdx = (currentIdx + 1) % themes.length
return themes[nextIdx].name
}
// Auth handlers // Auth handlers
const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' }
const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' }
@ -274,15 +248,7 @@ export default function Panel({ onManeuverClick }) {
</button> </button>
) )
)} )}
<button <ThemePicker />
onClick={toggleTheme}
className="p-1.5 rounded"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Switch to ${getNextThemeName()} theme`}
title={`Switch to ${getNextThemeName()} theme`}
>
{getThemeIcon()}
</button>
</div> </div>
</div> </div>
) )

View file

@ -0,0 +1,167 @@
import { useState, useRef, useEffect } from 'react'
import { Palette } from 'lucide-react'
import { themeList } from '../themes/registry'
import { useStore } from '../store'
/**
* ThemeSwatch - Renders a circular swatch with 3 color segments
*/
function ThemeSwatch({ colors, size = 28, active = false }) {
// Split circle into 3 segments using conic gradient
const gradient = `conic-gradient(
${colors[0]} 0deg 120deg,
${colors[1]} 120deg 240deg,
${colors[2]} 240deg 360deg
)`
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
background: gradient,
border: active ? '2px solid var(--accent)' : '2px solid var(--border)',
boxShadow: active ? '0 0 0 2px var(--accent-muted)' : 'none',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
/>
)
}
/**
* ThemePicker - Popover component for selecting themes
*/
export default function ThemePicker() {
const [isOpen, setIsOpen] = useState(false)
const theme = useStore((s) => s.theme)
const setThemeOverride = useStore((s) => s.setThemeOverride)
const triggerRef = useRef(null)
const popoverRef = useRef(null)
const themes = themeList()
const currentTheme = themes.find(t => t.id === theme) || themes[0]
// Handle click outside to close
useEffect(() => {
if (!isOpen) return
function handleClickOutside(e) {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target) &&
triggerRef.current &&
!triggerRef.current.contains(e.target)
) {
setIsOpen(false)
}
}
function handleEscape(e) {
if (e.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
const handleThemeSelect = (themeId) => {
setThemeOverride(themeId)
setIsOpen(false)
}
return (
<div style={{ position: 'relative' }}>
{/* Trigger button */}
<button
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
className="p-1.5 rounded flex items-center justify-center"
style={{ color: 'var(--text-secondary)' }}
aria-label="Select theme"
title="Select theme"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Palette size={16} />
</button>
{/* Popover */}
{isOpen && (
<div
ref={popoverRef}
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
background: 'var(--bg-raised)',
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px',
boxShadow: 'var(--shadow-lg)',
zIndex: 100,
minWidth: '140px',
}}
role="menu"
aria-orientation="horizontal"
>
<div
style={{
display: 'flex',
gap: '16px',
justifyContent: 'center',
}}
>
{themes.map((t) => (
<button
key={t.id}
onClick={() => handleThemeSelect(t.id)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
background: 'transparent',
border: 'none',
padding: '4px',
borderRadius: '6px',
cursor: 'pointer',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-overlay)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
role="menuitem"
aria-current={t.id === theme ? 'true' : undefined}
>
<ThemeSwatch
colors={t.swatch}
size={32}
active={t.id === theme}
/>
<span
style={{
fontSize: 'var(--text-xs)',
color: t.id === theme ? 'var(--accent)' : 'var(--text-secondary)',
fontWeight: t.id === theme ? 500 : 400,
}}
>
{t.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -11,11 +11,8 @@
*/ */
:root { :root {
/* ── Typography ── */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* ── Type scale ── */ /* ── Type scale ── */
/* Font families (--font-sans, --font-mono) are now in theme ui objects */
--text-xs: 0.6875rem; /* 11px */ --text-xs: 0.6875rem; /* 11px */
--text-sm: 0.8125rem; /* 13px */ --text-sm: 0.8125rem; /* 13px */
--text-base: 0.875rem; /* 14px */ --text-base: 0.875rem; /* 14px */

View file

@ -162,29 +162,46 @@ const cleanColors = {
* Clean Google-inspired white panels with standard gray text * Clean Google-inspired white panels with standard gray text
*/ */
const cleanUI = { const cleanUI = {
// Fonts
'--font-sans': "'Inter', system-ui, -apple-system, sans-serif",
'--font-mono': "'JetBrains Mono', ui-monospace, monospace",
// Backgrounds
'--bg-base': '#f5f5f5', '--bg-base': '#f5f5f5',
'--bg-raised': '#ffffff', '--bg-raised': '#ffffff',
'--bg-overlay': '#ffffff', '--bg-overlay': '#ffffff',
'--bg-input': '#ffffff', '--bg-input': '#ffffff',
'--bg-inset': '#f0f0f0',
'--bg-muted': '#f8f9fa',
// Text
'--text-primary': '#202124', '--text-primary': '#202124',
'--text-secondary': '#5f6368', '--text-secondary': '#5f6368',
'--text-tertiary': '#9aa0a6', '--text-tertiary': '#9aa0a6',
'--text-inverse': '#ffffff', '--text-inverse': '#ffffff',
// Borders
'--border': '#dadce0', '--border': '#dadce0',
'--border-subtle': '#e8eaed', '--border-subtle': '#e8eaed',
// Accent
'--accent': '#1a73e8', '--accent': '#1a73e8',
'--accent-hover': '#1557b0', '--accent-hover': '#1557b0',
'--accent-muted': '#e8f0fe', '--accent-muted': '#e8f0fe',
// Tan
'--tan': '#f9a825', '--tan': '#f9a825',
'--tan-muted': '#fef7e0', '--tan-muted': '#fef7e0',
// Pins
'--pin-origin': '#34a853', '--pin-origin': '#34a853',
'--pin-destination': '#ea4335', '--pin-destination': '#ea4335',
'--pin-intermediate': '#5f6368', '--pin-intermediate': '#5f6368',
'--pin-stroke': '#ffffff', '--pin-stroke': '#ffffff',
// Status
'--status-success': '#34a853', '--status-success': '#34a853',
'--status-warning': '#fbbc04', '--status-warning': '#fbbc04',
'--status-danger': '#ea4335', '--status-danger': '#ea4335',
'--success': '#34a853',
'--warning': '#fbbc04',
'--warning-muted': '#fef7e0',
// Route
'--route-line': '#1a73e8', '--route-line': '#1a73e8',
// Shadows
'--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)', '--shadow': '0 1px 3px rgba(60, 64, 67, 0.15), 0 1px 2px rgba(60, 64, 67, 0.1)',
'--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)', '--shadow-lg': '0 2px 6px rgba(60, 64, 67, 0.2), 0 1px 3px rgba(60, 64, 67, 0.15)',
} }

View file

@ -5,16 +5,18 @@
* protomaps themes (light/dark) and custom themes with full flavor objects. * protomaps themes (light/dark) and custom themes with full flavor objects.
* *
* Theme config structure: * Theme config structure:
* id: string - unique identifier (used in store, data-theme attr) * id: string - unique identifier (used in store, data-theme attr)
* name: string - display name for UI * name: string - display name for UI
* dark: boolean - true if dark theme (affects overlay styling, sprite fallback) * dark: boolean - true if dark theme (affects overlay styling, sprite fallback)
* colors: object|null - null for built-in themes, full flavor object for custom * colors: object|null - null for built-in themes, full flavor object for custom
* satellite: object|null - raster adjustments when satellite layer is present * satellite: object|null - raster adjustments when satellite layer is present
* overlay: object - overlay layer styling configuration * overlay: object - overlay layer styling configuration
* ui: object - CSS custom properties for UI elements * ui: object - CSS custom properties for UI elements
* swatch: string[3] - 3 hex colors for theme picker preview
* fontImports: string[] - URLs for font CSS imports (empty for system fonts)
*/ */
import { namedTheme } from 'protomaps-themes-base' import { namedTheme } from 'protomaps-themes-base'
import cleanTheme from './clean.js' import cleanTheme from './clean.js'
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@ -23,64 +25,98 @@ import cleanTheme from './clean.js'
/** /**
* Dark theme UI configuration * Dark theme UI configuration
* All CSS custom properties from [data-theme="dark"] in index.css * All CSS custom properties for dark theme UI
*/ */
const darkUI = { const darkUI = {
// Fonts
'--font-sans': "'Inter', system-ui, -apple-system, sans-serif",
'--font-mono': "'JetBrains Mono', ui-monospace, monospace",
// Backgrounds
'--bg-base': '#1c1917', '--bg-base': '#1c1917',
'--bg-raised': '#252220', '--bg-raised': '#252220',
'--bg-overlay': '#2e2a27', '--bg-overlay': '#2e2a27',
'--bg-input': '#201d1a', '--bg-input': '#201d1a',
'--bg-inset': '#181614',
'--bg-muted': '#2a2725',
// Text
'--text-primary': '#dde3dc', '--text-primary': '#dde3dc',
'--text-secondary': '#8f9a8e', '--text-secondary': '#8f9a8e',
'--text-tertiary': '#5e6b5d', '--text-tertiary': '#5e6b5d',
'--text-inverse': '#1c1917', '--text-inverse': '#1c1917',
// Borders
'--border': '#3a3530', '--border': '#3a3530',
'--border-subtle': '#2a2624', '--border-subtle': '#2a2624',
// Accent
'--accent': '#7a9a6b', '--accent': '#7a9a6b',
'--accent-hover': '#8fad7f', '--accent-hover': '#8fad7f',
'--accent-muted': '#3d4d36', '--accent-muted': '#3d4d36',
// Tan
'--tan': '#b8a88a', '--tan': '#b8a88a',
'--tan-muted': '#4a4235', '--tan-muted': '#4a4235',
// Pins
'--pin-origin': '#6b8f5e', '--pin-origin': '#6b8f5e',
'--pin-destination': '#a67c52', '--pin-destination': '#a67c52',
'--pin-intermediate': '#6b7268', '--pin-intermediate': '#6b7268',
'--pin-stroke': '#1c1917', '--pin-stroke': '#1c1917',
// Status
'--status-success': '#6b8f5e', '--status-success': '#6b8f5e',
'--status-warning': '#b89a4a', '--status-warning': '#b89a4a',
'--status-danger': '#a65c52', '--status-danger': '#a65c52',
'--success': '#6b8f5e',
'--warning': '#b89a4a',
'--warning-muted': '#4a4235',
// Route
'--route-line': '#7a9a6b', '--route-line': '#7a9a6b',
// Shadows
'--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)', '--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)',
'--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)', '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)',
} }
/** /**
* Light theme UI configuration * Light theme UI configuration
* All CSS custom properties from [data-theme="light"] in index.css * All CSS custom properties for light theme UI
*/ */
const lightUI = { const lightUI = {
// Fonts
'--font-sans': "'Inter', system-ui, -apple-system, sans-serif",
'--font-mono': "'JetBrains Mono', ui-monospace, monospace",
// Backgrounds
'--bg-base': '#ddd2b9', '--bg-base': '#ddd2b9',
'--bg-raised': '#e8dec8', '--bg-raised': '#e8dec8',
'--bg-overlay': '#e3d9c1', '--bg-overlay': '#e3d9c1',
'--bg-input': '#e8dec8', '--bg-input': '#e8dec8',
'--bg-inset': '#d5cab2',
'--bg-muted': '#e0d6c0',
// Text
'--text-primary': '#1a1d1a', '--text-primary': '#1a1d1a',
'--text-secondary': '#4f5a49', '--text-secondary': '#4f5a49',
'--text-tertiary': '#7a8674', '--text-tertiary': '#7a8674',
'--text-inverse': '#f5f2ed', '--text-inverse': '#f5f2ed',
// Borders
'--border': '#c4b89e', '--border': '#c4b89e',
'--border-subtle': '#d5cab2', '--border-subtle': '#d5cab2',
// Accent
'--accent': '#4a7040', '--accent': '#4a7040',
'--accent-hover': '#3d5e35', '--accent-hover': '#3d5e35',
'--accent-muted': '#dce8d6', '--accent-muted': '#dce8d6',
// Tan
'--tan': '#8a7556', '--tan': '#8a7556',
'--tan-muted': '#f0e8d8', '--tan-muted': '#f0e8d8',
// Pins
'--pin-origin': '#4a7040', '--pin-origin': '#4a7040',
'--pin-destination': '#8a5c35', '--pin-destination': '#8a5c35',
'--pin-intermediate': '#6b6960', '--pin-intermediate': '#6b6960',
'--pin-stroke': '#1a1d1a', '--pin-stroke': '#1a1d1a',
// Status
'--status-success': '#4a7040', '--status-success': '#4a7040',
'--status-warning': '#8a7040', '--status-warning': '#8a7040',
'--status-danger': '#8a4040', '--status-danger': '#8a4040',
'--success': '#4a7040',
'--warning': '#8a7040',
'--warning-muted': '#f0e8d8',
// Route
'--route-line': '#4a7040', '--route-line': '#4a7040',
// Shadows
'--shadow': '0 2px 8px rgba(0, 0, 0, 0.08)', '--shadow': '0 2px 8px rgba(0, 0, 0, 0.08)',
'--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.12)', '--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.12)',
} }
@ -432,6 +468,8 @@ const themes = {
satellite: null, satellite: null,
overlay: lightOverlay, overlay: lightOverlay,
ui: lightUI, ui: lightUI,
swatch: ['#ddd2b9', '#4a7040', '#8a7556'],
fontImports: [],
}, },
dark: { dark: {
id: 'dark', id: 'dark',
@ -441,8 +479,14 @@ const themes = {
satellite: null, satellite: null,
overlay: darkOverlay, overlay: darkOverlay,
ui: darkUI, ui: darkUI,
swatch: ['#1c1917', '#7a9a6b', '#b8a88a'],
fontImports: [],
},
clean: {
...cleanTheme,
swatch: ['#f5f5f5', '#1a73e8', '#34a853'],
fontImports: [],
}, },
clean: cleanTheme,
// Custom themes go here. Example: // Custom themes go here. Example:
// 'midnight': { // 'midnight': {
// id: 'midnight', // id: 'midnight',
@ -452,6 +496,8 @@ const themes = {
// satellite: { opacity: 0.8, brightnessMin: 0.1 }, // satellite: { opacity: 0.8, brightnessMin: 0.1 },
// overlay: { /* partial overrides - missing keys fall back to dark overlay */ }, // overlay: { /* partial overrides - missing keys fall back to dark overlay */ },
// ui: { /* partial overrides - missing keys fall back to dark ui */ }, // ui: { /* partial overrides - missing keys fall back to dark ui */ },
// swatch: ['#0a0a12', '#6060ff', '#4040a0'],
// fontImports: ['https://fonts.googleapis.com/css2?family=Orbitron&display=swap'],
// }, // },
} }
@ -541,6 +587,9 @@ export function getOverlayConfig(themeId, layerKey) {
* Sets the data-theme attribute AND applies all CSS variables from the * Sets the data-theme attribute AND applies all CSS variables from the
* theme's ui object directly to document.documentElement.style. * theme's ui object directly to document.documentElement.style.
* *
* Also manages font imports: removes previously injected font <link> tags
* and injects new ones for the current theme's fontImports array.
*
* For custom themes, missing ui keys fall back to the appropriate built-in * For custom themes, missing ui keys fall back to the appropriate built-in
* theme (dark or light based on theme.dark flag). * theme (dark or light based on theme.dark flag).
* *
@ -563,14 +612,28 @@ export function applyThemeUI(theme) {
for (const [prop, value] of Object.entries(ui)) { for (const [prop, value] of Object.entries(ui)) {
root.style.setProperty(prop, value) root.style.setProperty(prop, value)
} }
// Manage font imports
// Remove any previously injected theme font links
document.querySelectorAll('link[data-theme-font]').forEach(link => link.remove())
// Inject new font links for this theme
const fontImports = theme.fontImports || []
for (const url of fontImports) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = url
link.setAttribute('data-theme-font', theme.id)
document.head.appendChild(link)
}
} }
/** /**
* Get list of available themes for UI display * Get list of available themes for UI display
* @returns {Array<{id: string, name: string, dark: boolean}>} * @returns {Array<{id: string, name: string, dark: boolean, swatch: string[]}>}
*/ */
export function themeList() { export function themeList() {
return Object.values(themes).map(({ id, name, dark }) => ({ id, name, dark })) return Object.values(themes).map(({ id, name, dark, swatch }) => ({ id, name, dark, swatch }))
} }
/** /**