From f0acea33a097a72c5589819281eef4045d5b8e6b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 16:17:26 +0000 Subject: [PATCH] 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 --- src/hooks/useTheme.js | 13 ++++-- src/index.css | 78 ++----------------------------- src/themes/README.md | 91 ++++++++++++++++++++++++++++++------ src/themes/registry.js | 102 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 93 deletions(-) diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js index f09d835..fa374e5 100644 --- a/src/hooks/useTheme.js +++ b/src/hooks/useTheme.js @@ -1,12 +1,13 @@ import { useEffect } from 'react' import { useStore } from '../store' +import { getTheme, applyThemeUI } from '../themes/registry' /** * Initializes and manages the theme system. * Call once in App — it handles: * - Reading localStorage override on mount * - Listening to system prefers-color-scheme - * - Applying data-theme to + * - Applying theme UI via registry (CSS custom properties) * - Updating store.theme (resolved value) */ export function useTheme() { @@ -16,8 +17,11 @@ export function useTheme() { // Initialize override from localStorage on first mount useEffect(() => { const stored = localStorage.getItem('navi-theme-override') - if (stored === 'dark' || stored === 'light') { - useStore.getState().setThemeOverride(stored) + if (stored) { + const theme = getTheme(stored) + if (theme) { + useStore.getState().setThemeOverride(stored) + } } }, []) @@ -30,7 +34,8 @@ export function useTheme() { function apply() { const resolved = resolve() - document.documentElement.setAttribute('data-theme', resolved) + const theme = getTheme(resolved) + applyThemeUI(theme) setTheme(resolved) } diff --git a/src/index.css b/src/index.css index 84ab9b2..8368268 100644 --- a/src/index.css +++ b/src/index.css @@ -4,6 +4,10 @@ NAVI DESIGN TOKENS Warm grays, sage greens, khaki tans, deep blacks. 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 { @@ -19,80 +23,6 @@ --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 ═══ */ html, body, #root { margin: 0; diff --git a/src/themes/README.md b/src/themes/README.md index e110d59..baf4aea 100644 --- a/src/themes/README.md +++ b/src/themes/README.md @@ -4,7 +4,7 @@ This directory contains the theme registry and reference files for creating cust ## 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 - **light-flavor-reference.json** - Full namedTheme('light') output for reference @@ -19,11 +19,11 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: ```javascript { // === FLAT COLOR KEYS (73 total) === - + // Background & earth "background": "#34373d", "earth": "#1f1f1f", - + // Land use areas "park_a": "#1c2421", "park_b": "#192a24", @@ -45,7 +45,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "military": "#242323", "pier": "#333333", "buildings": "#111111", - + // Tunnels "tunnel_other_casing": "#141414", "tunnel_minor_casing": "#141414", @@ -57,7 +57,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "tunnel_link": "#292929", "tunnel_major": "#292929", "tunnel_highway": "#292929", - + // Roads & casings "minor_service_casing": "#1f1f1f", "minor_casing": "#1f1f1f", @@ -75,7 +75,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "highway": "#474747", "railway": "#000000", "boundaries": "#5b6374", - + // Bridges "bridges_other_casing": "#2b2b2b", "bridges_minor_casing": "#1f1f1f", @@ -87,7 +87,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "bridges_link": "#3d3d3d", "bridges_major": "#3d3d3d", "bridges_highway": "#474747", - + // Labels "waterway_label": "#717784", "roads_label_minor": "#525252", @@ -105,9 +105,9 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "country_label": "#5c5c5c", "address_label": "#525252", "address_label_halo": "#1f1f1f", - + // === NESTED OBJECTS (REQUIRED) === - + // POI icon colors - all 8 keys required "pois": { "blue": "#4299BB", @@ -119,7 +119,7 @@ The flavor object has **73 flat color keys** plus **2 nested objects**: "tangerine": "#F19B6E", "turquoise": "#00C3D4" }, - + // Landcover fill colors - all 7 keys required "landcover": { "grassland": "rgba(30, 41, 31, 1)", @@ -140,11 +140,11 @@ Add custom themes to `registry.js`: ```javascript const themes = { // ... existing themes ... - + 'sepia': { id: 'sepia', name: 'Sepia', - dark: false, // Affects overlay styling and sprite fallback + dark: false, // Affects overlay styling, sprite fallback, and UI cascade colors: { // Full flavor object (all 73 flat keys + pois + landcover) }, @@ -157,14 +157,75 @@ const themes = { saturation: 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 -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 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 diff --git a/src/themes/registry.js b/src/themes/registry.js index 1ef2f04..95246f9 100644 --- a/src/themes/registry.js +++ b/src/themes/registry.js @@ -11,10 +11,79 @@ * 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 */ 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 // ═══════════════════════════════════════════════════════════════════════════ @@ -361,6 +430,7 @@ const themes = { colors: null, // Use namedTheme('light') satellite: null, overlay: lightOverlay, + ui: lightUI, }, dark: { id: 'dark', @@ -369,6 +439,7 @@ const themes = { colors: null, // Use namedTheme('dark') satellite: null, overlay: darkOverlay, + ui: darkUI, }, // Custom themes go here. Example: // 'midnight': { @@ -378,6 +449,7 @@ const themes = { // colors: { /* full flavor object matching dark-flavor-reference.json schema */ }, // 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 */ }, // }, } @@ -461,6 +533,36 @@ export function getOverlayConfig(themeId, layerKey) { 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 * @returns {Array<{id: string, name: string, dark: boolean}>}