mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
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:
parent
7ec87f0945
commit
a7fd4e4e8c
5 changed files with 263 additions and 53 deletions
|
|
@ -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 <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
|
||||
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 }) {
|
|||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
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>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
167
src/components/ThemePicker.jsx
Normal file
167
src/components/ThemePicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@
|
|||
* 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'
|
||||
|
|
@ -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 <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
|
||||
* 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 }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue