feat(themes): consolidate UI CSS properties into theme registry

- Add darkUI and lightUI objects with all 25 CSS custom properties
- Add applyThemeUI() function to apply CSS vars via JavaScript
- Update useTheme.js to call applyThemeUI() instead of setAttribute
- Remove [data-theme="dark"] and [data-theme="light"] from index.css
- Custom themes can now override individual UI properties with cascade
- Update README.md to document the ui key and cascade behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-01 16:17:26 +00:00
commit f0acea33a0
4 changed files with 191 additions and 93 deletions

View file

@ -1,12 +1,13 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useStore } from '../store' import { useStore } from '../store'
import { getTheme, applyThemeUI } from '../themes/registry'
/** /**
* Initializes and manages the theme system. * Initializes and manages the theme system.
* Call once in App it handles: * Call once in App it handles:
* - Reading localStorage override on mount * - Reading localStorage override on mount
* - Listening to system prefers-color-scheme * - Listening to system prefers-color-scheme
* - Applying data-theme to <html> * - Applying theme UI via registry (CSS custom properties)
* - Updating store.theme (resolved value) * - Updating store.theme (resolved value)
*/ */
export function useTheme() { export function useTheme() {
@ -16,8 +17,11 @@ export function useTheme() {
// Initialize override from localStorage on first mount // Initialize override from localStorage on first mount
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem('navi-theme-override') const stored = localStorage.getItem('navi-theme-override')
if (stored === 'dark' || stored === 'light') { if (stored) {
useStore.getState().setThemeOverride(stored) const theme = getTheme(stored)
if (theme) {
useStore.getState().setThemeOverride(stored)
}
} }
}, []) }, [])
@ -30,7 +34,8 @@ export function useTheme() {
function apply() { function apply() {
const resolved = resolve() const resolved = resolve()
document.documentElement.setAttribute('data-theme', resolved) const theme = getTheme(resolved)
applyThemeUI(theme)
setTheme(resolved) setTheme(resolved)
} }

View file

@ -4,6 +4,10 @@
NAVI DESIGN TOKENS NAVI DESIGN TOKENS
Warm grays, sage greens, khaki tans, deep blacks. Warm grays, sage greens, khaki tans, deep blacks.
No blue in UI chrome. No blue in UI chrome.
NOTE: Color tokens (--bg-*, --text-*, --border, etc.)
are now applied via applyThemeUI() in src/themes/registry.js.
Each theme defines its own ui object with CSS custom properties.
*/ */
:root { :root {
@ -19,80 +23,6 @@
--text-lg: 1.125rem; /* 18px */ --text-lg: 1.125rem; /* 18px */
} }
/* ═══ DARK MODE (default) ═══ */
[data-theme="dark"] {
--bg-base: #1c1917; /* warm off-black (was #0f1210) */
--bg-raised: #252220; /* raised surface (was #181d1a) */
--bg-overlay: #2e2a27; /* overlay/dropdown (was #1e2522) */
--bg-input: #201d1a; /* input fields (was #141a16) */
--text-primary: #dde3dc;
--text-secondary: #8f9a8e;
--text-tertiary: #5e6b5d;
--text-inverse: #1c1917;
--border: #3a3530; /* warm brown-gray (was #2a3329) */
--border-subtle: #2a2624; /* (was #1f261e) */
--accent: #7a9a6b; /* sage green — interactive states */
--accent-hover: #8fad7f;
--accent-muted: #3d4d36;
--tan: #b8a88a; /* khaki — secondary highlights */
--tan-muted: #4a4235;
--pin-origin: #6b8f5e; /* sage */
--pin-destination: #a67c52; /* rust/tan */
--pin-intermediate: #6b7268; /* warm gray */
--pin-stroke: #1c1917;
--status-success: #6b8f5e;
--status-warning: #b89a4a;
--status-danger: #a65c52;
--route-line: #7a9a6b;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
}
/* ═══ LIGHT MODE ═══ */
[data-theme="light"] {
--bg-base: #ddd2b9; /* warm khaki-tan (was #ece8e1) */
--bg-raised: #e8dec8; /* raised surface (was #f5f2ec) */
--bg-overlay: #e3d9c1; /* overlay/dropdown (was #f0ece5) */
--bg-input: #e8dec8; /* input fields (was #f5f2ec) */
--text-primary: #1a1d1a;
--text-secondary: #4f5a49; /* darkened for WCAG AA on new base (was #5c6558) */
--text-tertiary: #7a8674; /* darkened proportionally (was #8a9486) */
--text-inverse: #f5f2ed;
--border: #c4b89e; /* warmer border (was #d4cfc5) */
--border-subtle: #d5cab2; /* warmer subtle border (was #e8e3db) */
--accent: #4a7040;
--accent-hover: #3d5e35;
--accent-muted: #dce8d6;
--tan: #8a7556;
--tan-muted: #f0e8d8;
--pin-origin: #4a7040;
--pin-destination: #8a5c35;
--pin-intermediate: #6b6960;
--pin-stroke: #1a1d1a;
--status-success: #4a7040;
--status-warning: #8a7040;
--status-danger: #8a4040;
--route-line: #4a7040;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
}
/* ═══ BASE STYLES ═══ */ /* ═══ BASE STYLES ═══ */
html, body, #root { html, body, #root {
margin: 0; margin: 0;

View file

@ -4,7 +4,7 @@ This directory contains the theme registry and reference files for creating cust
## Files ## Files
- **registry.js** - Theme registry with getTheme(), getThemeColors(), getThemeSprite(), themeList() - **registry.js** - Theme registry with getTheme(), getThemeColors(), getThemeSprite(), getOverlayConfig(), applyThemeUI(), themeList()
- **dark-flavor-reference.json** - Full namedTheme('dark') output for reference - **dark-flavor-reference.json** - Full namedTheme('dark') output for reference
- **light-flavor-reference.json** - Full namedTheme('light') output for reference - **light-flavor-reference.json** - Full namedTheme('light') output for reference
@ -144,7 +144,7 @@ const themes = {
'sepia': { 'sepia': {
id: 'sepia', id: 'sepia',
name: 'Sepia', name: 'Sepia',
dark: false, // Affects overlay styling and sprite fallback dark: false, // Affects overlay styling, sprite fallback, and UI cascade
colors: { colors: {
// Full flavor object (all 73 flat keys + pois + landcover) // Full flavor object (all 73 flat keys + pois + landcover)
}, },
@ -157,14 +157,75 @@ const themes = {
saturation: 0, saturation: 0,
hueRotate: 0, hueRotate: 0,
}, },
overlay: null, // Reserved for future use overlay: null, // Optional: custom overlay config, cascades from dark/light
ui: null, // Optional: custom UI CSS vars, cascades from dark/light
}, },
} }
``` ```
### UI Customization
Each theme can define a `ui` object containing CSS custom properties for the application chrome.
Custom themes cascade from the base dark/light UI based on the `dark` flag.
```javascript
// Full list of UI properties (25 total)
ui: {
'--bg-base': '#1c1917',
'--bg-raised': '#252220',
'--bg-overlay': '#2e2a27',
'--bg-input': '#201d1a',
'--text-primary': '#dde3dc',
'--text-secondary': '#8f9a8e',
'--text-tertiary': '#5e6b5d',
'--text-inverse': '#1c1917',
'--border': '#3a3530',
'--border-subtle': '#2a2624',
'--accent': '#7a9a6b',
'--accent-hover': '#8fad7f',
'--accent-muted': '#3d4d36',
'--tan': '#b8a88a',
'--tan-muted': '#4a4235',
'--pin-origin': '#6b8f5e',
'--pin-destination': '#a67c52',
'--pin-intermediate': '#6b7268',
'--pin-stroke': '#1c1917',
'--status-success': '#6b8f5e',
'--status-warning': '#b89a4a',
'--status-danger': '#a65c52',
'--route-line': '#7a9a6b',
'--shadow': '0 2px 8px rgba(0, 0, 0, 0.4)',
'--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.5)',
}
```
Custom themes only need to specify the properties they want to override:
```javascript
'sepia': {
id: 'sepia',
name: 'Sepia',
dark: false,
colors: { /* ... */ },
ui: {
// Only override what's different from the light theme
'--accent': '#8a7040',
'--accent-hover': '#6b5530',
'--tan': '#8a7556',
},
}
```
### Overlay Customization
Overlay styling (hillshade, traffic, contours, public lands, USFS trails, BLM trails) is also
configurable per-theme. See `darkOverlay` and `lightOverlay` in registry.js for the full
structure. Custom themes cascade from dark/light based on the `dark` flag.
### Important Notes ### Important Notes
1. **All keys are required** - protomaps-themes-base expects every key 1. **All color keys are required** - protomaps-themes-base expects every key
2. **Nested objects matter** - `pois` and `landcover` are objects, not flat keys 2. **Nested objects matter** - `pois` and `landcover` are objects, not flat keys
3. **Sprite fallback** - Custom themes fall back to dark/light sprite based on `dark` flag 3. **Sprite fallback** - Custom themes fall back to dark/light sprite based on `dark` flag
4. **CSS vars separate** - Map flavor colors are separate from UI CSS custom properties 4. **Cascading configs** - overlay and ui configs cascade from dark/light if not specified
5. **CSS vars via JS** - UI CSS properties are applied via `applyThemeUI()`, not CSS selectors

View file

@ -11,10 +11,79 @@
* 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
*/ */
import { namedTheme } from 'protomaps-themes-base' import { namedTheme } from 'protomaps-themes-base'
// ═══════════════════════════════════════════════════════════════════════════
// UI CSS CUSTOM PROPERTIES
// ═══════════════════════════════════════════════════════════════════════════
/**
* Dark theme UI configuration
* All CSS custom properties from [data-theme="dark"] in index.css
*/
const darkUI = {
'--bg-base': '#1c1917',
'--bg-raised': '#252220',
'--bg-overlay': '#2e2a27',
'--bg-input': '#201d1a',
'--text-primary': '#dde3dc',
'--text-secondary': '#8f9a8e',
'--text-tertiary': '#5e6b5d',
'--text-inverse': '#1c1917',
'--border': '#3a3530',
'--border-subtle': '#2a2624',
'--accent': '#7a9a6b',
'--accent-hover': '#8fad7f',
'--accent-muted': '#3d4d36',
'--tan': '#b8a88a',
'--tan-muted': '#4a4235',
'--pin-origin': '#6b8f5e',
'--pin-destination': '#a67c52',
'--pin-intermediate': '#6b7268',
'--pin-stroke': '#1c1917',
'--status-success': '#6b8f5e',
'--status-warning': '#b89a4a',
'--status-danger': '#a65c52',
'--route-line': '#7a9a6b',
'--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
*/
const lightUI = {
'--bg-base': '#ddd2b9',
'--bg-raised': '#e8dec8',
'--bg-overlay': '#e3d9c1',
'--bg-input': '#e8dec8',
'--text-primary': '#1a1d1a',
'--text-secondary': '#4f5a49',
'--text-tertiary': '#7a8674',
'--text-inverse': '#f5f2ed',
'--border': '#c4b89e',
'--border-subtle': '#d5cab2',
'--accent': '#4a7040',
'--accent-hover': '#3d5e35',
'--accent-muted': '#dce8d6',
'--tan': '#8a7556',
'--tan-muted': '#f0e8d8',
'--pin-origin': '#4a7040',
'--pin-destination': '#8a5c35',
'--pin-intermediate': '#6b6960',
'--pin-stroke': '#1a1d1a',
'--status-success': '#4a7040',
'--status-warning': '#8a7040',
'--status-danger': '#8a4040',
'--route-line': '#4a7040',
'--shadow': '0 2px 8px rgba(0, 0, 0, 0.08)',
'--shadow-lg': '0 4px 16px rgba(0, 0, 0, 0.12)',
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// OVERLAY CONFIGURATIONS // OVERLAY CONFIGURATIONS
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@ -361,6 +430,7 @@ const themes = {
colors: null, // Use namedTheme('light') colors: null, // Use namedTheme('light')
satellite: null, satellite: null,
overlay: lightOverlay, overlay: lightOverlay,
ui: lightUI,
}, },
dark: { dark: {
id: 'dark', id: 'dark',
@ -369,6 +439,7 @@ const themes = {
colors: null, // Use namedTheme('dark') colors: null, // Use namedTheme('dark')
satellite: null, satellite: null,
overlay: darkOverlay, overlay: darkOverlay,
ui: darkUI,
}, },
// Custom themes go here. Example: // Custom themes go here. Example:
// 'midnight': { // 'midnight': {
@ -378,6 +449,7 @@ const themes = {
// colors: { /* full flavor object matching dark-flavor-reference.json schema */ }, // colors: { /* full flavor object matching dark-flavor-reference.json schema */ },
// 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 */ },
// }, // },
} }
@ -461,6 +533,36 @@ export function getOverlayConfig(themeId, layerKey) {
return baseConfig return baseConfig
} }
/**
* Apply theme UI CSS custom properties to the document
*
* Sets the data-theme attribute AND applies all CSS variables from the
* theme's ui object directly to document.documentElement.style.
*
* For custom themes, missing ui keys fall back to the appropriate built-in
* theme (dark or light based on theme.dark flag).
*
* @param {object} theme - Theme config object (from getTheme())
*/
export function applyThemeUI(theme) {
const root = document.documentElement
// Set data-theme attribute for any CSS selectors that still reference it
root.setAttribute('data-theme', theme.id)
// Get base UI config from appropriate built-in theme
const builtinTheme = theme.dark ? themes.dark : themes.light
const baseUI = builtinTheme.ui
// Merge with any custom theme overrides
const ui = theme.ui ? { ...baseUI, ...theme.ui } : baseUI
// Apply all UI variables directly to root element style
for (const [prop, value] of Object.entries(ui)) {
root.style.setProperty(prop, value)
}
}
/** /**
* 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}>}