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
This commit is contained in:
Will Miao
2026-06-18 09:53:40 +08:00
parent b7721866e5
commit c5c7373e10
16 changed files with 837 additions and 93 deletions

View File

@@ -251,7 +251,18 @@
"toggle": "Theme wechseln", "toggle": "Theme wechseln",
"switchToLight": "Zu hellem Theme wechseln", "switchToLight": "Zu hellem Theme wechseln",
"switchToDark": "Zu dunklem 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": { "actions": {
"checkUpdates": "Updates prüfen", "checkUpdates": "Updates prüfen",

View File

@@ -251,7 +251,18 @@
"toggle": "Toggle theme", "toggle": "Toggle theme",
"switchToLight": "Switch to light theme", "switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark 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": { "actions": {
"checkUpdates": "Check Updates", "checkUpdates": "Check Updates",

View File

@@ -251,7 +251,18 @@
"toggle": "Cambiar tema", "toggle": "Cambiar tema",
"switchToLight": "Cambiar a tema claro", "switchToLight": "Cambiar a tema claro",
"switchToDark": "Cambiar a tema oscuro", "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": { "actions": {
"checkUpdates": "Comprobar actualizaciones", "checkUpdates": "Comprobar actualizaciones",

View File

@@ -251,7 +251,18 @@
"toggle": "Basculer le thème", "toggle": "Basculer le thème",
"switchToLight": "Passer au thème clair", "switchToLight": "Passer au thème clair",
"switchToDark": "Passer au thème sombre", "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": { "actions": {
"checkUpdates": "Vérifier les mises à jour", "checkUpdates": "Vérifier les mises à jour",

View File

@@ -251,7 +251,18 @@
"toggle": "החלף ערכת נושא", "toggle": "החלף ערכת נושא",
"switchToLight": "עבור לערכת נושא בהירה", "switchToLight": "עבור לערכת נושא בהירה",
"switchToDark": "עבור לערכת נושא כהה", "switchToDark": "עבור לערכת נושא כהה",
"switchToAuto": "עבור לערכת נושא אוטומטית" "switchToAuto": "עבור לערכת נושא אוטומטית",
"presets": "ערכות נושא מוגדרות",
"default": "ברירת מחדל",
"nord": "Nord",
"gruvbox": "Gruvbox",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "מצב",
"light": "בהיר",
"dark": "כהה",
"auto": "אוטומטי"
}, },
"actions": { "actions": {
"checkUpdates": "בדוק עדכונים", "checkUpdates": "בדוק עדכונים",

View File

@@ -251,7 +251,18 @@
"toggle": "テーマの切り替え", "toggle": "テーマの切り替え",
"switchToLight": "ライトテーマに切り替え", "switchToLight": "ライトテーマに切り替え",
"switchToDark": "ダークテーマに切り替え", "switchToDark": "ダークテーマに切り替え",
"switchToAuto": "自動テーマに切り替え" "switchToAuto": "自動テーマに切り替え",
"presets": "テーマプリセット",
"default": "デフォルト",
"nord": "Nord",
"gruvbox": "Gruvbox",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "モード",
"light": "ライト",
"dark": "ダーク",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "更新確認", "checkUpdates": "更新確認",

View File

@@ -251,7 +251,18 @@
"toggle": "테마 토글", "toggle": "테마 토글",
"switchToLight": "라이트 테마로 전환", "switchToLight": "라이트 테마로 전환",
"switchToDark": "다크 테마로 전환", "switchToDark": "다크 테마로 전환",
"switchToAuto": "자동 테마로 전환" "switchToAuto": "자동 테마로 전환",
"presets": "테마 프리셋",
"default": "기본",
"nord": "Nord",
"gruvbox": "Gruvbox",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "모드",
"light": "라이트",
"dark": "다크",
"auto": "자동"
}, },
"actions": { "actions": {
"checkUpdates": "업데이트 확인", "checkUpdates": "업데이트 확인",

View File

@@ -251,7 +251,18 @@
"toggle": "Переключить тему", "toggle": "Переключить тему",
"switchToLight": "Переключить на светлую тему", "switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему", "switchToDark": "Переключить на тёмную тему",
"switchToAuto": "Переключить на автоматическую тему" "switchToAuto": "Переключить на автоматическую тему",
"presets": "Предустановки тем",
"default": "По умолчанию",
"nord": "Nord",
"gruvbox": "Gruvbox",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "Режим",
"light": "Светлый",
"dark": "Тёмный",
"auto": "Авто"
}, },
"actions": { "actions": {
"checkUpdates": "Проверить обновления", "checkUpdates": "Проверить обновления",

View File

@@ -251,7 +251,18 @@
"toggle": "切换主题", "toggle": "切换主题",
"switchToLight": "切换到浅色主题", "switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题", "switchToDark": "切换到深色主题",
"switchToAuto": "切换到自动主题" "switchToAuto": "切换到自动主题",
"presets": "主题预设",
"default": "默认",
"nord": "Nord",
"gruvbox": "Gruvbox",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "浅色",
"dark": "深色",
"auto": "自动"
}, },
"actions": { "actions": {
"checkUpdates": "检查更新", "checkUpdates": "检查更新",

View File

@@ -251,7 +251,18 @@
"toggle": "切換主題", "toggle": "切換主題",
"switchToLight": "切換至淺色主題", "switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題", "switchToDark": "切換至深色主題",
"switchToAuto": "自動主題" "switchToAuto": "自動主題",
"presets": "主題預設",
"default": "預設",
"nord": "Nord",
"gruvbox": "Gruvbox",
"monokai": "Monokai",
"dracula": "Dracula",
"solarized": "Solarized",
"mode": "模式",
"light": "淺色",
"dark": "深色",
"auto": "自動"
}, },
"actions": { "actions": {
"checkUpdates": "檢查更新", "checkUpdates": "檢查更新",

View File

@@ -283,7 +283,6 @@
.theme-toggle { .theme-toggle {
position: relative; position: relative;
/* Ensure relative positioning for the container */
} }
.theme-toggle .light-icon, .theme-toggle .light-icon,
@@ -293,17 +292,14 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
/* Center perfectly */
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
/* Default state shows dark icon */
.theme-toggle .dark-icon { .theme-toggle .dark-icon {
opacity: 1; opacity: 1;
} }
/* Light theme shows light icon */
.theme-toggle.theme-light .light-icon { .theme-toggle.theme-light .light-icon {
opacity: 1; opacity: 1;
} }
@@ -313,7 +309,6 @@
opacity: 0; opacity: 0;
} }
/* Dark theme shows dark icon */
.theme-toggle.theme-dark .dark-icon { .theme-toggle.theme-dark .dark-icon {
opacity: 1; opacity: 1;
} }
@@ -323,7 +318,6 @@
opacity: 0; opacity: 0;
} }
/* Auto theme shows auto icon */
.theme-toggle.theme-auto .auto-icon { .theme-toggle.theme-auto .auto-icon {
opacity: 1; opacity: 1;
} }
@@ -333,6 +327,203 @@
opacity: 0; 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 */ /* Badge styling */
.update-badge { .update-badge {
position: absolute; position: absolute;

View File

@@ -115,3 +115,313 @@
--favorite-color: #ffc107; --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);
}

View File

@@ -1,9 +1,9 @@
import { updateService } from '../managers/UpdateService.js'; 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 { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js'; import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.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 { updateElementAttribute } from '../utils/i18nHelpers.js';
import { renderSupporters } from '../services/supportersService.js'; import { renderSupporters } from '../services/supportersService.js';
@@ -47,25 +47,8 @@ export class HeaderManager {
} }
initializeCommonElements() { initializeCommonElements() {
// Handle theme toggle this.initializeThemePopover();
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
const currentTheme = getStorageItem('theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
// 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'); const settingsToggle = document.querySelector('.settings-toggle');
if (settingsToggle) { if (settingsToggle) {
settingsToggle.addEventListener('click', () => { settingsToggle.addEventListener('click', () => {
@@ -74,22 +57,19 @@ export class HeaderManager {
} }
}); });
} }
// Handle update toggle
const updateToggle = document.getElementById('updateToggleBtn'); const updateToggle = document.getElementById('updateToggleBtn');
if (updateToggle) { if (updateToggle) {
updateToggle.addEventListener('click', () => { updateToggle.addEventListener('click', () => {
updateService.toggleUpdateModal(); updateService.toggleUpdateModal();
}); });
} }
// Handle support toggle
const supportToggle = document.getElementById('supportToggleBtn'); const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) { if (supportToggle) {
supportToggle.addEventListener('click', async () => { supportToggle.addEventListener('click', async () => {
if (window.modalManager) { if (window.modalManager) {
window.modalManager.toggleModal('supportModal'); window.modalManager.toggleModal('supportModal');
// Load supporters data when modal opens
try { try {
await renderSupporters(); await renderSupporters();
} catch (error) { } catch (error) {
@@ -99,41 +79,126 @@ export class HeaderManager {
}); });
} }
// Handle QR code toggle
const qrToggle = document.getElementById('toggleQRCode'); const qrToggle = document.getElementById('toggleQRCode');
const qrContainer = document.getElementById('qrCodeContainer'); 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(); 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() { initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown'); const hamburgerDropdown = document.getElementById('hamburgerDropdown');
@@ -188,7 +253,6 @@ export class HeaderManager {
case 'theme': case 'theme':
if (typeof toggleTheme === 'function') { if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme(); const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle'); const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) { if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
@@ -196,6 +260,7 @@ export class HeaderManager {
this.updateThemeTooltip(themeToggle, newTheme); this.updateThemeTooltip(themeToggle, newTheme);
} }
this.updateHamburgerThemeIcon(); this.updateHamburgerThemeIcon();
this.updatePopoverActiveStates(newTheme, getStorageItem('theme_preset') || 'default');
} }
break; break;
case 'settings': case 'settings':

View File

@@ -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() { export function initTheme() {
const savedTheme = getStorageItem('theme') || 'auto'; const savedTheme = getStorageItem('theme') || 'auto';
const savedPreset = getStorageItem('theme_preset') || 'default';
applyTheme(savedTheme); applyTheme(savedTheme);
applyPreset(savedPreset);
// Update theme when system preference changes (for 'auto' mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = getStorageItem('theme') || 'auto'; const currentTheme = getStorageItem('theme') || 'auto';
if (currentTheme === 'auto') { if (currentTheme === 'auto') {
@@ -212,34 +218,44 @@ export function initTheme() {
export function toggleTheme() { export function toggleTheme() {
const currentTheme = getStorageItem('theme') || 'auto'; const currentTheme = getStorageItem('theme') || 'auto';
let newTheme; const currentIndex = CYCLE_ORDER.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % CYCLE_ORDER.length;
if (currentTheme === 'light') { const newTheme = CYCLE_ORDER[nextIndex];
newTheme = 'dark';
} else {
newTheme = 'light';
}
setStorageItem('theme', newTheme); setStorageItem('theme', newTheme);
applyTheme(newTheme); applyTheme(newTheme);
// Force a repaint to ensure theme changes are applied immediately
document.body.style.display = 'none'; document.body.style.display = 'none';
document.body.offsetHeight; // Trigger a reflow document.body.offsetHeight;
document.body.style.display = ''; document.body.style.display = '';
return newTheme; 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) { function applyTheme(theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const htmlElement = document.documentElement; const htmlElement = document.documentElement;
// Remove any existing theme attributes
htmlElement.removeAttribute('data-theme'); htmlElement.removeAttribute('data-theme');
// Apply the appropriate theme
if (theme === 'dark' || (theme === 'auto' && prefersDark)) { if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark'); htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark'; document.body.dataset.theme = 'dark';
@@ -248,19 +264,18 @@ function applyTheme(theme) {
document.body.dataset.theme = 'light'; document.body.dataset.theme = 'light';
} }
// Update the theme-toggle icon state
updateThemeToggleIcons(theme); updateThemeToggleIcons(theme);
} }
// New function to update theme toggle icons function applyPreset(preset) {
document.documentElement.setAttribute('data-theme-preset', preset);
}
function updateThemeToggleIcons(theme) { function updateThemeToggleIcons(theme) {
const themeToggle = document.querySelector('.theme-toggle'); const themeToggle = document.querySelector('.theme-toggle');
if (!themeToggle) return; if (!themeToggle) return;
// Remove any existing active classes
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto'); themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
// Add the appropriate class based on current theme
themeToggle.classList.add(`theme-${theme}`); themeToggle.classList.add(`theme-${theme}`);
} }

View File

@@ -46,16 +46,20 @@
</script> </script>
<script> <script>
(function() { (function() {
// Apply theme immediately based on stored preference var STORAGE_PREFIX = 'lora_manager_';
const STORAGE_PREFIX = 'lora_manager_'; var savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto';
const savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto'; var savedPreset = localStorage.getItem(STORAGE_PREFIX + 'theme_preset') || 'default';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) { if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
} else { } else {
document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light');
} }
if (savedPreset && savedPreset !== 'default') {
document.documentElement.setAttribute('data-theme-preset', savedPreset);
}
})(); })();
</script> </script>
{% block head_scripts %}{% endblock %} {% block head_scripts %}{% endblock %}

View File

@@ -72,6 +72,55 @@
<i class="fas fa-moon dark-icon"></i> <i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i> <i class="fas fa-sun light-icon"></i>
<i class="fas fa-adjust auto-icon"></i> <i class="fas fa-adjust auto-icon"></i>
<div class="theme-popover" id="themePopover">
<div class="theme-popover-section">
<div class="theme-popover-label">{{ t('header.theme.mode') }}</div>
<div class="theme-popover-modes">
<button class="theme-mode-btn" data-mode="light" title="{{ t('header.theme.light') }}">
<i class="fas fa-sun"></i>
<span>{{ t('header.theme.light') }}</span>
</button>
<button class="theme-mode-btn" data-mode="dark" title="{{ t('header.theme.dark') }}">
<i class="fas fa-moon"></i>
<span>{{ t('header.theme.dark') }}</span>
</button>
<button class="theme-mode-btn" data-mode="auto" title="{{ t('header.theme.auto') }}">
<i class="fas fa-adjust"></i>
<span>{{ t('header.theme.auto') }}</span>
</button>
</div>
</div>
<div class="theme-popover-divider"></div>
<div class="theme-popover-section">
<div class="theme-popover-label">{{ t('header.theme.presets') }}</div>
<div class="theme-popover-presets">
<button class="theme-preset-btn" data-preset="default" title="{{ t('header.theme.default') }}">
<span class="preset-swatch preset-swatch-default"></span>
<span>{{ t('header.theme.default') }}</span>
</button>
<button class="theme-preset-btn" data-preset="nord" title="{{ t('header.theme.nord') }}">
<span class="preset-swatch preset-swatch-nord"></span>
<span>{{ t('header.theme.nord') }}</span>
</button>
<button class="theme-preset-btn" data-preset="gruvbox" title="{{ t('header.theme.gruvbox') }}">
<span class="preset-swatch preset-swatch-gruvbox"></span>
<span>{{ t('header.theme.gruvbox') }}</span>
</button>
<button class="theme-preset-btn" data-preset="monokai" title="{{ t('header.theme.monokai') }}">
<span class="preset-swatch preset-swatch-monokai"></span>
<span>{{ t('header.theme.monokai') }}</span>
</button>
<button class="theme-preset-btn" data-preset="dracula" title="{{ t('header.theme.dracula') }}">
<span class="preset-swatch preset-swatch-dracula"></span>
<span>{{ t('header.theme.dracula') }}</span>
</button>
<button class="theme-preset-btn" data-preset="solarized" title="{{ t('header.theme.solarized') }}">
<span class="preset-swatch preset-swatch-solarized"></span>
<span>{{ t('header.theme.solarized') }}</span>
</button>
</div>
</div>
</div>
</div> </div>
<div class="settings-toggle" title="{{ t('common.actions.settings') }}"> <div class="settings-toggle" title="{{ t('common.actions.settings') }}">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>