diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx
index cd6dd43..2799a89 100644
--- a/src/components/Panel.jsx
+++ b/src/components/Panel.jsx
@@ -1,6 +1,6 @@
import { useRef, useCallback, useEffect, useState } from 'react'
-import { Sun, Moon, Sparkles, LogIn, LogOut } from 'lucide-react'
-import { themeList } from '../themes/registry'
+import { LogIn, LogOut } from 'lucide-react'
+import ThemePicker from './ThemePicker'
import { useStore, usePanelState } from '../store'
import { hasFeature } from '../config'
import SearchBar from './SearchBar'
@@ -55,32 +55,6 @@ export default function Panel({ onManeuverClick }) {
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
- case 'light': return
- case 'clean': return
- default: return
- }
- }
-
- // 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
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/' }
@@ -274,15 +248,7 @@ export default function Panel({ onManeuverClick }) {
)
)}
-
+
)
diff --git a/src/components/ThemePicker.jsx b/src/components/ThemePicker.jsx
new file mode 100644
index 0000000..b9f7792
--- /dev/null
+++ b/src/components/ThemePicker.jsx
@@ -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 (
+
+ )
+}
+
+/**
+ * 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 (
+
+ {/* Trigger button */}
+
+
+ {/* Popover */}
+ {isOpen && (
+
+
+ {themes.map((t) => (
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/index.css b/src/index.css
index 8368268..6a2bd4c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -11,11 +11,8 @@
═══════════════════════════════════════════════════════ */
:root {
- /* ── Typography ── */
- --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
- --font-mono: 'JetBrains Mono', ui-monospace, monospace;
-
/* ── Type scale ── */
+ /* Font families (--font-sans, --font-mono) are now in theme ui objects */
--text-xs: 0.6875rem; /* 11px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.875rem; /* 14px */
diff --git a/src/themes/clean.js b/src/themes/clean.js
index 3215ede..2a7057e 100644
--- a/src/themes/clean.js
+++ b/src/themes/clean.js
@@ -162,29 +162,46 @@ const cleanColors = {
* Clean Google-inspired white panels with standard gray text
*/
const cleanUI = {
+ // Fonts
+ '--font-sans': "'Inter', system-ui, -apple-system, sans-serif",
+ '--font-mono': "'JetBrains Mono', ui-monospace, monospace",
+ // Backgrounds
'--bg-base': '#f5f5f5',
'--bg-raised': '#ffffff',
'--bg-overlay': '#ffffff',
'--bg-input': '#ffffff',
+ '--bg-inset': '#f0f0f0',
+ '--bg-muted': '#f8f9fa',
+ // Text
'--text-primary': '#202124',
'--text-secondary': '#5f6368',
'--text-tertiary': '#9aa0a6',
'--text-inverse': '#ffffff',
+ // Borders
'--border': '#dadce0',
'--border-subtle': '#e8eaed',
+ // Accent
'--accent': '#1a73e8',
'--accent-hover': '#1557b0',
'--accent-muted': '#e8f0fe',
+ // Tan
'--tan': '#f9a825',
'--tan-muted': '#fef7e0',
+ // Pins
'--pin-origin': '#34a853',
'--pin-destination': '#ea4335',
'--pin-intermediate': '#5f6368',
'--pin-stroke': '#ffffff',
+ // Status
'--status-success': '#34a853',
'--status-warning': '#fbbc04',
'--status-danger': '#ea4335',
+ '--success': '#34a853',
+ '--warning': '#fbbc04',
+ '--warning-muted': '#fef7e0',
+ // Route
'--route-line': '#1a73e8',
+ // Shadows
'--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)',
}
diff --git a/src/themes/registry.js b/src/themes/registry.js
index dfc419d..58627cf 100644
--- a/src/themes/registry.js
+++ b/src/themes/registry.js
@@ -5,16 +5,18 @@
* protomaps themes (light/dark) and custom themes with full flavor objects.
*
* Theme config structure:
- * id: string - unique identifier (used in store, data-theme attr)
- * name: string - display name for UI
- * dark: boolean - true if dark theme (affects overlay styling, sprite fallback)
- * colors: object|null - null for built-in themes, full flavor object for custom
+ * id: string - unique identifier (used in store, data-theme attr)
+ * name: string - display name for UI
+ * dark: boolean - true if dark theme (affects overlay styling, sprite fallback)
+ * colors: object|null - null for built-in themes, full flavor object for custom
* satellite: object|null - raster adjustments when satellite layer is present
- * overlay: object - overlay layer styling configuration
- * ui: object - CSS custom properties for UI elements
+ * overlay: object - overlay layer styling configuration
+ * 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'
// ═══════════════════════════════════════════════════════════════════════════
@@ -23,64 +25,98 @@ import cleanTheme from './clean.js'
/**
* 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 = {
+ // Fonts
+ '--font-sans': "'Inter', system-ui, -apple-system, sans-serif",
+ '--font-mono': "'JetBrains Mono', ui-monospace, monospace",
+ // Backgrounds
'--bg-base': '#1c1917',
'--bg-raised': '#252220',
'--bg-overlay': '#2e2a27',
'--bg-input': '#201d1a',
+ '--bg-inset': '#181614',
+ '--bg-muted': '#2a2725',
+ // Text
'--text-primary': '#dde3dc',
'--text-secondary': '#8f9a8e',
'--text-tertiary': '#5e6b5d',
'--text-inverse': '#1c1917',
+ // Borders
'--border': '#3a3530',
'--border-subtle': '#2a2624',
+ // Accent
'--accent': '#7a9a6b',
'--accent-hover': '#8fad7f',
'--accent-muted': '#3d4d36',
+ // Tan
'--tan': '#b8a88a',
'--tan-muted': '#4a4235',
+ // Pins
'--pin-origin': '#6b8f5e',
'--pin-destination': '#a67c52',
'--pin-intermediate': '#6b7268',
'--pin-stroke': '#1c1917',
+ // Status
'--status-success': '#6b8f5e',
'--status-warning': '#b89a4a',
'--status-danger': '#a65c52',
+ '--success': '#6b8f5e',
+ '--warning': '#b89a4a',
+ '--warning-muted': '#4a4235',
+ // Route
'--route-line': '#7a9a6b',
+ // Shadows
'--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)',
'--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)',
}
/**
* 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 = {
+ // Fonts
+ '--font-sans': "'Inter', system-ui, -apple-system, sans-serif",
+ '--font-mono': "'JetBrains Mono', ui-monospace, monospace",
+ // Backgrounds
'--bg-base': '#ddd2b9',
'--bg-raised': '#e8dec8',
'--bg-overlay': '#e3d9c1',
'--bg-input': '#e8dec8',
+ '--bg-inset': '#d5cab2',
+ '--bg-muted': '#e0d6c0',
+ // Text
'--text-primary': '#1a1d1a',
'--text-secondary': '#4f5a49',
'--text-tertiary': '#7a8674',
'--text-inverse': '#f5f2ed',
+ // Borders
'--border': '#c4b89e',
'--border-subtle': '#d5cab2',
+ // Accent
'--accent': '#4a7040',
'--accent-hover': '#3d5e35',
'--accent-muted': '#dce8d6',
+ // Tan
'--tan': '#8a7556',
'--tan-muted': '#f0e8d8',
+ // Pins
'--pin-origin': '#4a7040',
'--pin-destination': '#8a5c35',
'--pin-intermediate': '#6b6960',
'--pin-stroke': '#1a1d1a',
+ // Status
'--status-success': '#4a7040',
'--status-warning': '#8a7040',
'--status-danger': '#8a4040',
+ '--success': '#4a7040',
+ '--warning': '#8a7040',
+ '--warning-muted': '#f0e8d8',
+ // Route
'--route-line': '#4a7040',
+ // Shadows
'--shadow': '0 2px 8px rgba(0, 0, 0, 0.08)',
'--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.12)',
}
@@ -432,6 +468,8 @@ const themes = {
satellite: null,
overlay: lightOverlay,
ui: lightUI,
+ swatch: ['#ddd2b9', '#4a7040', '#8a7556'],
+ fontImports: [],
},
dark: {
id: 'dark',
@@ -441,8 +479,14 @@ const themes = {
satellite: null,
overlay: darkOverlay,
ui: darkUI,
+ swatch: ['#1c1917', '#7a9a6b', '#b8a88a'],
+ fontImports: [],
+ },
+ clean: {
+ ...cleanTheme,
+ swatch: ['#f5f5f5', '#1a73e8', '#34a853'],
+ fontImports: [],
},
- clean: cleanTheme,
// Custom themes go here. Example:
// 'midnight': {
// id: 'midnight',
@@ -452,6 +496,8 @@ const themes = {
// satellite: { opacity: 0.8, brightnessMin: 0.1 },
// overlay: { /* partial overrides - missing keys fall back to dark overlay */ },
// 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
* theme's ui object directly to document.documentElement.style.
*
+ * Also manages font imports: removes previously injected font 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
* 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)) {
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
- * @returns {Array<{id: string, name: string, dark: boolean}>}
+ * @returns {Array<{id: string, name: string, dark: boolean, swatch: string[]}>}
*/
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 }))
}
/**