From a7fd4e4e8cb413f5803a19054ea12a17c61a606f Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 17:42:51 +0000 Subject: [PATCH] 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 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 --- src/components/Panel.jsx | 40 +------- src/components/ThemePicker.jsx | 167 +++++++++++++++++++++++++++++++++ src/index.css | 5 +- src/themes/clean.js | 17 ++++ src/themes/registry.js | 87 ++++++++++++++--- 5 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 src/components/ThemePicker.jsx 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 })) } /**