From c48cd87d16067e2ecc4350900baac9ec4a162d53 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 21 Sep 2025 20:28:33 -0700 Subject: [PATCH] feat(frontend): add theme switcher with light dark and system modes --- frontend/static/css/style.css | 346 +++++++++++++++++------------- frontend/static/js/main.js | 146 +++++++++++++ frontend/templates/cli.html | 81 ++++++- frontend/templates/editor.html | 85 +++++++- frontend/templates/index.html | 81 ++++++- frontend/templates/templates.html | 81 +++++++ 6 files changed, 670 insertions(+), 150 deletions(-) diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css index 380a4c3..08faa52 100644 --- a/frontend/static/css/style.css +++ b/frontend/static/css/style.css @@ -1,6 +1,7 @@ /* UnitForge CSS Styles */ :root { + color-scheme: light; --primary-color: #0d6efd; --secondary-color: #6c757d; --success-color: #198754; @@ -12,6 +13,80 @@ --border-radius: 0.375rem; --box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); --box-shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --body-bg: #ffffff; + --surface-bg: #ffffff; + --surface-alt-bg: #f8f9fa; + --surface-border: #dee2e6; + --text-color: #212529; + --muted-color: #6c757d; + --muted-on-dark-color: #cbd5e0; + --divider-color: #f1f3f4; + --hero-bg-start: #f8f9fa; + --hero-bg-stop: #e9ecef; + --code-bg: #1a202c; + --code-color: #f7fafc; + --placeholder-color: #6c757d; + --input-bg: #ffffff; + --input-text-color: #212529; + --theme-color: #0d6efd; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --primary-color: #3b82f6; + --secondary-color: #94a3b8; + --success-color: #34d399; + --danger-color: #f87171; + --warning-color: #facc15; + --info-color: #38bdf8; + --light-color: #1f2937; + --dark-color: #e2e8f0; + --body-bg: #0f172a; + --surface-bg: #1e293b; + --surface-alt-bg: #16213b; + --surface-border: #334155; + --text-color: #e2e8f0; + --muted-color: #94a3b8; + --muted-on-dark-color: #a8b4cc; + --divider-color: #273449; + --hero-bg-start: #111c2f; + --hero-bg-stop: #1e293b; + --code-bg: #111c2f; + --code-color: #e2e8f0; + --placeholder-color: #94a3b8; + --input-bg: #16213b; + --input-text-color: #e2e8f0; + --theme-color: #0b1120; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + color-scheme: dark; + --primary-color: #3b82f6; + --secondary-color: #94a3b8; + --success-color: #34d399; + --danger-color: #f87171; + --warning-color: #facc15; + --info-color: #38bdf8; + --light-color: #1f2937; + --dark-color: #e2e8f0; + --body-bg: #0f172a; + --surface-bg: #1e293b; + --surface-alt-bg: #16213b; + --surface-border: #334155; + --text-color: #e2e8f0; + --muted-color: #94a3b8; + --muted-on-dark-color: #a8b4cc; + --divider-color: #273449; + --hero-bg-start: #111c2f; + --hero-bg-stop: #1e293b; + --code-bg: #111c2f; + --code-color: #e2e8f0; + --placeholder-color: #94a3b8; + --input-bg: #16213b; + --input-text-color: #e2e8f0; + --theme-color: #0b1120; + } } /* General Styles */ @@ -29,12 +104,14 @@ body { font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; - color: var(--dark-color); + background-color: var(--body-bg); + color: var(--text-color); + transition: background-color 0.3s ease, color 0.3s ease; } .hero-section { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-bottom: 1px solid #dee2e6; + background: linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-stop) 100%); + border-bottom: 1px solid var(--surface-border); } /* Feature Icons */ @@ -57,15 +134,22 @@ body { box-shadow: var(--box-shadow-lg); } +.card { + background-color: var(--surface-bg); + border-color: var(--surface-border); + color: var(--text-color); + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; +} + /* Unit Type Badges */ .unit-type-badge { - background: var(--light-color); - border: 1px solid #dee2e6; + background: var(--surface-alt-bg); + border: 1px solid var(--surface-border); border-radius: var(--border-radius); padding: 0.75rem 1rem; text-align: center; font-weight: 500; - color: var(--dark-color); + color: var(--text-color); transition: all 0.2s ease; } @@ -113,41 +197,28 @@ body { color: #f7fafc !important; } -/* Fix text-muted contrast issues */ +.bg-light { + background-color: var(--surface-alt-bg) !important; + color: var(--text-color) !important; +} + +/* Muted text helpers */ .text-muted { - color: #6c757d !important; + color: var(--muted-color) !important; } -/* Better contrast for text-muted on dark backgrounds */ -.bg-dark .text-muted, -.card.bg-dark .text-muted { - color: #cbd5e0 !important; -} - -/* Better contrast for text-muted in cards */ -.card .text-muted { - color: #495057 !important; -} - -/* Ensure text-muted in footer has proper contrast */ -footer.bg-dark .text-muted { - color: #9ca3af !important; -} - -/* Fix text-muted in modals with dark backgrounds */ -.modal.bg-dark .text-muted, -.modal-content.bg-dark .text-muted { - color: #cbd5e0 !important; -} - -/* Better contrast for text-muted in forms on dark backgrounds */ -.bg-dark .form-text.text-muted { - color: #a0aec0 !important; -} - -/* Specific override for hero section text-muted */ +.card .text-muted, .hero-section .text-muted { - color: #495057 !important; + color: var(--muted-color) !important; +} + +.bg-dark .text-muted, +.card.bg-dark .text-muted, +footer.bg-dark .text-muted, +.modal.bg-dark .text-muted, +.modal-content.bg-dark .text-muted, +.bg-dark .form-text.text-muted { + color: var(--muted-on-dark-color) !important; } /* Editor Specific Styles */ @@ -218,7 +289,7 @@ footer.bg-dark .text-muted { /* Info Items */ .info-item { padding: 0.25rem 0; - border-bottom: 1px solid #f1f3f4; + border-bottom: 1px solid var(--divider-color); } .info-item:last-child { @@ -260,8 +331,8 @@ footer.bg-dark .text-muted { } .template-tag { - background: var(--light-color); - color: #495057; + background: var(--surface-alt-bg); + color: var(--text-color); padding: 0.125rem 0.5rem; border-radius: 1rem; font-size: 0.75rem; @@ -281,19 +352,36 @@ footer.bg-dark .text-muted { } .nav-pills .nav-link:hover:not(.active) { - background-color: var(--light-color); - color: var(--dark-color); + background-color: var(--surface-alt-bg); + color: var(--text-color); +} + +.form-control, +.form-select { + background-color: var(--input-bg); + color: var(--input-text-color); + border-color: var(--surface-border); + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; +} + +.form-control::placeholder { + color: var(--placeholder-color); + opacity: 1; } /* Form Styles */ .form-control:focus { border-color: var(--primary-color); box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); + background-color: var(--input-bg); + color: var(--input-text-color); } .form-select:focus { border-color: var(--primary-color); box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); + background-color: var(--input-bg); + color: var(--input-text-color); } /* Button Styles */ @@ -320,19 +408,21 @@ footer.bg-dark .text-muted { /* Modal Styles */ .modal-content { - border: none; + border: 1px solid var(--surface-border); border-radius: var(--border-radius); box-shadow: var(--box-shadow-lg); + background-color: var(--surface-bg); + color: var(--text-color); } .modal-header { - border-bottom: 1px solid #f1f3f4; - background: var(--light-color); + border-bottom: 1px solid var(--surface-border); + background: var(--surface-alt-bg); } .modal-footer { - border-top: 1px solid #f1f3f4; - background: var(--light-color); + border-top: 1px solid var(--surface-border); + background: var(--surface-alt-bg); } /* Loading States */ @@ -345,14 +435,14 @@ footer.bg-dark .text-muted { .parameter-group { margin-bottom: 1rem; padding: 1rem; - background: var(--light-color); + background: var(--surface-alt-bg); border-radius: var(--border-radius); - border: 1px solid #e9ecef; + border: 1px solid var(--surface-border); } .parameter-label { font-weight: 600; - color: var(--dark-color); + color: var(--text-color); margin-bottom: 0.25rem; } @@ -374,8 +464,8 @@ footer.bg-dark .text-muted { /* Preview Code Block */ .preview-code { - background: #1a202c; - color: #f7fafc; + background: var(--code-bg); + color: var(--code-color); border-radius: var(--border-radius); padding: 1rem; font-family: "Courier New", Consolas, "Liberation Mono", monospace; @@ -487,103 +577,69 @@ footer.bg-dark .text-muted { border-left: 4px solid var(--danger-color) !important; } -/* Dark Theme Support */ -@media (prefers-color-scheme: dark) { - .card { - background-color: #2d3748; - border-color: #4a5568; - color: #f7fafc; - } - - .card pre, - .card code { - color: #f7fafc !important; - } - - .card .text-muted { - color: #cbd5e0 !important; - } - - .text-muted { - color: #a0aec0 !important; - } - - /* Better text-muted for dark theme forms */ - .form-text.text-muted { - color: #9ca3af !important; - } - - .modal-content { - background-color: #2d3748; - color: #f7fafc; - } - - .form-control { - background-color: #1f2937; - border-color: #4a5568; - color: #f7fafc; - } - .form-control::placeholder { - color: #cbd5e0; - opacity: 1; - } - - .form-control:focus { - background-color: #1f2937; - border-color: var(--primary-color); - color: #f7fafc; - } - - /* Improve outline-secondary contrast on dark backgrounds */ - .card.bg-dark .btn-outline-secondary, - .bg-dark .btn-outline-secondary, - .card .btn-outline-secondary { - color: #e2e8f0; - border-color: #e2e8f0; - } - .card.bg-dark .btn-outline-secondary:hover, - .bg-dark .btn-outline-secondary:hover, - .card .btn-outline-secondary:hover { - color: #1a202c; - background-color: #e2e8f0; - border-color: #e2e8f0; - } +/* Outline button contrast helpers */ +.btn-outline-secondary { + color: var(--muted-color); + border-color: var(--muted-color); } -/* Increase link and outline-primary contrast on dark backgrounds */ -@media (prefers-color-scheme: dark) { - .card.bg-dark a, - .bg-dark a { - color: #93c5fd; - } - .card.bg-dark a:hover, - .bg-dark a:hover { - color: #bfdbfe; - } +.btn-outline-secondary:hover, +.btn-outline-secondary:focus { + color: #ffffff; + background-color: var(--muted-color); + border-color: var(--muted-color); +} - .card.bg-dark .btn-outline-primary, - .bg-dark .btn-outline-primary, - .card .btn-outline-primary { - color: #e2e8f0; - border-color: #93c5fd; - } - .card.bg-dark .btn-outline-primary:hover, - .bg-dark .btn-outline-primary:hover, - .card .btn-outline-primary:hover { - color: #1a202c; - background-color: #93c5fd; - border-color: #93c5fd; - } +.card.bg-dark .btn-outline-secondary, +.bg-dark .btn-outline-secondary { + color: #e2e8f0; + border-color: #e2e8f0; +} - /* High-contrast link utility */ - .link-contrast { - color: #93c5fd !important; - } - .link-contrast:hover, - .link-contrast:focus { - color: #bfdbfe !important; - text-decoration: underline; - } +.card.bg-dark .btn-outline-secondary:hover, +.bg-dark .btn-outline-secondary:hover, +.card.bg-dark .btn-outline-secondary:focus, +.bg-dark .btn-outline-secondary:focus { + color: #1a202c; + background-color: #e2e8f0; + border-color: #e2e8f0; +} + +.card.bg-dark a, +.bg-dark a { + color: #93c5fd; +} + +.card.bg-dark a:hover, +.bg-dark a:hover, +.card.bg-dark a:focus, +.bg-dark a:focus { + color: #bfdbfe; +} + +.card.bg-dark .btn-outline-primary, +.bg-dark .btn-outline-primary { + color: #e2e8f0; + border-color: #93c5fd; +} + +.card.bg-dark .btn-outline-primary:hover, +.bg-dark .btn-outline-primary:hover, +.card.bg-dark .btn-outline-primary:focus, +.bg-dark .btn-outline-primary:focus { + color: #1a202c; + background-color: #93c5fd; + border-color: #93c5fd; +} + +.link-contrast { + color: #93c5fd !important; +} + +.link-contrast:hover, +.link-contrast:focus { + color: #bfdbfe !important; + text-decoration: underline; } /* Footer OSI logo */ diff --git a/frontend/static/js/main.js b/frontend/static/js/main.js index faa3bcd..b8eae44 100644 --- a/frontend/static/js/main.js +++ b/frontend/static/js/main.js @@ -1,8 +1,152 @@ // UnitForge Main JavaScript // Handles general functionality across the application +class ThemeManager { + constructor() { + this.storageKey = 'unitforge-theme'; + this.themeToggleButton = document.querySelector('[data-theme-toggle]'); + this.themeLabel = document.querySelector('[data-theme-label]'); + this.themeIcon = document.querySelector('[data-theme-icon]'); + this.themeOptions = Array.from(document.querySelectorAll('[data-theme-option]')); + this.metaThemeColor = document.querySelector('meta[name="theme-color"]'); + this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + this.iconMap = { + light: 'fa-sun', + dark: 'fa-moon', + system: 'fa-circle-half-stroke' + }; + this.labelMap = { + light: 'Light', + dark: 'Dark', + system: 'System' + }; + this.currentTheme = 'system'; + + this.init(); + } + + init() { + const storedTheme = this.getStoredTheme(); + this.applyTheme(storedTheme, false); + this.attachEvents(); + } + + getStoredTheme() { + try { + const stored = localStorage.getItem(this.storageKey); + return this.normalizeTheme(stored); + } catch (error) { + console.warn('Theme preference unavailable:', error); + return 'system'; + } + } + + normalizeTheme(value) { + return value === 'light' || value === 'dark' ? value : 'system'; + } + + attachEvents() { + this.themeOptions.forEach((option) => { + option.addEventListener('click', (event) => { + event.preventDefault(); + const selection = this.normalizeTheme(option.dataset.themeOption); + this.applyTheme(selection); + }); + }); + + const handleChange = () => this.handleSystemPreferenceChange(); + if (typeof this.mediaQuery.addEventListener === 'function') { + this.mediaQuery.addEventListener('change', handleChange); + } else if (typeof this.mediaQuery.addListener === 'function') { + this.mediaQuery.addListener(handleChange); + } + } + + handleSystemPreferenceChange() { + if (this.currentTheme === 'system') { + this.applyTheme('system', false); + } + } + + applyTheme(theme, persist = true) { + const normalized = this.normalizeTheme(theme); + const root = document.documentElement; + this.currentTheme = normalized; + + if (persist) { + try { + localStorage.setItem(this.storageKey, normalized); + } catch (error) { + console.warn('Unable to persist theme preference:', error); + } + } + + if (normalized === 'light' || normalized === 'dark') { + root.setAttribute('data-theme', normalized); + } else { + root.removeAttribute('data-theme'); + } + + root.setAttribute('data-theme-preference', normalized); + + this.updateToggleUI(normalized); + this.updateMetaThemeColor(); + + const resolved = this.getResolvedTheme(normalized); + document.dispatchEvent(new CustomEvent('themechange', { + detail: { + preference: normalized, + theme: resolved + } + })); + } + + updateToggleUI(theme) { + const label = this.labelMap[theme] || this.labelMap.system; + const icon = this.iconMap[theme] || this.iconMap.system; + + if (this.themeLabel) { + this.themeLabel.textContent = label; + } + + if (this.themeIcon) { + this.themeIcon.className = `fas ${icon} me-2`; + } + + if (this.themeToggleButton) { + this.themeToggleButton.setAttribute('aria-label', `Theme: ${label}`); + } + + this.themeOptions.forEach((option) => { + const isActive = option.dataset.themeOption === theme; + option.classList.toggle('active', isActive); + option.setAttribute('aria-checked', String(isActive)); + }); + } + + updateMetaThemeColor() { + if (!this.metaThemeColor) return; + + const computedColor = getComputedStyle(document.documentElement) + .getPropertyValue('--theme-color') + .trim(); + + if (computedColor) { + this.metaThemeColor.setAttribute('content', computedColor); + } + } + + getResolvedTheme(theme = this.currentTheme) { + if (theme === 'system') { + return this.mediaQuery.matches ? 'dark' : 'light'; + } + return theme; + } +} + class UnitForge { constructor() { + this.themeManager = new ThemeManager(); this.baseUrl = window.location.origin; this.apiUrl = `${this.baseUrl}/api`; this.init(); @@ -377,3 +521,5 @@ const unitforge = new UnitForge(); // Export for use in other modules window.UnitForge = UnitForge; window.unitforge = unitforge; +window.ThemeManager = ThemeManager; +window.themeManager = unitforge.themeManager; diff --git a/frontend/templates/cli.html b/frontend/templates/cli.html index a2686a0..44c41a9 100644 --- a/frontend/templates/cli.html +++ b/frontend/templates/cli.html @@ -14,6 +14,34 @@ + @@ -48,7 +76,58 @@ CLI - -