From c5c7373e10f48cd9ba11fb3627311fdd6dfae787 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 18 Jun 2026 09:53:40 +0800 Subject: [PATCH] feat(theme): add 5 preset color themes (Nord/Gruvbox/Monokai/Dracula/Solarized) with popover selector Implements Approach C (dual-attribute: data-theme + data-theme-preset), keeping all 106 existing [data-theme="dark"] overrides unchanged. - Colors: 5 professionally designed oklch palettes in tokens/colors.css - UI: popover theme selector with mode (Light/Dark/Auto) + preset grid - JS: cycleTheme(), setPreset(), localStorage persistence - Locale: 12 new translation keys across 10 languages - Polish: solid accent swatches matching flat token-driven aesthetic --- locales/de.json | 13 +- locales/en.json | 13 +- locales/es.json | 13 +- locales/fr.json | 13 +- locales/he.json | 13 +- locales/ja.json | 13 +- locales/ko.json | 13 +- locales/ru.json | 13 +- locales/zh-CN.json | 13 +- locales/zh-TW.json | 13 +- static/css/components/header.css | 203 +++++++++++++++++++- static/css/tokens/colors.css | 310 +++++++++++++++++++++++++++++++ static/js/components/Header.js | 175 +++++++++++------ static/js/utils/uiHelpers.js | 51 +++-- templates/base.html | 12 +- templates/components/header.html | 49 +++++ 16 files changed, 837 insertions(+), 93 deletions(-) diff --git a/locales/de.json b/locales/de.json index 6213cad3..783ef8d3 100644 --- a/locales/de.json +++ b/locales/de.json @@ -251,7 +251,18 @@ "toggle": "Theme wechseln", "switchToLight": "Zu hellem Theme wechseln", "switchToDark": "Zu dunklem Theme wechseln", - "switchToAuto": "Zu automatischem Theme wechseln" + "switchToAuto": "Zu automatischem Theme wechseln", + "presets": "Theme-Voreinstellungen", + "default": "Standard", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "Modus", + "light": "Hell", + "dark": "Dunkel", + "auto": "Auto" }, "actions": { "checkUpdates": "Updates prüfen", diff --git a/locales/en.json b/locales/en.json index ca93e66c..12543251 100644 --- a/locales/en.json +++ b/locales/en.json @@ -251,7 +251,18 @@ "toggle": "Toggle theme", "switchToLight": "Switch to light theme", "switchToDark": "Switch to dark theme", - "switchToAuto": "Switch to auto theme" + "switchToAuto": "Switch to auto theme", + "presets": "Theme Presets", + "default": "Default", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "Mode", + "light": "Light", + "dark": "Dark", + "auto": "Auto" }, "actions": { "checkUpdates": "Check Updates", diff --git a/locales/es.json b/locales/es.json index 25830e8a..4b00eb3e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -251,7 +251,18 @@ "toggle": "Cambiar tema", "switchToLight": "Cambiar a tema claro", "switchToDark": "Cambiar a tema oscuro", - "switchToAuto": "Cambiar a tema automático" + "switchToAuto": "Cambiar a tema automático", + "presets": "Preajustes de tema", + "default": "Predeterminado", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "Modo", + "light": "Claro", + "dark": "Oscuro", + "auto": "Auto" }, "actions": { "checkUpdates": "Comprobar actualizaciones", diff --git a/locales/fr.json b/locales/fr.json index db6d59a5..673244ff 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -251,7 +251,18 @@ "toggle": "Basculer le thème", "switchToLight": "Passer au thème clair", "switchToDark": "Passer au thème sombre", - "switchToAuto": "Passer au thème automatique" + "switchToAuto": "Passer au thème automatique", + "presets": "Préréglages de thème", + "default": "Par défaut", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "Mode", + "light": "Clair", + "dark": "Sombre", + "auto": "Auto" }, "actions": { "checkUpdates": "Vérifier les mises à jour", diff --git a/locales/he.json b/locales/he.json index 7c55e995..b6b3f6ef 100644 --- a/locales/he.json +++ b/locales/he.json @@ -251,7 +251,18 @@ "toggle": "החלף ערכת נושא", "switchToLight": "עבור לערכת נושא בהירה", "switchToDark": "עבור לערכת נושא כהה", - "switchToAuto": "עבור לערכת נושא אוטומטית" + "switchToAuto": "עבור לערכת נושא אוטומטית", + "presets": "ערכות נושא מוגדרות", + "default": "ברירת מחדל", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "מצב", + "light": "בהיר", + "dark": "כהה", + "auto": "אוטומטי" }, "actions": { "checkUpdates": "בדוק עדכונים", diff --git a/locales/ja.json b/locales/ja.json index 29b0998e..cda982c4 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -251,7 +251,18 @@ "toggle": "テーマの切り替え", "switchToLight": "ライトテーマに切り替え", "switchToDark": "ダークテーマに切り替え", - "switchToAuto": "自動テーマに切り替え" + "switchToAuto": "自動テーマに切り替え", + "presets": "テーマプリセット", + "default": "デフォルト", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "モード", + "light": "ライト", + "dark": "ダーク", + "auto": "自動" }, "actions": { "checkUpdates": "更新確認", diff --git a/locales/ko.json b/locales/ko.json index b0aa59a8..bccace89 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -251,7 +251,18 @@ "toggle": "테마 토글", "switchToLight": "라이트 테마로 전환", "switchToDark": "다크 테마로 전환", - "switchToAuto": "자동 테마로 전환" + "switchToAuto": "자동 테마로 전환", + "presets": "테마 프리셋", + "default": "기본", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "모드", + "light": "라이트", + "dark": "다크", + "auto": "자동" }, "actions": { "checkUpdates": "업데이트 확인", diff --git a/locales/ru.json b/locales/ru.json index c1b63c56..24dd8008 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -251,7 +251,18 @@ "toggle": "Переключить тему", "switchToLight": "Переключить на светлую тему", "switchToDark": "Переключить на тёмную тему", - "switchToAuto": "Переключить на автоматическую тему" + "switchToAuto": "Переключить на автоматическую тему", + "presets": "Предустановки тем", + "default": "По умолчанию", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "Режим", + "light": "Светлый", + "dark": "Тёмный", + "auto": "Авто" }, "actions": { "checkUpdates": "Проверить обновления", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index a034a742..0aabac47 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -251,7 +251,18 @@ "toggle": "切换主题", "switchToLight": "切换到浅色主题", "switchToDark": "切换到深色主题", - "switchToAuto": "切换到自动主题" + "switchToAuto": "切换到自动主题", + "presets": "主题预设", + "default": "默认", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "模式", + "light": "浅色", + "dark": "深色", + "auto": "自动" }, "actions": { "checkUpdates": "检查更新", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 6da1ed33..5930aab8 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -251,7 +251,18 @@ "toggle": "切換主題", "switchToLight": "切換至淺色主題", "switchToDark": "切換至深色主題", - "switchToAuto": "自動主題" + "switchToAuto": "自動主題", + "presets": "主題預設", + "default": "預設", + "nord": "Nord", + "gruvbox": "Gruvbox", + "monokai": "Monokai", + "dracula": "Dracula", + "solarized": "Solarized", + "mode": "模式", + "light": "淺色", + "dark": "深色", + "auto": "自動" }, "actions": { "checkUpdates": "檢查更新", diff --git a/static/css/components/header.css b/static/css/components/header.css index 5d790366..8cd54440 100644 --- a/static/css/components/header.css +++ b/static/css/components/header.css @@ -283,7 +283,6 @@ .theme-toggle { position: relative; - /* Ensure relative positioning for the container */ } .theme-toggle .light-icon, @@ -293,17 +292,14 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - /* Center perfectly */ opacity: 0; transition: opacity 0.3s ease; } -/* Default state shows dark icon */ .theme-toggle .dark-icon { opacity: 1; } -/* Light theme shows light icon */ .theme-toggle.theme-light .light-icon { opacity: 1; } @@ -313,7 +309,6 @@ opacity: 0; } -/* Dark theme shows dark icon */ .theme-toggle.theme-dark .dark-icon { opacity: 1; } @@ -323,7 +318,6 @@ opacity: 0; } -/* Auto theme shows auto icon */ .theme-toggle.theme-auto .auto-icon { opacity: 1; } @@ -333,6 +327,203 @@ opacity: 0; } +.theme-popover { + display: none; + position: absolute; + top: calc(100% + 8px); + right: -8px; + background: var(--surface-base, #ffffff); + border: 1px solid var(--border-base, #e0e0e0); + border-radius: var(--radius-md, 8px); + box-shadow: var(--shadow-xl, 0 4px 16px rgba(0, 0, 0, 0.15)); + padding: 12px; + min-width: 220px; + z-index: var(--z-dropdown, 200); + animation: theme-popover-in 0.15s ease-out; +} + +.theme-popover.active { + display: block; +} + +@keyframes theme-popover-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.theme-popover-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.theme-popover-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, #6c757d); +} + +.theme-popover-divider { + height: 1px; + background: var(--border-base, #e0e0e0); + margin: 10px 0; +} + +.theme-popover-modes { + display: flex; + gap: 6px; +} + +.theme-mode-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 4px; + border: 1px solid var(--border-base, #e0e0e0); + border-radius: var(--radius-sm, 6px); + background: var(--surface-elevated, #ffffff); + color: var(--text-primary, #333333); + cursor: pointer; + font-size: 0.75rem; + transition: background-color var(--transition-base, 200ms ease), + border-color var(--transition-base, 200ms ease), + color var(--transition-base, 200ms ease); +} + +.theme-mode-btn i { + font-size: 0.9rem; +} + +.theme-mode-btn:hover { + background: var(--surface-hover, oklch(95% 0.02 256)); + border-color: var(--color-accent, oklch(68% 0.28 256)); +} + +.theme-mode-btn.active { + background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12)); + border-color: var(--color-accent, oklch(68% 0.28 256)); + color: var(--color-accent, oklch(68% 0.28 256)); +} + +.theme-popover-presets { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.theme-preset-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 4px; + border: 1px solid var(--border-base, #e0e0e0); + border-radius: var(--radius-sm, 6px); + background: var(--surface-elevated, #ffffff); + color: var(--text-primary, #333333); + cursor: pointer; + font-size: 0.7rem; + transition: background-color var(--transition-base, 200ms ease), + border-color var(--transition-base, 200ms ease), + color var(--transition-base, 200ms ease); +} + +.theme-preset-btn:hover { + background: var(--surface-hover, oklch(95% 0.02 256)); + border-color: var(--color-accent, oklch(68% 0.28 256)); +} + +.theme-preset-btn.active { + background: var(--color-accent-subtle, oklch(68% 0.28 256 / 0.12)); + border-color: var(--color-accent, oklch(68% 0.28 256)); + color: var(--color-accent, oklch(68% 0.28 256)); +} + +.preset-swatch { + display: inline-block; + width: 22px; + height: 22px; + border-radius: var(--radius-xs, 4px); + border: 1px solid var(--border-subtle, oklch(72% 0.03 256 / 0.45)); + flex-shrink: 0; + transition: transform var(--transition-base, 200ms ease), + box-shadow var(--transition-base, 200ms ease); +} + +/* Solid accent colors — each swatch shows the theme's accent color directly. + This matches the app's flat, token-driven design language instead of using + decorative gradients that clash with the matte aesthetic. */ + +.preset-swatch-default { + background: oklch(68% 0.28 256); +} + +.preset-swatch-nord { + background: oklch(62% 0.18 213); +} + +.preset-swatch-gruvbox { + background: oklch(58% 0.22 25); +} + +.preset-swatch-monokai { + background: oklch(72% 0.24 190); +} + +.preset-swatch-dracula { + background: oklch(68% 0.24 265); +} + +.preset-swatch-solarized { + background: oklch(55% 0.18 175); +} + +.theme-preset-btn.active .preset-swatch { + box-shadow: 0 0 0 2px var(--color-accent, oklch(68% 0.28 256)); +} + +.theme-preset-btn:hover .preset-swatch { + transform: scale(1.08); +} + +/* Dark mode: use each preset's dark-mode accent lightness for visibility. + These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."] + in tokens/colors.css so the swatch accurately previews what the theme looks like. */ + +[data-theme="dark"] .preset-swatch-default { + background: oklch(68% 0.28 256); +} + +[data-theme="dark"] .preset-swatch-nord { + background: oklch(68% 0.18 213); +} + +[data-theme="dark"] .preset-swatch-gruvbox { + background: oklch(62% 0.22 25); +} + +[data-theme="dark"] .preset-swatch-monokai { + background: oklch(72% 0.24 190); +} + +[data-theme="dark"] .preset-swatch-dracula { + background: oklch(72% 0.24 265); +} + +[data-theme="dark"] .preset-swatch-solarized { + background: oklch(60% 0.18 175); +} + /* Badge styling */ .update-badge { position: absolute; diff --git a/static/css/tokens/colors.css b/static/css/tokens/colors.css index f50b5e95..e021394f 100644 --- a/static/css/tokens/colors.css +++ b/static/css/tokens/colors.css @@ -115,3 +115,313 @@ --favorite-color: #ffc107; } + +/* ── Preset: Nord ──────────────────────────────────────────── */ + +[data-theme-preset="nord"] { + --color-accent-h: 213; + --color-accent-c: 0.18; + --color-accent-l: 62%; + --color-warning-h: 35; + --color-warning-c: 0.18; + --color-success-h: 130; + --color-error-h: 5; + + --bg-base: oklch(96% 0.01 240); + --bg-elevated: oklch(98% 0.008 240 / 0.95); + --bg-hover: oklch(93% 0.02 240); + --bg-disabled: oklch(92% 0.01 240); + + --text-primary: oklch(22% 0.03 260); + --text-secondary: oklch(48% 0.03 260); + --text-inverse: oklch(97% 0.01 240); + + --surface-base: oklch(97% 0.01 240); + --surface-elevated: oklch(98% 0.008 240 / 0.95); + --surface-hover: oklch(93% 0.02 240); + --surface-subtle: oklch(0% 0 0 / 0.03); + + --border-base: oklch(82% 0.03 240); + --border-subtle: oklch(82% 0.03 240 / 0.45); + + --favorite-color: oklch(72% 0.14 85); + --favorite-glow: oklch(72% 0.14 85 / 0.5); +} + +[data-theme="dark"][data-theme-preset="nord"] { + --color-accent-h: 213; + --color-accent-c: 0.18; + --color-accent-l: 68%; + --color-warning-h: 35; + --color-warning-c: 0.18; + --color-success-h: 130; + --color-error-h: 5; + + --bg-base: oklch(20% 0.03 260); + --bg-elevated: oklch(24% 0.03 260 / 0.98); + --bg-hover: oklch(30% 0.03 260); + --bg-disabled: oklch(30% 0.02 260); + + --text-primary: oklch(87% 0.02 240); + --text-secondary: oklch(68% 0.02 240); + --text-inverse: oklch(20% 0.03 260); + + --surface-base: oklch(26% 0.03 260); + --surface-elevated: oklch(24% 0.03 260 / 0.98); + --surface-hover: oklch(30% 0.03 260); + --surface-subtle: oklch(100% 0 0 / 0.03); + + --border-base: oklch(38% 0.03 260); + --border-subtle: oklch(87% 0.02 240 / 0.15); + + --favorite-color: oklch(78% 0.15 85); + --favorite-glow: oklch(78% 0.15 85 / 0.5); +} + +/* ── Preset: Gruvbox ───────────────────────────────────────── */ + +[data-theme-preset="gruvbox"] { + --color-accent-h: 25; + --color-accent-c: 0.22; + --color-accent-l: 58%; + --color-warning-h: 45; + --color-warning-c: 0.22; + --color-success-h: 120; + --color-error-h: 4; + + --bg-base: oklch(95% 0.02 80); + --bg-elevated: oklch(97% 0.015 80 / 0.95); + --bg-hover: oklch(91% 0.03 80); + --bg-disabled: oklch(90% 0.02 80); + + --text-primary: oklch(28% 0.03 55); + --text-secondary: oklch(48% 0.03 55); + --text-inverse: oklch(95% 0.02 80); + + --surface-base: oklch(96% 0.015 80); + --surface-elevated: oklch(97% 0.015 80 / 0.95); + --surface-hover: oklch(91% 0.03 80); + --surface-subtle: oklch(0% 0 0 / 0.03); + + --border-base: oklch(78% 0.04 75); + --border-subtle: oklch(78% 0.04 75 / 0.45); + + --favorite-color: oklch(72% 0.16 75); + --favorite-glow: oklch(72% 0.16 75 / 0.5); +} + +[data-theme="dark"][data-theme-preset="gruvbox"] { + --color-accent-h: 25; + --color-accent-c: 0.22; + --color-accent-l: 62%; + --color-warning-h: 45; + --color-warning-c: 0.22; + --color-success-h: 120; + --color-error-h: 4; + + --bg-base: oklch(22% 0.02 55); + --bg-elevated: oklch(26% 0.025 55 / 0.98); + --bg-hover: oklch(32% 0.03 55); + --bg-disabled: oklch(30% 0.02 55); + + --text-primary: oklch(85% 0.03 75); + --text-secondary: oklch(68% 0.03 75); + --text-inverse: oklch(22% 0.02 55); + + --surface-base: oklch(28% 0.025 55); + --surface-elevated: oklch(26% 0.025 55 / 0.98); + --surface-hover: oklch(32% 0.03 55); + --surface-subtle: oklch(100% 0 0 / 0.03); + + --border-base: oklch(38% 0.03 55); + --border-subtle: oklch(85% 0.03 75 / 0.15); + + --favorite-color: oklch(78% 0.16 75); + --favorite-glow: oklch(78% 0.16 75 / 0.5); +} + +/* ── Preset: Monokai ───────────────────────────────────────── */ + +[data-theme-preset="monokai"] { + --color-accent-h: 190; + --color-accent-c: 0.24; + --color-accent-l: 72%; + --color-warning-h: 50; + --color-warning-c: 0.22; + --color-success-h: 140; + --color-error-h: 340; + + --bg-base: oklch(96% 0.01 80); + --bg-elevated: oklch(98% 0.005 80 / 0.95); + --bg-hover: oklch(93% 0.015 80); + --bg-disabled: oklch(92% 0.01 80); + + --text-primary: oklch(20% 0.02 100); + --text-secondary: oklch(45% 0.02 100); + --text-inverse: oklch(97% 0.01 80); + + --surface-base: oklch(97% 0.008 80); + --surface-elevated: oklch(98% 0.005 80 / 0.95); + --surface-hover: oklch(93% 0.015 80); + --surface-subtle: oklch(0% 0 0 / 0.03); + + --border-base: oklch(80% 0.02 80); + --border-subtle: oklch(80% 0.02 80 / 0.45); + + --favorite-color: oklch(72% 0.16 85); + --favorite-glow: oklch(72% 0.16 85 / 0.5); +} + +[data-theme="dark"][data-theme-preset="monokai"] { + --color-accent-h: 190; + --color-accent-c: 0.24; + --color-accent-l: 72%; + --color-warning-h: 50; + --color-warning-c: 0.22; + --color-success-h: 140; + --color-error-h: 340; + + --bg-base: oklch(18% 0.02 100); + --bg-elevated: oklch(22% 0.02 100 / 0.98); + --bg-hover: oklch(28% 0.025 100); + --bg-disabled: oklch(28% 0.015 100); + + --text-primary: oklch(90% 0.02 80); + --text-secondary: oklch(70% 0.02 80); + --text-inverse: oklch(18% 0.02 100); + + --surface-base: oklch(24% 0.02 100); + --surface-elevated: oklch(22% 0.02 100 / 0.98); + --surface-hover: oklch(28% 0.025 100); + --surface-subtle: oklch(100% 0 0 / 0.03); + + --border-base: oklch(36% 0.02 100); + --border-subtle: oklch(90% 0.02 80 / 0.15); + + --favorite-color: oklch(78% 0.16 85); + --favorite-glow: oklch(78% 0.16 85 / 0.5); +} + +/* ── Preset: Dracula ───────────────────────────────────────── */ + +[data-theme-preset="dracula"] { + --color-accent-h: 265; + --color-accent-c: 0.24; + --color-accent-l: 68%; + --color-warning-h: 45; + --color-warning-c: 0.22; + --color-success-h: 135; + --color-error-h: 350; + + --bg-base: oklch(96% 0.01 290); + --bg-elevated: oklch(98% 0.008 290 / 0.95); + --bg-hover: oklch(93% 0.02 290); + --bg-disabled: oklch(92% 0.01 290); + + --text-primary: oklch(22% 0.04 290); + --text-secondary: oklch(48% 0.04 290); + --text-inverse: oklch(97% 0.01 290); + + --surface-base: oklch(97% 0.01 290); + --surface-elevated: oklch(98% 0.008 290 / 0.95); + --surface-hover: oklch(93% 0.02 290); + --surface-subtle: oklch(0% 0 0 / 0.03); + + --border-base: oklch(80% 0.04 290); + --border-subtle: oklch(80% 0.04 290 / 0.45); + + --favorite-color: oklch(72% 0.16 85); + --favorite-glow: oklch(72% 0.16 85 / 0.5); +} + +[data-theme="dark"][data-theme-preset="dracula"] { + --color-accent-h: 265; + --color-accent-c: 0.24; + --color-accent-l: 72%; + --color-warning-h: 45; + --color-warning-c: 0.22; + --color-success-h: 135; + --color-error-h: 350; + + --bg-base: oklch(18% 0.04 290); + --bg-elevated: oklch(22% 0.04 290 / 0.98); + --bg-hover: oklch(28% 0.04 290); + --bg-disabled: oklch(28% 0.03 290); + + --text-primary: oklch(90% 0.02 290); + --text-secondary: oklch(70% 0.03 290); + --text-inverse: oklch(18% 0.04 290); + + --surface-base: oklch(24% 0.04 290); + --surface-elevated: oklch(22% 0.04 290 / 0.98); + --surface-hover: oklch(28% 0.04 290); + --surface-subtle: oklch(100% 0 0 / 0.03); + + --border-base: oklch(36% 0.04 290); + --border-subtle: oklch(90% 0.02 290 / 0.15); + + --favorite-color: oklch(78% 0.16 85); + --favorite-glow: oklch(78% 0.16 85 / 0.5); +} + +/* ── Preset: Solarized ─────────────────────────────────────── */ + +[data-theme-preset="solarized"] { + --color-accent-h: 175; + --color-accent-c: 0.18; + --color-accent-l: 55%; + --color-warning-h: 45; + --color-warning-c: 0.20; + --color-success-h: 68; + --color-error-h: 25; + + --bg-base: oklch(95% 0.03 85); + --bg-elevated: oklch(97% 0.025 85 / 0.95); + --bg-hover: oklch(91% 0.035 85); + --bg-disabled: oklch(90% 0.025 85); + + --text-primary: oklch(30% 0.06 200); + --text-secondary: oklch(50% 0.04 200); + --text-inverse: oklch(95% 0.03 85); + + --surface-base: oklch(96% 0.025 85); + --surface-elevated: oklch(97% 0.025 85 / 0.95); + --surface-hover: oklch(91% 0.035 85); + --surface-subtle: oklch(0% 0 0 / 0.03); + + --border-base: oklch(78% 0.04 85); + --border-subtle: oklch(78% 0.04 85 / 0.45); + + --favorite-color: oklch(68% 0.16 75); + --favorite-glow: oklch(68% 0.16 75 / 0.5); +} + +[data-theme="dark"][data-theme-preset="solarized"] { + --color-accent-h: 175; + --color-accent-c: 0.18; + --color-accent-l: 60%; + --color-warning-h: 45; + --color-warning-c: 0.20; + --color-success-h: 68; + --color-error-h: 25; + + --bg-base: oklch(18% 0.05 200); + --bg-elevated: oklch(22% 0.05 200 / 0.98); + --bg-hover: oklch(28% 0.05 200); + --bg-disabled: oklch(28% 0.04 200); + + --text-primary: oklch(72% 0.03 85); + --text-secondary: oklch(58% 0.03 85); + --text-inverse: oklch(18% 0.05 200); + + --surface-base: oklch(24% 0.05 200); + --surface-elevated: oklch(22% 0.05 200 / 0.98); + --surface-hover: oklch(28% 0.05 200); + --surface-subtle: oklch(100% 0 0 / 0.03); + + --border-base: oklch(36% 0.04 200); + --border-subtle: oklch(72% 0.03 85 / 0.15); + + --favorite-color: oklch(72% 0.16 75); + --favorite-glow: oklch(72% 0.16 75 / 0.5); +} diff --git a/static/js/components/Header.js b/static/js/components/Header.js index c9e8ef3a..34008baf 100644 --- a/static/js/components/Header.js +++ b/static/js/components/Header.js @@ -1,9 +1,9 @@ import { updateService } from '../managers/UpdateService.js'; -import { toggleTheme } from '../utils/uiHelpers.js'; +import { toggleTheme, setPreset, CYCLE_ORDER, PRESET_NAMES } from '../utils/uiHelpers.js'; import { SearchManager } from '../managers/SearchManager.js'; import { FilterManager } from '../managers/FilterManager.js'; import { initPageState } from '../state/index.js'; -import { getStorageItem } from '../utils/storageHelpers.js'; +import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { updateElementAttribute } from '../utils/i18nHelpers.js'; import { renderSupporters } from '../services/supportersService.js'; @@ -47,25 +47,8 @@ export class HeaderManager { } initializeCommonElements() { - // Handle theme toggle - const themeToggle = document.querySelector('.theme-toggle'); - if (themeToggle) { - const currentTheme = getStorageItem('theme') || 'auto'; - themeToggle.classList.add(`theme-${currentTheme}`); + this.initializeThemePopover(); - // Use i18nHelpers to update themeToggle's title - this.updateThemeTooltip(themeToggle, currentTheme); - - themeToggle.addEventListener('click', async () => { - if (typeof toggleTheme === 'function') { - const newTheme = toggleTheme(); - // Use i18nHelpers to update themeToggle's title - this.updateThemeTooltip(themeToggle, newTheme); - } - }); - } - - // Handle settings toggle const settingsToggle = document.querySelector('.settings-toggle'); if (settingsToggle) { settingsToggle.addEventListener('click', () => { @@ -74,22 +57,19 @@ export class HeaderManager { } }); } - - // Handle update toggle + const updateToggle = document.getElementById('updateToggleBtn'); if (updateToggle) { updateToggle.addEventListener('click', () => { updateService.toggleUpdateModal(); }); } - - // Handle support toggle + const supportToggle = document.getElementById('supportToggleBtn'); if (supportToggle) { supportToggle.addEventListener('click', async () => { if (window.modalManager) { window.modalManager.toggleModal('supportModal'); - // Load supporters data when modal opens try { await renderSupporters(); } catch (error) { @@ -99,41 +79,126 @@ export class HeaderManager { }); } - // Handle QR code toggle const qrToggle = document.getElementById('toggleQRCode'); const qrContainer = document.getElementById('qrCodeContainer'); - - if (qrToggle && qrContainer) { - qrToggle.addEventListener('click', function() { - qrContainer.classList.toggle('show'); - qrToggle.classList.toggle('active'); - - const toggleText = qrToggle.querySelector('.toggle-text'); - if (qrContainer.classList.contains('show')) { - toggleText.textContent = 'Hide WeChat QR Code'; - // Add small delay to ensure DOM is updated before scrolling - setTimeout(() => { - const supportModal = document.querySelector('.support-modal'); - if (supportModal) { - supportModal.scrollTo({ - top: supportModal.scrollHeight, - behavior: 'smooth' - }); - } - }, 250); - } else { - toggleText.textContent = 'Show WeChat QR Code'; - } - }); - } - - // Hide search functionality on Statistics page - this.updateHeaderForPage(); - // Initialize hamburger menu for mobile + if (qrToggle && qrContainer) { + qrToggle.addEventListener('click', function () { + qrContainer.classList.toggle('show'); + qrToggle.classList.toggle('active'); + + const toggleText = qrToggle.querySelector('.toggle-text'); + if (qrContainer.classList.contains('show')) { + toggleText.textContent = 'Hide WeChat QR Code'; + setTimeout(() => { + const supportModal = document.querySelector('.support-modal'); + if (supportModal) { + supportModal.scrollTo({ + top: supportModal.scrollHeight, + behavior: 'smooth' + }); + } + }, 250); + } else { + toggleText.textContent = 'Show WeChat QR Code'; + } + }); + } + + this.updateHeaderForPage(); this.initializeHamburgerMenu(); } + initializeThemePopover() { + const themeToggle = document.querySelector('.theme-toggle'); + const themePopover = document.getElementById('themePopover'); + if (!themeToggle || !themePopover) return; + + const currentTheme = getStorageItem('theme') || 'auto'; + const currentPreset = getStorageItem('theme_preset') || 'default'; + themeToggle.classList.add(`theme-${currentTheme}`); + this.updateThemeTooltip(themeToggle, currentTheme); + this.updatePopoverActiveStates(currentTheme, currentPreset); + + themeToggle.addEventListener('click', (e) => { + if (e.target.closest('.theme-popover')) return; + e.stopPropagation(); + const isOpen = themePopover.classList.contains('active'); + this.closeAllPopovers(); + if (!isOpen) { + themePopover.classList.add('active'); + } + }); + + themePopover.addEventListener('click', (e) => { + e.stopPropagation(); + const modeBtn = e.target.closest('.theme-mode-btn'); + const presetBtn = e.target.closest('.theme-preset-btn'); + + if (modeBtn) { + const mode = modeBtn.dataset.mode; + this.setThemeMode(mode); + } else if (presetBtn) { + const preset = presetBtn.dataset.preset; + this.setThemePreset(preset); + } + }); + + document.addEventListener('click', (e) => { + if (!themeToggle.contains(e.target) && !themePopover.contains(e.target)) { + themePopover.classList.remove('active'); + } + }); + } + + closeAllPopovers() { + const themePopover = document.getElementById('themePopover'); + if (themePopover) { + themePopover.classList.remove('active'); + } + } + + setThemeMode(mode) { + setStorageItem('theme', mode); + const htmlElement = document.documentElement; + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + htmlElement.removeAttribute('data-theme'); + if (mode === 'dark' || (mode === 'auto' && prefersDark)) { + htmlElement.setAttribute('data-theme', 'dark'); + document.body.dataset.theme = 'dark'; + } else { + htmlElement.setAttribute('data-theme', 'light'); + document.body.dataset.theme = 'light'; + } + const themeToggle = document.querySelector('.theme-toggle'); + if (themeToggle) { + themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); + themeToggle.classList.add(`theme-${mode}`); + this.updateThemeTooltip(themeToggle, mode); + } + this.updateHamburgerThemeIcon(); + this.updatePopoverActiveStates(mode, getStorageItem('theme_preset') || 'default'); + } + + setThemePreset(preset) { + setPreset(preset); + this.updatePopoverActiveStates(getStorageItem('theme') || 'auto', preset); + this.updateHamburgerThemeIcon(); + } + + updatePopoverActiveStates(theme, preset) { + const popover = document.getElementById('themePopover'); + if (!popover) return; + + popover.querySelectorAll('.theme-mode-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === theme); + }); + + popover.querySelectorAll('.theme-preset-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.preset === preset); + }); + } + initializeHamburgerMenu() { const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); const hamburgerDropdown = document.getElementById('hamburgerDropdown'); @@ -188,7 +253,6 @@ export class HeaderManager { case 'theme': if (typeof toggleTheme === 'function') { const newTheme = toggleTheme(); - // Update theme toggle in header if it exists const themeToggle = document.querySelector('.theme-toggle'); if (themeToggle) { themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); @@ -196,6 +260,7 @@ export class HeaderManager { this.updateThemeTooltip(themeToggle, newTheme); } this.updateHamburgerThemeIcon(); + this.updatePopoverActiveStates(newTheme, getStorageItem('theme_preset') || 'default'); } break; case 'settings': diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 6c76a5e1..20b6d60b 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -197,11 +197,17 @@ export function restoreFolderFilter() { } } +const CYCLE_ORDER = ['auto', 'light', 'dark']; +const PRESET_NAMES = ['default', 'nord', 'gruvbox', 'monokai', 'dracula', 'solarized']; + +export { CYCLE_ORDER, PRESET_NAMES }; + export function initTheme() { const savedTheme = getStorageItem('theme') || 'auto'; + const savedPreset = getStorageItem('theme_preset') || 'default'; applyTheme(savedTheme); + applyPreset(savedPreset); - // Update theme when system preference changes (for 'auto' mode) window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { const currentTheme = getStorageItem('theme') || 'auto'; if (currentTheme === 'auto') { @@ -212,34 +218,44 @@ export function initTheme() { export function toggleTheme() { const currentTheme = getStorageItem('theme') || 'auto'; - let newTheme; - - if (currentTheme === 'light') { - newTheme = 'dark'; - } else { - newTheme = 'light'; - } + const currentIndex = CYCLE_ORDER.indexOf(currentTheme); + const nextIndex = (currentIndex + 1) % CYCLE_ORDER.length; + const newTheme = CYCLE_ORDER[nextIndex]; setStorageItem('theme', newTheme); applyTheme(newTheme); - // Force a repaint to ensure theme changes are applied immediately document.body.style.display = 'none'; - document.body.offsetHeight; // Trigger a reflow + document.body.offsetHeight; document.body.style.display = ''; return newTheme; } -// Add a new helper function to apply the theme +export function cyclePreset() { + const currentPreset = getStorageItem('theme_preset') || 'default'; + const currentIndex = PRESET_NAMES.indexOf(currentPreset); + const nextIndex = (currentIndex + 1) % PRESET_NAMES.length; + const newPreset = PRESET_NAMES[nextIndex]; + + setStorageItem('theme_preset', newPreset); + applyPreset(newPreset); + + return newPreset; +} + +export function setPreset(name) { + if (!PRESET_NAMES.includes(name)) return; + setStorageItem('theme_preset', name); + applyPreset(name); +} + function applyTheme(theme) { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const htmlElement = document.documentElement; - // Remove any existing theme attributes htmlElement.removeAttribute('data-theme'); - // Apply the appropriate theme if (theme === 'dark' || (theme === 'auto' && prefersDark)) { htmlElement.setAttribute('data-theme', 'dark'); document.body.dataset.theme = 'dark'; @@ -248,19 +264,18 @@ function applyTheme(theme) { document.body.dataset.theme = 'light'; } - // Update the theme-toggle icon state updateThemeToggleIcons(theme); } -// New function to update theme toggle icons +function applyPreset(preset) { + document.documentElement.setAttribute('data-theme-preset', preset); +} + function updateThemeToggleIcons(theme) { const themeToggle = document.querySelector('.theme-toggle'); if (!themeToggle) return; - // Remove any existing active classes themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); - - // Add the appropriate class based on current theme themeToggle.classList.add(`theme-${theme}`); } diff --git a/templates/base.html b/templates/base.html index 019c79b3..ff67f783 100644 --- a/templates/base.html +++ b/templates/base.html @@ -46,16 +46,20 @@ {% block head_scripts %}{% endblock %} diff --git a/templates/components/header.html b/templates/components/header.html index 95d5a2ea..35a499bd 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -72,6 +72,55 @@ +
+
+
{{ t('header.theme.mode') }}
+
+ + + +
+
+
+
+
{{ t('header.theme.presets') }}
+
+ + + + + + +
+
+