From a2ef854d5142ea37bf63cf9cb4096654df67578e Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:13:25 +0800 Subject: [PATCH 01/11] feat: add accessible light theme toggle --- frontend/main.js | 2 + frontend/styles/header.css | 2 +- frontend/styles/modal.css | 4 +- frontend/styles/motion.css | 2 +- frontend/styles/onboarding.css | 12 +-- frontend/styles/tokens.css | 162 ++++++++++++++------------------- frontend/styles/utilities.css | 17 ++++ frontend/theme.js | 46 ++++++++++ public/index.html | 33 ++++++- 9 files changed, 171 insertions(+), 109 deletions(-) create mode 100644 frontend/theme.js diff --git a/frontend/main.js b/frontend/main.js index 4cd0fd4..bdbbff9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -4,6 +4,7 @@ import './style.css'; import { checkDisclaimer, loadGradesData } from './storage.js'; +import { setupThemeToggle } from './theme.js'; // ── Lazy init 狀態 ────────────────────── let syncInited = false; @@ -27,6 +28,7 @@ export async function ensureShareReady() { // ── 首屏必要初始化 ────────────────────── document.addEventListener('DOMContentLoaded', () => { + setupThemeToggle(); checkDisclaimer(); loadGradesData(); diff --git a/frontend/styles/header.css b/frontend/styles/header.css index 19176b5..f313422 100644 --- a/frontend/styles/header.css +++ b/frontend/styles/header.css @@ -16,7 +16,7 @@ padding-right: 24px; - background: rgba(28, 28, 30, 0.72); + background: var(--color-header-bg); backdrop-filter: blur(20px) saturate(1.8); diff --git a/frontend/styles/modal.css b/frontend/styles/modal.css index 52bc2df..063d07c 100644 --- a/frontend/styles/modal.css +++ b/frontend/styles/modal.css @@ -6,7 +6,7 @@ inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--color-modal-overlay); backdrop-filter: blur(10px); @@ -382,7 +382,7 @@ visibility: visible; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); + box-shadow: 0 0 0 3px var(--color-focus-ring); } diff --git a/frontend/styles/motion.css b/frontend/styles/motion.css index 03a5b43..c9def0b 100644 --- a/frontend/styles/motion.css +++ b/frontend/styles/motion.css @@ -132,7 +132,7 @@ ::selection { - background: rgba(0, 122, 255, 0.25); + background: var(--color-selection); } diff --git a/frontend/styles/onboarding.css b/frontend/styles/onboarding.css index 776fabe..0f142e8 100644 --- a/frontend/styles/onboarding.css +++ b/frontend/styles/onboarding.css @@ -20,7 +20,7 @@ inset: 0; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); } @@ -30,17 +30,17 @@ position: fixed; - border: 2px solid rgba(168, 199, 250, 0.95); + border: 2px solid var(--color-tour-highlight); border-radius: 12px; box-shadow: - 0 0 0 9999px rgba(7, 10, 18, 0.68), + 0 0 0 9999px var(--color-tour-overlay), - 0 0 0 1px rgba(255, 255, 255, 0.15) inset, + 0 0 0 1px var(--color-tour-highlight-inset) inset, - 0 0 24px rgba(168, 199, 250, 0.45); + 0 0 24px var(--color-tour-highlight-glow); transition: all 0.2s var(--ease); @@ -164,7 +164,7 @@ z-index: 1550; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); display: flex; diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 31b26e6..5157287 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -1,168 +1,138 @@ :root { + color-scheme: dark; --md-sys-color-primary: #a8c7fa; - --md-sys-color-on-primary: #062e6f; - --md-sys-color-primary-container: #224a94; - --md-sys-color-on-primary-container: #d6e3ff; - - --md-sys-color-secondary: #c2c7cf; - --md-sys-color-on-secondary: #2b313a; - --md-sys-color-secondary-container: #424751; - --md-sys-color-on-secondary-container: #dee3ec; - - --md-sys-color-error: #ffb4ab; - --md-sys-color-on-error: #690005; - --md-sys-color-error-container: #93000a; - --md-sys-color-on-error-container: #ffdad6; - - --md-sys-color-background: #111318; - --md-sys-color-on-background: #e1e2e8; - - - --md-sys-color-surface: #111318; - --md-sys-color-on-surface: #e1e2e8; - - - --md-sys-color-surface-variant: #44474e; - --md-sys-color-on-surface-variant: #c4c6d0; - - - --md-sys-color-outline: #8e9099; - --md-sys-color-outline-variant: #44474e; - - /* Semantic mapping */ - --color-primary: var(--md-sys-color-primary); - --color-primary-hover: var(--md-sys-color-on-primary-container); - --color-primary-muted: var(--md-sys-color-primary-container); - - --color-success: #81c995; - --color-success-muted: rgba(129, 201, 149, 0.15); - --color-warning: #fde293; - --color-warning-muted: rgba(253, 226, 147, 0.15); - --color-danger: var(--md-sys-color-error); - --color-danger-muted: var(--md-sys-color-error-container); - --color-purple: #dfbcf1; - --color-purple-muted: #583f68; - - - /* Surfaces ??MD3 Elevations */ - + /* Surfaces */ --color-bg: var(--md-sys-color-background); - --color-surface: var(--md-sys-color-surface); - --color-surface-elevated: #1e2025; - - /* elevation 1 */ - --color-surface-hover: #292b30; - - /* elevation 2 */ - --color-fill: var(--md-sys-color-surface-variant); - - /* Text */ - --color-text-main: var(--md-sys-color-on-surface); - --color-text-secondary: var(--md-sys-color-on-surface-variant); - --color-text-muted: var(--md-sys-color-outline); - - /* Separator */ - --color-border: var(--md-sys-color-outline-variant); - --color-border-subtle: rgba(142, 144, 153, 0.2); - - - /* Shadow ??MD3 Elevations */ - - --shadow-soft: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); - - --shadow-hover: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15); - + /* Shadows */ + --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-hover: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); --shadow-card: none; - /* M3 relies on surface color in dark mode */ - - - - /* Radius ??MD3 standard */ - + /* Radius */ --radius-lg: 24px; - - /* Extra large */ - --radius-md: 16px; - - /* Large */ - --radius-sm: 8px; - - /* Medium/small fallback */ - --radius-pill: 9999px; - - /* Motion */ - --ease: cubic-bezier(0.2, 0, 0, 1); - --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); - --duration: 0.2s; - - /* Font */ - --font-stack: 'Roboto', 'Noto Sans TC', sans-serif; + /* Overlay and emphasis */ + --color-header-bg: rgba(28, 28, 30, 0.72); + --color-modal-overlay: rgba(0, 0, 0, 0.45); + --color-tour-overlay: rgba(7, 10, 18, 0.68); + --color-tour-highlight: rgba(168, 199, 250, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.15); + --color-tour-highlight-glow: rgba(168, 199, 250, 0.45); + --color-selection: rgba(0, 122, 255, 0.25); + --color-focus-ring: rgba(0, 122, 255, 0.28); } - - +:root[data-theme='light'] { + color-scheme: light; + + --md-sys-color-primary: #2d5daa; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #d7e3ff; + --md-sys-color-on-primary-container: #12326a; + + --md-sys-color-secondary: #565f71; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #dae2f9; + --md-sys-color-on-secondary-container: #131c2b; + + --md-sys-color-error: #ba1a1a; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #ffdad6; + --md-sys-color-on-error-container: #410002; + + --md-sys-color-background: #f8f9ff; + --md-sys-color-on-background: #181b21; + --md-sys-color-surface: #f8f9ff; + --md-sys-color-on-surface: #181b21; + --md-sys-color-surface-variant: #e1e2ec; + --md-sys-color-on-surface-variant: #444750; + --md-sys-color-outline: #74777f; + --md-sys-color-outline-variant: #c5c6d0; + + --color-success: #126738; + --color-success-muted: rgba(18, 103, 56, 0.12); + --color-warning: #946c00; + --color-warning-muted: rgba(148, 108, 0, 0.14); + --color-purple: #635b86; + --color-purple-muted: #ece7f8; + + --color-surface-elevated: #ffffff; + --color-surface-hover: #eef1fa; + --color-border-subtle: rgba(116, 119, 127, 0.25); + + --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); + --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); + --shadow-card: 0 1px 2px 0 rgba(24, 27, 33, 0.04); + + --color-header-bg: rgba(248, 249, 255, 0.88); + --color-modal-overlay: rgba(17, 19, 24, 0.3); + --color-tour-overlay: rgba(17, 19, 24, 0.52); + --color-tour-highlight: rgba(45, 93, 170, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.85); + --color-tour-highlight-glow: rgba(45, 93, 170, 0.35); + --color-selection: rgba(45, 93, 170, 0.22); + --color-focus-ring: rgba(45, 93, 170, 0.2); +} diff --git a/frontend/styles/utilities.css b/frontend/styles/utilities.css index 385457d..937e832 100644 --- a/frontend/styles/utilities.css +++ b/frontend/styles/utilities.css @@ -74,6 +74,23 @@ } +.theme-toggle-btn { + min-width: 92px; + justify-content: center; + background: var(--color-surface-elevated); + color: var(--color-text-main); + border: 1px solid var(--color-border-subtle); +} + +.theme-toggle-btn:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.theme-label { + line-height: 1; +} + /* share.js utils */ diff --git a/frontend/theme.js b/frontend/theme.js new file mode 100644 index 0000000..ce9a29f --- /dev/null +++ b/frontend/theme.js @@ -0,0 +1,46 @@ +const STORAGE_KEY = 'grades-theme'; + +function getInitialTheme() { + const storedTheme = localStorage.getItem(STORAGE_KEY); + if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + } else { + root.removeAttribute('data-theme'); + } + + const toggleBtn = document.getElementById('themeToggleBtn'); + const sunIcon = toggleBtn?.querySelector('.theme-icon-sun'); + const moonIcon = toggleBtn?.querySelector('.theme-icon-moon'); + + if (sunIcon && moonIcon) { + const isLight = theme === 'light'; + sunIcon.classList.toggle('hidden', !isLight); + moonIcon.classList.toggle('hidden', isLight); + } + + if (toggleBtn) { + const nextThemeLabel = theme === 'light' ? '深色' : '淺色'; + toggleBtn.setAttribute('aria-label', `切換至${nextThemeLabel}模式`); + toggleBtn.setAttribute('title', `切換至${nextThemeLabel}模式`); + } +} + +export function setupThemeToggle() { + const toggleBtn = document.getElementById('themeToggleBtn'); + if (!toggleBtn) return; + + let currentTheme = getInitialTheme(); + applyTheme(currentTheme); + + toggleBtn.addEventListener('click', () => { + currentTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(currentTheme); + localStorage.setItem(STORAGE_KEY, currentTheme); + }); +} diff --git a/public/index.html b/public/index.html index 619c31d..15b764c 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,16 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - + + @@ -79,6 +88,24 @@

成績分析平台

--
+
- + - \ No newline at end of file + From 615b60331e153a94a2a3827f53119cbf4b0c7c82 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:21:22 +0800 Subject: [PATCH 02/11] fix: prevent header action text clipping in light theme --- frontend/main.js | 2 + frontend/styles/header.css | 13 ++- frontend/styles/modal.css | 4 +- frontend/styles/motion.css | 2 +- frontend/styles/onboarding.css | 12 +-- frontend/styles/responsive.css | 6 +- frontend/styles/tokens.css | 162 ++++++++++++++------------------- frontend/styles/utilities.css | 17 ++++ frontend/theme.js | 46 ++++++++++ public/index.html | 35 ++++++- 10 files changed, 187 insertions(+), 112 deletions(-) create mode 100644 frontend/theme.js diff --git a/frontend/main.js b/frontend/main.js index 4cd0fd4..bdbbff9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -4,6 +4,7 @@ import './style.css'; import { checkDisclaimer, loadGradesData } from './storage.js'; +import { setupThemeToggle } from './theme.js'; // ── Lazy init 狀態 ────────────────────── let syncInited = false; @@ -27,6 +28,7 @@ export async function ensureShareReady() { // ── 首屏必要初始化 ────────────────────── document.addEventListener('DOMContentLoaded', () => { + setupThemeToggle(); checkDisclaimer(); loadGradesData(); diff --git a/frontend/styles/header.css b/frontend/styles/header.css index 19176b5..7f204ae 100644 --- a/frontend/styles/header.css +++ b/frontend/styles/header.css @@ -16,7 +16,7 @@ padding-right: 24px; - background: rgba(28, 28, 30, 0.72); + background: var(--color-header-bg); backdrop-filter: blur(20px) saturate(1.8); @@ -154,6 +154,12 @@ } +.header-actions { + justify-content: flex-end; + flex-wrap: wrap; + row-gap: 10px; +} + .time-info { @@ -260,6 +266,10 @@ touch-action: manipulation; + white-space: nowrap; + + flex-shrink: 0; + } @@ -477,4 +487,3 @@ visibility: visible; } - diff --git a/frontend/styles/modal.css b/frontend/styles/modal.css index 52bc2df..063d07c 100644 --- a/frontend/styles/modal.css +++ b/frontend/styles/modal.css @@ -6,7 +6,7 @@ inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--color-modal-overlay); backdrop-filter: blur(10px); @@ -382,7 +382,7 @@ visibility: visible; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); + box-shadow: 0 0 0 3px var(--color-focus-ring); } diff --git a/frontend/styles/motion.css b/frontend/styles/motion.css index 03a5b43..c9def0b 100644 --- a/frontend/styles/motion.css +++ b/frontend/styles/motion.css @@ -132,7 +132,7 @@ ::selection { - background: rgba(0, 122, 255, 0.25); + background: var(--color-selection); } diff --git a/frontend/styles/onboarding.css b/frontend/styles/onboarding.css index 776fabe..0f142e8 100644 --- a/frontend/styles/onboarding.css +++ b/frontend/styles/onboarding.css @@ -20,7 +20,7 @@ inset: 0; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); } @@ -30,17 +30,17 @@ position: fixed; - border: 2px solid rgba(168, 199, 250, 0.95); + border: 2px solid var(--color-tour-highlight); border-radius: 12px; box-shadow: - 0 0 0 9999px rgba(7, 10, 18, 0.68), + 0 0 0 9999px var(--color-tour-overlay), - 0 0 0 1px rgba(255, 255, 255, 0.15) inset, + 0 0 0 1px var(--color-tour-highlight-inset) inset, - 0 0 24px rgba(168, 199, 250, 0.45); + 0 0 24px var(--color-tour-highlight-glow); transition: all 0.2s var(--ease); @@ -164,7 +164,7 @@ z-index: 1550; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); display: flex; diff --git a/frontend/styles/responsive.css b/frontend/styles/responsive.css index 61b2cd6..af64b85 100644 --- a/frontend/styles/responsive.css +++ b/frontend/styles/responsive.css @@ -40,6 +40,11 @@ } + .header-actions { + width: 100%; + justify-content: flex-start; + } + .logo-text h1 { @@ -191,4 +196,3 @@ } - diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 31b26e6..5157287 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -1,168 +1,138 @@ :root { + color-scheme: dark; --md-sys-color-primary: #a8c7fa; - --md-sys-color-on-primary: #062e6f; - --md-sys-color-primary-container: #224a94; - --md-sys-color-on-primary-container: #d6e3ff; - - --md-sys-color-secondary: #c2c7cf; - --md-sys-color-on-secondary: #2b313a; - --md-sys-color-secondary-container: #424751; - --md-sys-color-on-secondary-container: #dee3ec; - - --md-sys-color-error: #ffb4ab; - --md-sys-color-on-error: #690005; - --md-sys-color-error-container: #93000a; - --md-sys-color-on-error-container: #ffdad6; - - --md-sys-color-background: #111318; - --md-sys-color-on-background: #e1e2e8; - - - --md-sys-color-surface: #111318; - --md-sys-color-on-surface: #e1e2e8; - - - --md-sys-color-surface-variant: #44474e; - --md-sys-color-on-surface-variant: #c4c6d0; - - - --md-sys-color-outline: #8e9099; - --md-sys-color-outline-variant: #44474e; - - /* Semantic mapping */ - --color-primary: var(--md-sys-color-primary); - --color-primary-hover: var(--md-sys-color-on-primary-container); - --color-primary-muted: var(--md-sys-color-primary-container); - - --color-success: #81c995; - --color-success-muted: rgba(129, 201, 149, 0.15); - --color-warning: #fde293; - --color-warning-muted: rgba(253, 226, 147, 0.15); - --color-danger: var(--md-sys-color-error); - --color-danger-muted: var(--md-sys-color-error-container); - --color-purple: #dfbcf1; - --color-purple-muted: #583f68; - - - /* Surfaces ??MD3 Elevations */ - + /* Surfaces */ --color-bg: var(--md-sys-color-background); - --color-surface: var(--md-sys-color-surface); - --color-surface-elevated: #1e2025; - - /* elevation 1 */ - --color-surface-hover: #292b30; - - /* elevation 2 */ - --color-fill: var(--md-sys-color-surface-variant); - - /* Text */ - --color-text-main: var(--md-sys-color-on-surface); - --color-text-secondary: var(--md-sys-color-on-surface-variant); - --color-text-muted: var(--md-sys-color-outline); - - /* Separator */ - --color-border: var(--md-sys-color-outline-variant); - --color-border-subtle: rgba(142, 144, 153, 0.2); - - - /* Shadow ??MD3 Elevations */ - - --shadow-soft: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); - - --shadow-hover: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15); - + /* Shadows */ + --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-hover: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); --shadow-card: none; - /* M3 relies on surface color in dark mode */ - - - - /* Radius ??MD3 standard */ - + /* Radius */ --radius-lg: 24px; - - /* Extra large */ - --radius-md: 16px; - - /* Large */ - --radius-sm: 8px; - - /* Medium/small fallback */ - --radius-pill: 9999px; - - /* Motion */ - --ease: cubic-bezier(0.2, 0, 0, 1); - --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); - --duration: 0.2s; - - /* Font */ - --font-stack: 'Roboto', 'Noto Sans TC', sans-serif; + /* Overlay and emphasis */ + --color-header-bg: rgba(28, 28, 30, 0.72); + --color-modal-overlay: rgba(0, 0, 0, 0.45); + --color-tour-overlay: rgba(7, 10, 18, 0.68); + --color-tour-highlight: rgba(168, 199, 250, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.15); + --color-tour-highlight-glow: rgba(168, 199, 250, 0.45); + --color-selection: rgba(0, 122, 255, 0.25); + --color-focus-ring: rgba(0, 122, 255, 0.28); } - - +:root[data-theme='light'] { + color-scheme: light; + + --md-sys-color-primary: #2d5daa; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #d7e3ff; + --md-sys-color-on-primary-container: #12326a; + + --md-sys-color-secondary: #565f71; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #dae2f9; + --md-sys-color-on-secondary-container: #131c2b; + + --md-sys-color-error: #ba1a1a; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #ffdad6; + --md-sys-color-on-error-container: #410002; + + --md-sys-color-background: #f8f9ff; + --md-sys-color-on-background: #181b21; + --md-sys-color-surface: #f8f9ff; + --md-sys-color-on-surface: #181b21; + --md-sys-color-surface-variant: #e1e2ec; + --md-sys-color-on-surface-variant: #444750; + --md-sys-color-outline: #74777f; + --md-sys-color-outline-variant: #c5c6d0; + + --color-success: #126738; + --color-success-muted: rgba(18, 103, 56, 0.12); + --color-warning: #946c00; + --color-warning-muted: rgba(148, 108, 0, 0.14); + --color-purple: #635b86; + --color-purple-muted: #ece7f8; + + --color-surface-elevated: #ffffff; + --color-surface-hover: #eef1fa; + --color-border-subtle: rgba(116, 119, 127, 0.25); + + --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); + --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); + --shadow-card: 0 1px 2px 0 rgba(24, 27, 33, 0.04); + + --color-header-bg: rgba(248, 249, 255, 0.88); + --color-modal-overlay: rgba(17, 19, 24, 0.3); + --color-tour-overlay: rgba(17, 19, 24, 0.52); + --color-tour-highlight: rgba(45, 93, 170, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.85); + --color-tour-highlight-glow: rgba(45, 93, 170, 0.35); + --color-selection: rgba(45, 93, 170, 0.22); + --color-focus-ring: rgba(45, 93, 170, 0.2); +} diff --git a/frontend/styles/utilities.css b/frontend/styles/utilities.css index 385457d..937e832 100644 --- a/frontend/styles/utilities.css +++ b/frontend/styles/utilities.css @@ -74,6 +74,23 @@ } +.theme-toggle-btn { + min-width: 92px; + justify-content: center; + background: var(--color-surface-elevated); + color: var(--color-text-main); + border: 1px solid var(--color-border-subtle); +} + +.theme-toggle-btn:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.theme-label { + line-height: 1; +} + /* share.js utils */ diff --git a/frontend/theme.js b/frontend/theme.js new file mode 100644 index 0000000..ce9a29f --- /dev/null +++ b/frontend/theme.js @@ -0,0 +1,46 @@ +const STORAGE_KEY = 'grades-theme'; + +function getInitialTheme() { + const storedTheme = localStorage.getItem(STORAGE_KEY); + if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + } else { + root.removeAttribute('data-theme'); + } + + const toggleBtn = document.getElementById('themeToggleBtn'); + const sunIcon = toggleBtn?.querySelector('.theme-icon-sun'); + const moonIcon = toggleBtn?.querySelector('.theme-icon-moon'); + + if (sunIcon && moonIcon) { + const isLight = theme === 'light'; + sunIcon.classList.toggle('hidden', !isLight); + moonIcon.classList.toggle('hidden', isLight); + } + + if (toggleBtn) { + const nextThemeLabel = theme === 'light' ? '深色' : '淺色'; + toggleBtn.setAttribute('aria-label', `切換至${nextThemeLabel}模式`); + toggleBtn.setAttribute('title', `切換至${nextThemeLabel}模式`); + } +} + +export function setupThemeToggle() { + const toggleBtn = document.getElementById('themeToggleBtn'); + if (!toggleBtn) return; + + let currentTheme = getInitialTheme(); + applyTheme(currentTheme); + + toggleBtn.addEventListener('click', () => { + currentTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(currentTheme); + localStorage.setItem(STORAGE_KEY, currentTheme); + }); +} diff --git a/public/index.html b/public/index.html index 619c31d..2bebe84 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,16 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - + + @@ -78,7 +87,25 @@

成績分析平台

資料更新時間 -- -
+
+
- + - \ No newline at end of file + From 61ddf8fd39fea517ed481f5745bca6724ba4ba72 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:27:56 +0800 Subject: [PATCH 03/11] fix: compact header actions to reduce space usage --- frontend/main.js | 2 + frontend/styles/header.css | 17 +++- frontend/styles/modal.css | 4 +- frontend/styles/motion.css | 2 +- frontend/styles/onboarding.css | 12 +-- frontend/styles/responsive.css | 7 +- frontend/styles/tokens.css | 162 ++++++++++++++------------------- frontend/styles/utilities.css | 19 ++++ frontend/theme.js | 46 ++++++++++ public/index.html | 34 ++++++- 10 files changed, 189 insertions(+), 116 deletions(-) create mode 100644 frontend/theme.js diff --git a/frontend/main.js b/frontend/main.js index 4cd0fd4..bdbbff9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -4,6 +4,7 @@ import './style.css'; import { checkDisclaimer, loadGradesData } from './storage.js'; +import { setupThemeToggle } from './theme.js'; // ── Lazy init 狀態 ────────────────────── let syncInited = false; @@ -27,6 +28,7 @@ export async function ensureShareReady() { // ── 首屏必要初始化 ────────────────────── document.addEventListener('DOMContentLoaded', () => { + setupThemeToggle(); checkDisclaimer(); loadGradesData(); diff --git a/frontend/styles/header.css b/frontend/styles/header.css index 19176b5..309a289 100644 --- a/frontend/styles/header.css +++ b/frontend/styles/header.css @@ -16,7 +16,7 @@ padding-right: 24px; - background: rgba(28, 28, 30, 0.72); + background: var(--color-header-bg); backdrop-filter: blur(20px) saturate(1.8); @@ -154,6 +154,11 @@ } +.header-actions { + justify-content: flex-end; + flex-wrap: nowrap; +} + .time-info { @@ -238,7 +243,7 @@ height: 40px; - padding: 0 24px; + padding: 0 18px; background: var(--color-primary); @@ -248,7 +253,7 @@ color: var(--md-sys-color-on-primary); - font-size: 14px; + font-size: 13px; font-weight: 500; @@ -260,6 +265,10 @@ touch-action: manipulation; + white-space: nowrap; + + flex-shrink: 0; + } @@ -476,5 +485,3 @@ visibility: visible; } - - diff --git a/frontend/styles/modal.css b/frontend/styles/modal.css index 52bc2df..063d07c 100644 --- a/frontend/styles/modal.css +++ b/frontend/styles/modal.css @@ -6,7 +6,7 @@ inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--color-modal-overlay); backdrop-filter: blur(10px); @@ -382,7 +382,7 @@ visibility: visible; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); + box-shadow: 0 0 0 3px var(--color-focus-ring); } diff --git a/frontend/styles/motion.css b/frontend/styles/motion.css index 03a5b43..c9def0b 100644 --- a/frontend/styles/motion.css +++ b/frontend/styles/motion.css @@ -132,7 +132,7 @@ ::selection { - background: rgba(0, 122, 255, 0.25); + background: var(--color-selection); } diff --git a/frontend/styles/onboarding.css b/frontend/styles/onboarding.css index 776fabe..0f142e8 100644 --- a/frontend/styles/onboarding.css +++ b/frontend/styles/onboarding.css @@ -20,7 +20,7 @@ inset: 0; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); } @@ -30,17 +30,17 @@ position: fixed; - border: 2px solid rgba(168, 199, 250, 0.95); + border: 2px solid var(--color-tour-highlight); border-radius: 12px; box-shadow: - 0 0 0 9999px rgba(7, 10, 18, 0.68), + 0 0 0 9999px var(--color-tour-overlay), - 0 0 0 1px rgba(255, 255, 255, 0.15) inset, + 0 0 0 1px var(--color-tour-highlight-inset) inset, - 0 0 24px rgba(168, 199, 250, 0.45); + 0 0 24px var(--color-tour-highlight-glow); transition: all 0.2s var(--ease); @@ -164,7 +164,7 @@ z-index: 1550; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); display: flex; diff --git a/frontend/styles/responsive.css b/frontend/styles/responsive.css index 61b2cd6..b358f42 100644 --- a/frontend/styles/responsive.css +++ b/frontend/styles/responsive.css @@ -40,6 +40,11 @@ } + .header-actions { + width: auto; + justify-content: flex-end; + } + .logo-text h1 { @@ -190,5 +195,3 @@ } - - diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 31b26e6..5157287 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -1,168 +1,138 @@ :root { + color-scheme: dark; --md-sys-color-primary: #a8c7fa; - --md-sys-color-on-primary: #062e6f; - --md-sys-color-primary-container: #224a94; - --md-sys-color-on-primary-container: #d6e3ff; - - --md-sys-color-secondary: #c2c7cf; - --md-sys-color-on-secondary: #2b313a; - --md-sys-color-secondary-container: #424751; - --md-sys-color-on-secondary-container: #dee3ec; - - --md-sys-color-error: #ffb4ab; - --md-sys-color-on-error: #690005; - --md-sys-color-error-container: #93000a; - --md-sys-color-on-error-container: #ffdad6; - - --md-sys-color-background: #111318; - --md-sys-color-on-background: #e1e2e8; - - - --md-sys-color-surface: #111318; - --md-sys-color-on-surface: #e1e2e8; - - - --md-sys-color-surface-variant: #44474e; - --md-sys-color-on-surface-variant: #c4c6d0; - - - --md-sys-color-outline: #8e9099; - --md-sys-color-outline-variant: #44474e; - - /* Semantic mapping */ - --color-primary: var(--md-sys-color-primary); - --color-primary-hover: var(--md-sys-color-on-primary-container); - --color-primary-muted: var(--md-sys-color-primary-container); - - --color-success: #81c995; - --color-success-muted: rgba(129, 201, 149, 0.15); - --color-warning: #fde293; - --color-warning-muted: rgba(253, 226, 147, 0.15); - --color-danger: var(--md-sys-color-error); - --color-danger-muted: var(--md-sys-color-error-container); - --color-purple: #dfbcf1; - --color-purple-muted: #583f68; - - - /* Surfaces ??MD3 Elevations */ - + /* Surfaces */ --color-bg: var(--md-sys-color-background); - --color-surface: var(--md-sys-color-surface); - --color-surface-elevated: #1e2025; - - /* elevation 1 */ - --color-surface-hover: #292b30; - - /* elevation 2 */ - --color-fill: var(--md-sys-color-surface-variant); - - /* Text */ - --color-text-main: var(--md-sys-color-on-surface); - --color-text-secondary: var(--md-sys-color-on-surface-variant); - --color-text-muted: var(--md-sys-color-outline); - - /* Separator */ - --color-border: var(--md-sys-color-outline-variant); - --color-border-subtle: rgba(142, 144, 153, 0.2); - - - /* Shadow ??MD3 Elevations */ - - --shadow-soft: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); - - --shadow-hover: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15); - + /* Shadows */ + --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-hover: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); --shadow-card: none; - /* M3 relies on surface color in dark mode */ - - - - /* Radius ??MD3 standard */ - + /* Radius */ --radius-lg: 24px; - - /* Extra large */ - --radius-md: 16px; - - /* Large */ - --radius-sm: 8px; - - /* Medium/small fallback */ - --radius-pill: 9999px; - - /* Motion */ - --ease: cubic-bezier(0.2, 0, 0, 1); - --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); - --duration: 0.2s; - - /* Font */ - --font-stack: 'Roboto', 'Noto Sans TC', sans-serif; + /* Overlay and emphasis */ + --color-header-bg: rgba(28, 28, 30, 0.72); + --color-modal-overlay: rgba(0, 0, 0, 0.45); + --color-tour-overlay: rgba(7, 10, 18, 0.68); + --color-tour-highlight: rgba(168, 199, 250, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.15); + --color-tour-highlight-glow: rgba(168, 199, 250, 0.45); + --color-selection: rgba(0, 122, 255, 0.25); + --color-focus-ring: rgba(0, 122, 255, 0.28); } - - +:root[data-theme='light'] { + color-scheme: light; + + --md-sys-color-primary: #2d5daa; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #d7e3ff; + --md-sys-color-on-primary-container: #12326a; + + --md-sys-color-secondary: #565f71; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #dae2f9; + --md-sys-color-on-secondary-container: #131c2b; + + --md-sys-color-error: #ba1a1a; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #ffdad6; + --md-sys-color-on-error-container: #410002; + + --md-sys-color-background: #f8f9ff; + --md-sys-color-on-background: #181b21; + --md-sys-color-surface: #f8f9ff; + --md-sys-color-on-surface: #181b21; + --md-sys-color-surface-variant: #e1e2ec; + --md-sys-color-on-surface-variant: #444750; + --md-sys-color-outline: #74777f; + --md-sys-color-outline-variant: #c5c6d0; + + --color-success: #126738; + --color-success-muted: rgba(18, 103, 56, 0.12); + --color-warning: #946c00; + --color-warning-muted: rgba(148, 108, 0, 0.14); + --color-purple: #635b86; + --color-purple-muted: #ece7f8; + + --color-surface-elevated: #ffffff; + --color-surface-hover: #eef1fa; + --color-border-subtle: rgba(116, 119, 127, 0.25); + + --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); + --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); + --shadow-card: 0 1px 2px 0 rgba(24, 27, 33, 0.04); + + --color-header-bg: rgba(248, 249, 255, 0.88); + --color-modal-overlay: rgba(17, 19, 24, 0.3); + --color-tour-overlay: rgba(17, 19, 24, 0.52); + --color-tour-highlight: rgba(45, 93, 170, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.85); + --color-tour-highlight-glow: rgba(45, 93, 170, 0.35); + --color-selection: rgba(45, 93, 170, 0.22); + --color-focus-ring: rgba(45, 93, 170, 0.2); +} diff --git a/frontend/styles/utilities.css b/frontend/styles/utilities.css index 385457d..b6c177c 100644 --- a/frontend/styles/utilities.css +++ b/frontend/styles/utilities.css @@ -74,6 +74,25 @@ } +.theme-toggle-btn { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + background: var(--color-surface-elevated); + color: var(--color-text-main); + border: 1px solid var(--color-border-subtle); +} + +.theme-toggle-btn:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.theme-toggle-btn .dropdown-icon { + font-size: 18px; +} + /* share.js utils */ diff --git a/frontend/theme.js b/frontend/theme.js new file mode 100644 index 0000000..ce9a29f --- /dev/null +++ b/frontend/theme.js @@ -0,0 +1,46 @@ +const STORAGE_KEY = 'grades-theme'; + +function getInitialTheme() { + const storedTheme = localStorage.getItem(STORAGE_KEY); + if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + } else { + root.removeAttribute('data-theme'); + } + + const toggleBtn = document.getElementById('themeToggleBtn'); + const sunIcon = toggleBtn?.querySelector('.theme-icon-sun'); + const moonIcon = toggleBtn?.querySelector('.theme-icon-moon'); + + if (sunIcon && moonIcon) { + const isLight = theme === 'light'; + sunIcon.classList.toggle('hidden', !isLight); + moonIcon.classList.toggle('hidden', isLight); + } + + if (toggleBtn) { + const nextThemeLabel = theme === 'light' ? '深色' : '淺色'; + toggleBtn.setAttribute('aria-label', `切換至${nextThemeLabel}模式`); + toggleBtn.setAttribute('title', `切換至${nextThemeLabel}模式`); + } +} + +export function setupThemeToggle() { + const toggleBtn = document.getElementById('themeToggleBtn'); + if (!toggleBtn) return; + + let currentTheme = getInitialTheme(); + applyTheme(currentTheme); + + toggleBtn.addEventListener('click', () => { + currentTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(currentTheme); + localStorage.setItem(STORAGE_KEY, currentTheme); + }); +} diff --git a/public/index.html b/public/index.html index 619c31d..c23a039 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,16 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - + + @@ -78,7 +87,24 @@

成績分析平台

資料更新時間 --
-
+
+
- + - \ No newline at end of file + From a8bd09255f8db677b3148c42b29fe823d6b23222 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:38:59 +0800 Subject: [PATCH 04/11] fix: improve chart text contrast across theme changes --- frontend/charts.js | 101 +++++++++++++++++--- frontend/main.js | 2 + frontend/styles/header.css | 17 +++- frontend/styles/modal.css | 4 +- frontend/styles/motion.css | 2 +- frontend/styles/onboarding.css | 12 +-- frontend/styles/responsive.css | 7 +- frontend/styles/tokens.css | 162 ++++++++++++++------------------- frontend/styles/utilities.css | 19 ++++ frontend/theme.js | 50 ++++++++++ public/index.html | 34 ++++++- 11 files changed, 280 insertions(+), 130 deletions(-) create mode 100644 frontend/theme.js diff --git a/frontend/charts.js b/frontend/charts.js index 1a84099..5fc5021 100644 --- a/frontend/charts.js +++ b/frontend/charts.js @@ -13,6 +13,12 @@ let barChartInstance = null; let chartJsLoadPromise = null; let latestRenderToken = 0; +if (typeof document !== 'undefined') { + document.addEventListener('themechange', () => { + applyThemeToExistingCharts(); + }); +} + export function generateCharts(subjects) { if (!Array.isArray(subjects) || subjects.length === 0) return; @@ -103,6 +109,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { const radarCtx = radarCanvas?.getContext('2d'); if (!radarCtx) return; + const palette = getChartThemePalette(); + if (!radarChartInstance) { radarChartInstance = new ChartCtor(radarCtx, { type: 'radar', @@ -116,8 +124,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { borderColor: '#6366f1', borderWidth: 2, pointBackgroundColor: '#6366f1', - pointBorderColor: '#fff', - pointHoverBackgroundColor: '#fff', + pointBorderColor: palette.surface, + pointHoverBackgroundColor: palette.surface, pointHoverBorderColor: '#6366f1' }, { @@ -127,8 +135,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { borderColor: '#10b981', borderWidth: 2, pointBackgroundColor: '#10b981', - pointBorderColor: '#fff', - pointHoverBackgroundColor: '#fff', + pointBorderColor: palette.surface, + pointHoverBackgroundColor: palette.surface, pointHoverBorderColor: '#10b981' } ] @@ -142,17 +150,17 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { max: 100, ticks: { stepSize: 20, - color: '#94a3b8', + color: palette.textMuted, backdropColor: 'transparent' }, grid: { - color: 'rgba(148, 163, 184, 0.2)' + color: palette.grid }, angleLines: { - color: 'rgba(148, 163, 184, 0.2)' + color: palette.grid }, pointLabels: { - color: '#f8fafc', + color: palette.textSecondary, font: { size: 12 } @@ -163,7 +171,7 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { legend: { position: 'bottom', labels: { - color: '#f8fafc', + color: palette.textMain, padding: 20, font: { size: 13 @@ -187,6 +195,8 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { const barCtx = barCanvas?.getContext('2d'); if (!barCtx) return; + const palette = getChartThemePalette(); + if (!barChartInstance) { barChartInstance = new ChartCtor(barCtx, { type: 'bar', @@ -217,20 +227,20 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { scales: { x: { grid: { - color: 'rgba(148, 163, 184, 0.1)' + color: palette.gridSubtle }, ticks: { - color: '#94a3b8' + color: palette.textMuted } }, y: { beginAtZero: true, max: 100, grid: { - color: 'rgba(148, 163, 184, 0.1)' + color: palette.gridSubtle }, ticks: { - color: '#94a3b8' + color: palette.textMuted } } }, @@ -238,7 +248,7 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { legend: { position: 'bottom', labels: { - color: '#f8fafc', + color: palette.textMain, padding: 20, font: { size: 13 @@ -257,6 +267,69 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { barChartInstance.update('none'); } +function getChartThemePalette() { + const styles = getComputedStyle(document.documentElement); + const readVar = (name, fallback) => { + const value = styles.getPropertyValue(name).trim(); + return value || fallback; + }; + + return { + textMain: readVar('--color-text-main', '#0f172a'), + textSecondary: readVar('--color-text-secondary', '#334155'), + textMuted: readVar('--color-text-muted', '#64748b'), + surface: readVar('--color-surface-elevated', '#ffffff'), + grid: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.22)'), + gridSubtle: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.16)') + }; +} + +function applyThemeToExistingCharts() { + const palette = getChartThemePalette(); + + if (radarChartInstance) { + const radarOptions = radarChartInstance.options; + const radarScale = radarOptions.scales?.r; + const radarLegendLabels = radarOptions.plugins?.legend?.labels; + if (radarScale) { + radarScale.ticks.color = palette.textMuted; + radarScale.grid.color = palette.grid; + radarScale.angleLines.color = palette.grid; + radarScale.pointLabels.color = palette.textSecondary; + } + if (radarLegendLabels) { + radarLegendLabels.color = palette.textMain; + } + + radarChartInstance.data.datasets.forEach((dataset) => { + dataset.pointBorderColor = palette.surface; + dataset.pointHoverBackgroundColor = palette.surface; + }); + + radarChartInstance.update('none'); + } + + if (barChartInstance) { + const barOptions = barChartInstance.options; + const xScale = barOptions.scales?.x; + const yScale = barOptions.scales?.y; + const barLegendLabels = barOptions.plugins?.legend?.labels; + if (xScale) { + xScale.grid.color = palette.gridSubtle; + xScale.ticks.color = palette.textMuted; + } + if (yScale) { + yScale.grid.color = palette.gridSubtle; + yScale.ticks.color = palette.textMuted; + } + if (barLegendLabels) { + barLegendLabels.color = palette.textMain; + } + + barChartInstance.update('none'); + } +} + function clearCanvas(canvasId) { const canvas = document.getElementById(canvasId); const context = canvas?.getContext?.('2d'); diff --git a/frontend/main.js b/frontend/main.js index 4cd0fd4..bdbbff9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -4,6 +4,7 @@ import './style.css'; import { checkDisclaimer, loadGradesData } from './storage.js'; +import { setupThemeToggle } from './theme.js'; // ── Lazy init 狀態 ────────────────────── let syncInited = false; @@ -27,6 +28,7 @@ export async function ensureShareReady() { // ── 首屏必要初始化 ────────────────────── document.addEventListener('DOMContentLoaded', () => { + setupThemeToggle(); checkDisclaimer(); loadGradesData(); diff --git a/frontend/styles/header.css b/frontend/styles/header.css index 19176b5..309a289 100644 --- a/frontend/styles/header.css +++ b/frontend/styles/header.css @@ -16,7 +16,7 @@ padding-right: 24px; - background: rgba(28, 28, 30, 0.72); + background: var(--color-header-bg); backdrop-filter: blur(20px) saturate(1.8); @@ -154,6 +154,11 @@ } +.header-actions { + justify-content: flex-end; + flex-wrap: nowrap; +} + .time-info { @@ -238,7 +243,7 @@ height: 40px; - padding: 0 24px; + padding: 0 18px; background: var(--color-primary); @@ -248,7 +253,7 @@ color: var(--md-sys-color-on-primary); - font-size: 14px; + font-size: 13px; font-weight: 500; @@ -260,6 +265,10 @@ touch-action: manipulation; + white-space: nowrap; + + flex-shrink: 0; + } @@ -476,5 +485,3 @@ visibility: visible; } - - diff --git a/frontend/styles/modal.css b/frontend/styles/modal.css index 52bc2df..063d07c 100644 --- a/frontend/styles/modal.css +++ b/frontend/styles/modal.css @@ -6,7 +6,7 @@ inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--color-modal-overlay); backdrop-filter: blur(10px); @@ -382,7 +382,7 @@ visibility: visible; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); + box-shadow: 0 0 0 3px var(--color-focus-ring); } diff --git a/frontend/styles/motion.css b/frontend/styles/motion.css index 03a5b43..c9def0b 100644 --- a/frontend/styles/motion.css +++ b/frontend/styles/motion.css @@ -132,7 +132,7 @@ ::selection { - background: rgba(0, 122, 255, 0.25); + background: var(--color-selection); } diff --git a/frontend/styles/onboarding.css b/frontend/styles/onboarding.css index 776fabe..0f142e8 100644 --- a/frontend/styles/onboarding.css +++ b/frontend/styles/onboarding.css @@ -20,7 +20,7 @@ inset: 0; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); } @@ -30,17 +30,17 @@ position: fixed; - border: 2px solid rgba(168, 199, 250, 0.95); + border: 2px solid var(--color-tour-highlight); border-radius: 12px; box-shadow: - 0 0 0 9999px rgba(7, 10, 18, 0.68), + 0 0 0 9999px var(--color-tour-overlay), - 0 0 0 1px rgba(255, 255, 255, 0.15) inset, + 0 0 0 1px var(--color-tour-highlight-inset) inset, - 0 0 24px rgba(168, 199, 250, 0.45); + 0 0 24px var(--color-tour-highlight-glow); transition: all 0.2s var(--ease); @@ -164,7 +164,7 @@ z-index: 1550; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); display: flex; diff --git a/frontend/styles/responsive.css b/frontend/styles/responsive.css index 61b2cd6..b358f42 100644 --- a/frontend/styles/responsive.css +++ b/frontend/styles/responsive.css @@ -40,6 +40,11 @@ } + .header-actions { + width: auto; + justify-content: flex-end; + } + .logo-text h1 { @@ -190,5 +195,3 @@ } - - diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 31b26e6..5157287 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -1,168 +1,138 @@ :root { + color-scheme: dark; --md-sys-color-primary: #a8c7fa; - --md-sys-color-on-primary: #062e6f; - --md-sys-color-primary-container: #224a94; - --md-sys-color-on-primary-container: #d6e3ff; - - --md-sys-color-secondary: #c2c7cf; - --md-sys-color-on-secondary: #2b313a; - --md-sys-color-secondary-container: #424751; - --md-sys-color-on-secondary-container: #dee3ec; - - --md-sys-color-error: #ffb4ab; - --md-sys-color-on-error: #690005; - --md-sys-color-error-container: #93000a; - --md-sys-color-on-error-container: #ffdad6; - - --md-sys-color-background: #111318; - --md-sys-color-on-background: #e1e2e8; - - - --md-sys-color-surface: #111318; - --md-sys-color-on-surface: #e1e2e8; - - - --md-sys-color-surface-variant: #44474e; - --md-sys-color-on-surface-variant: #c4c6d0; - - - --md-sys-color-outline: #8e9099; - --md-sys-color-outline-variant: #44474e; - - /* Semantic mapping */ - --color-primary: var(--md-sys-color-primary); - --color-primary-hover: var(--md-sys-color-on-primary-container); - --color-primary-muted: var(--md-sys-color-primary-container); - - --color-success: #81c995; - --color-success-muted: rgba(129, 201, 149, 0.15); - --color-warning: #fde293; - --color-warning-muted: rgba(253, 226, 147, 0.15); - --color-danger: var(--md-sys-color-error); - --color-danger-muted: var(--md-sys-color-error-container); - --color-purple: #dfbcf1; - --color-purple-muted: #583f68; - - - /* Surfaces ??MD3 Elevations */ - + /* Surfaces */ --color-bg: var(--md-sys-color-background); - --color-surface: var(--md-sys-color-surface); - --color-surface-elevated: #1e2025; - - /* elevation 1 */ - --color-surface-hover: #292b30; - - /* elevation 2 */ - --color-fill: var(--md-sys-color-surface-variant); - - /* Text */ - --color-text-main: var(--md-sys-color-on-surface); - --color-text-secondary: var(--md-sys-color-on-surface-variant); - --color-text-muted: var(--md-sys-color-outline); - - /* Separator */ - --color-border: var(--md-sys-color-outline-variant); - --color-border-subtle: rgba(142, 144, 153, 0.2); - - - /* Shadow ??MD3 Elevations */ - - --shadow-soft: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); - - --shadow-hover: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15); - + /* Shadows */ + --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-hover: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); --shadow-card: none; - /* M3 relies on surface color in dark mode */ - - - - /* Radius ??MD3 standard */ - + /* Radius */ --radius-lg: 24px; - - /* Extra large */ - --radius-md: 16px; - - /* Large */ - --radius-sm: 8px; - - /* Medium/small fallback */ - --radius-pill: 9999px; - - /* Motion */ - --ease: cubic-bezier(0.2, 0, 0, 1); - --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); - --duration: 0.2s; - - /* Font */ - --font-stack: 'Roboto', 'Noto Sans TC', sans-serif; + /* Overlay and emphasis */ + --color-header-bg: rgba(28, 28, 30, 0.72); + --color-modal-overlay: rgba(0, 0, 0, 0.45); + --color-tour-overlay: rgba(7, 10, 18, 0.68); + --color-tour-highlight: rgba(168, 199, 250, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.15); + --color-tour-highlight-glow: rgba(168, 199, 250, 0.45); + --color-selection: rgba(0, 122, 255, 0.25); + --color-focus-ring: rgba(0, 122, 255, 0.28); } - - +:root[data-theme='light'] { + color-scheme: light; + + --md-sys-color-primary: #2d5daa; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #d7e3ff; + --md-sys-color-on-primary-container: #12326a; + + --md-sys-color-secondary: #565f71; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #dae2f9; + --md-sys-color-on-secondary-container: #131c2b; + + --md-sys-color-error: #ba1a1a; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #ffdad6; + --md-sys-color-on-error-container: #410002; + + --md-sys-color-background: #f8f9ff; + --md-sys-color-on-background: #181b21; + --md-sys-color-surface: #f8f9ff; + --md-sys-color-on-surface: #181b21; + --md-sys-color-surface-variant: #e1e2ec; + --md-sys-color-on-surface-variant: #444750; + --md-sys-color-outline: #74777f; + --md-sys-color-outline-variant: #c5c6d0; + + --color-success: #126738; + --color-success-muted: rgba(18, 103, 56, 0.12); + --color-warning: #946c00; + --color-warning-muted: rgba(148, 108, 0, 0.14); + --color-purple: #635b86; + --color-purple-muted: #ece7f8; + + --color-surface-elevated: #ffffff; + --color-surface-hover: #eef1fa; + --color-border-subtle: rgba(116, 119, 127, 0.25); + + --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); + --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); + --shadow-card: 0 1px 2px 0 rgba(24, 27, 33, 0.04); + + --color-header-bg: rgba(248, 249, 255, 0.88); + --color-modal-overlay: rgba(17, 19, 24, 0.3); + --color-tour-overlay: rgba(17, 19, 24, 0.52); + --color-tour-highlight: rgba(45, 93, 170, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.85); + --color-tour-highlight-glow: rgba(45, 93, 170, 0.35); + --color-selection: rgba(45, 93, 170, 0.22); + --color-focus-ring: rgba(45, 93, 170, 0.2); +} diff --git a/frontend/styles/utilities.css b/frontend/styles/utilities.css index 385457d..b6c177c 100644 --- a/frontend/styles/utilities.css +++ b/frontend/styles/utilities.css @@ -74,6 +74,25 @@ } +.theme-toggle-btn { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + background: var(--color-surface-elevated); + color: var(--color-text-main); + border: 1px solid var(--color-border-subtle); +} + +.theme-toggle-btn:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.theme-toggle-btn .dropdown-icon { + font-size: 18px; +} + /* share.js utils */ diff --git a/frontend/theme.js b/frontend/theme.js new file mode 100644 index 0000000..ba1e8f0 --- /dev/null +++ b/frontend/theme.js @@ -0,0 +1,50 @@ +const STORAGE_KEY = 'grades-theme'; + +function getInitialTheme() { + const storedTheme = localStorage.getItem(STORAGE_KEY); + if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + } else { + root.removeAttribute('data-theme'); + } + + const toggleBtn = document.getElementById('themeToggleBtn'); + const sunIcon = toggleBtn?.querySelector('.theme-icon-sun'); + const moonIcon = toggleBtn?.querySelector('.theme-icon-moon'); + + if (sunIcon && moonIcon) { + const isLight = theme === 'light'; + sunIcon.classList.toggle('hidden', !isLight); + moonIcon.classList.toggle('hidden', isLight); + } + + if (toggleBtn) { + const nextThemeLabel = theme === 'light' ? '深色' : '淺色'; + toggleBtn.setAttribute('aria-label', `切換至${nextThemeLabel}模式`); + toggleBtn.setAttribute('title', `切換至${nextThemeLabel}模式`); + } + + document.dispatchEvent(new CustomEvent('themechange', { + detail: { theme } + })); +} + +export function setupThemeToggle() { + const toggleBtn = document.getElementById('themeToggleBtn'); + if (!toggleBtn) return; + + let currentTheme = getInitialTheme(); + applyTheme(currentTheme); + + toggleBtn.addEventListener('click', () => { + currentTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(currentTheme); + localStorage.setItem(STORAGE_KEY, currentTheme); + }); +} diff --git a/public/index.html b/public/index.html index 619c31d..20a7b68 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,16 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - + + @@ -78,7 +87,24 @@

成績分析平台

資料更新時間 --
-
+
+
- + - \ No newline at end of file + From 5c2e0317ac5fca2dfd4d2aacacd58083fcb51f5b Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:02:39 +0800 Subject: [PATCH 05/11] fix: use manual dark default theme and md3 toggle icons --- frontend/charts.js | 101 +++++++++++++++++--- frontend/main.js | 2 + frontend/styles/header.css | 17 +++- frontend/styles/modal.css | 4 +- frontend/styles/motion.css | 2 +- frontend/styles/onboarding.css | 12 +-- frontend/styles/responsive.css | 7 +- frontend/styles/tokens.css | 162 ++++++++++++++------------------- frontend/styles/utilities.css | 19 ++++ frontend/theme.js | 50 ++++++++++ public/index.html | 33 ++++++- 11 files changed, 279 insertions(+), 130 deletions(-) create mode 100644 frontend/theme.js diff --git a/frontend/charts.js b/frontend/charts.js index 1a84099..5fc5021 100644 --- a/frontend/charts.js +++ b/frontend/charts.js @@ -13,6 +13,12 @@ let barChartInstance = null; let chartJsLoadPromise = null; let latestRenderToken = 0; +if (typeof document !== 'undefined') { + document.addEventListener('themechange', () => { + applyThemeToExistingCharts(); + }); +} + export function generateCharts(subjects) { if (!Array.isArray(subjects) || subjects.length === 0) return; @@ -103,6 +109,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { const radarCtx = radarCanvas?.getContext('2d'); if (!radarCtx) return; + const palette = getChartThemePalette(); + if (!radarChartInstance) { radarChartInstance = new ChartCtor(radarCtx, { type: 'radar', @@ -116,8 +124,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { borderColor: '#6366f1', borderWidth: 2, pointBackgroundColor: '#6366f1', - pointBorderColor: '#fff', - pointHoverBackgroundColor: '#fff', + pointBorderColor: palette.surface, + pointHoverBackgroundColor: palette.surface, pointHoverBorderColor: '#6366f1' }, { @@ -127,8 +135,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { borderColor: '#10b981', borderWidth: 2, pointBackgroundColor: '#10b981', - pointBorderColor: '#fff', - pointHoverBackgroundColor: '#fff', + pointBorderColor: palette.surface, + pointHoverBackgroundColor: palette.surface, pointHoverBorderColor: '#10b981' } ] @@ -142,17 +150,17 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { max: 100, ticks: { stepSize: 20, - color: '#94a3b8', + color: palette.textMuted, backdropColor: 'transparent' }, grid: { - color: 'rgba(148, 163, 184, 0.2)' + color: palette.grid }, angleLines: { - color: 'rgba(148, 163, 184, 0.2)' + color: palette.grid }, pointLabels: { - color: '#f8fafc', + color: palette.textSecondary, font: { size: 12 } @@ -163,7 +171,7 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { legend: { position: 'bottom', labels: { - color: '#f8fafc', + color: palette.textMain, padding: 20, font: { size: 13 @@ -187,6 +195,8 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { const barCtx = barCanvas?.getContext('2d'); if (!barCtx) return; + const palette = getChartThemePalette(); + if (!barChartInstance) { barChartInstance = new ChartCtor(barCtx, { type: 'bar', @@ -217,20 +227,20 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { scales: { x: { grid: { - color: 'rgba(148, 163, 184, 0.1)' + color: palette.gridSubtle }, ticks: { - color: '#94a3b8' + color: palette.textMuted } }, y: { beginAtZero: true, max: 100, grid: { - color: 'rgba(148, 163, 184, 0.1)' + color: palette.gridSubtle }, ticks: { - color: '#94a3b8' + color: palette.textMuted } } }, @@ -238,7 +248,7 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { legend: { position: 'bottom', labels: { - color: '#f8fafc', + color: palette.textMain, padding: 20, font: { size: 13 @@ -257,6 +267,69 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { barChartInstance.update('none'); } +function getChartThemePalette() { + const styles = getComputedStyle(document.documentElement); + const readVar = (name, fallback) => { + const value = styles.getPropertyValue(name).trim(); + return value || fallback; + }; + + return { + textMain: readVar('--color-text-main', '#0f172a'), + textSecondary: readVar('--color-text-secondary', '#334155'), + textMuted: readVar('--color-text-muted', '#64748b'), + surface: readVar('--color-surface-elevated', '#ffffff'), + grid: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.22)'), + gridSubtle: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.16)') + }; +} + +function applyThemeToExistingCharts() { + const palette = getChartThemePalette(); + + if (radarChartInstance) { + const radarOptions = radarChartInstance.options; + const radarScale = radarOptions.scales?.r; + const radarLegendLabels = radarOptions.plugins?.legend?.labels; + if (radarScale) { + radarScale.ticks.color = palette.textMuted; + radarScale.grid.color = palette.grid; + radarScale.angleLines.color = palette.grid; + radarScale.pointLabels.color = palette.textSecondary; + } + if (radarLegendLabels) { + radarLegendLabels.color = palette.textMain; + } + + radarChartInstance.data.datasets.forEach((dataset) => { + dataset.pointBorderColor = palette.surface; + dataset.pointHoverBackgroundColor = palette.surface; + }); + + radarChartInstance.update('none'); + } + + if (barChartInstance) { + const barOptions = barChartInstance.options; + const xScale = barOptions.scales?.x; + const yScale = barOptions.scales?.y; + const barLegendLabels = barOptions.plugins?.legend?.labels; + if (xScale) { + xScale.grid.color = palette.gridSubtle; + xScale.ticks.color = palette.textMuted; + } + if (yScale) { + yScale.grid.color = palette.gridSubtle; + yScale.ticks.color = palette.textMuted; + } + if (barLegendLabels) { + barLegendLabels.color = palette.textMain; + } + + barChartInstance.update('none'); + } +} + function clearCanvas(canvasId) { const canvas = document.getElementById(canvasId); const context = canvas?.getContext?.('2d'); diff --git a/frontend/main.js b/frontend/main.js index 4cd0fd4..bdbbff9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -4,6 +4,7 @@ import './style.css'; import { checkDisclaimer, loadGradesData } from './storage.js'; +import { setupThemeToggle } from './theme.js'; // ── Lazy init 狀態 ────────────────────── let syncInited = false; @@ -27,6 +28,7 @@ export async function ensureShareReady() { // ── 首屏必要初始化 ────────────────────── document.addEventListener('DOMContentLoaded', () => { + setupThemeToggle(); checkDisclaimer(); loadGradesData(); diff --git a/frontend/styles/header.css b/frontend/styles/header.css index 19176b5..309a289 100644 --- a/frontend/styles/header.css +++ b/frontend/styles/header.css @@ -16,7 +16,7 @@ padding-right: 24px; - background: rgba(28, 28, 30, 0.72); + background: var(--color-header-bg); backdrop-filter: blur(20px) saturate(1.8); @@ -154,6 +154,11 @@ } +.header-actions { + justify-content: flex-end; + flex-wrap: nowrap; +} + .time-info { @@ -238,7 +243,7 @@ height: 40px; - padding: 0 24px; + padding: 0 18px; background: var(--color-primary); @@ -248,7 +253,7 @@ color: var(--md-sys-color-on-primary); - font-size: 14px; + font-size: 13px; font-weight: 500; @@ -260,6 +265,10 @@ touch-action: manipulation; + white-space: nowrap; + + flex-shrink: 0; + } @@ -476,5 +485,3 @@ visibility: visible; } - - diff --git a/frontend/styles/modal.css b/frontend/styles/modal.css index 52bc2df..063d07c 100644 --- a/frontend/styles/modal.css +++ b/frontend/styles/modal.css @@ -6,7 +6,7 @@ inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--color-modal-overlay); backdrop-filter: blur(10px); @@ -382,7 +382,7 @@ visibility: visible; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); + box-shadow: 0 0 0 3px var(--color-focus-ring); } diff --git a/frontend/styles/motion.css b/frontend/styles/motion.css index 03a5b43..c9def0b 100644 --- a/frontend/styles/motion.css +++ b/frontend/styles/motion.css @@ -132,7 +132,7 @@ ::selection { - background: rgba(0, 122, 255, 0.25); + background: var(--color-selection); } diff --git a/frontend/styles/onboarding.css b/frontend/styles/onboarding.css index 776fabe..0f142e8 100644 --- a/frontend/styles/onboarding.css +++ b/frontend/styles/onboarding.css @@ -20,7 +20,7 @@ inset: 0; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); } @@ -30,17 +30,17 @@ position: fixed; - border: 2px solid rgba(168, 199, 250, 0.95); + border: 2px solid var(--color-tour-highlight); border-radius: 12px; box-shadow: - 0 0 0 9999px rgba(7, 10, 18, 0.68), + 0 0 0 9999px var(--color-tour-overlay), - 0 0 0 1px rgba(255, 255, 255, 0.15) inset, + 0 0 0 1px var(--color-tour-highlight-inset) inset, - 0 0 24px rgba(168, 199, 250, 0.45); + 0 0 24px var(--color-tour-highlight-glow); transition: all 0.2s var(--ease); @@ -164,7 +164,7 @@ z-index: 1550; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); display: flex; diff --git a/frontend/styles/responsive.css b/frontend/styles/responsive.css index 61b2cd6..b358f42 100644 --- a/frontend/styles/responsive.css +++ b/frontend/styles/responsive.css @@ -40,6 +40,11 @@ } + .header-actions { + width: auto; + justify-content: flex-end; + } + .logo-text h1 { @@ -190,5 +195,3 @@ } - - diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 31b26e6..5157287 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -1,168 +1,138 @@ :root { + color-scheme: dark; --md-sys-color-primary: #a8c7fa; - --md-sys-color-on-primary: #062e6f; - --md-sys-color-primary-container: #224a94; - --md-sys-color-on-primary-container: #d6e3ff; - - --md-sys-color-secondary: #c2c7cf; - --md-sys-color-on-secondary: #2b313a; - --md-sys-color-secondary-container: #424751; - --md-sys-color-on-secondary-container: #dee3ec; - - --md-sys-color-error: #ffb4ab; - --md-sys-color-on-error: #690005; - --md-sys-color-error-container: #93000a; - --md-sys-color-on-error-container: #ffdad6; - - --md-sys-color-background: #111318; - --md-sys-color-on-background: #e1e2e8; - - - --md-sys-color-surface: #111318; - --md-sys-color-on-surface: #e1e2e8; - - - --md-sys-color-surface-variant: #44474e; - --md-sys-color-on-surface-variant: #c4c6d0; - - - --md-sys-color-outline: #8e9099; - --md-sys-color-outline-variant: #44474e; - - /* Semantic mapping */ - --color-primary: var(--md-sys-color-primary); - --color-primary-hover: var(--md-sys-color-on-primary-container); - --color-primary-muted: var(--md-sys-color-primary-container); - - --color-success: #81c995; - --color-success-muted: rgba(129, 201, 149, 0.15); - --color-warning: #fde293; - --color-warning-muted: rgba(253, 226, 147, 0.15); - --color-danger: var(--md-sys-color-error); - --color-danger-muted: var(--md-sys-color-error-container); - --color-purple: #dfbcf1; - --color-purple-muted: #583f68; - - - /* Surfaces ??MD3 Elevations */ - + /* Surfaces */ --color-bg: var(--md-sys-color-background); - --color-surface: var(--md-sys-color-surface); - --color-surface-elevated: #1e2025; - - /* elevation 1 */ - --color-surface-hover: #292b30; - - /* elevation 2 */ - --color-fill: var(--md-sys-color-surface-variant); - - /* Text */ - --color-text-main: var(--md-sys-color-on-surface); - --color-text-secondary: var(--md-sys-color-on-surface-variant); - --color-text-muted: var(--md-sys-color-outline); - - /* Separator */ - --color-border: var(--md-sys-color-outline-variant); - --color-border-subtle: rgba(142, 144, 153, 0.2); - - - /* Shadow ??MD3 Elevations */ - - --shadow-soft: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); - - --shadow-hover: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15); - + /* Shadows */ + --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-hover: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); --shadow-card: none; - /* M3 relies on surface color in dark mode */ - - - - /* Radius ??MD3 standard */ - + /* Radius */ --radius-lg: 24px; - - /* Extra large */ - --radius-md: 16px; - - /* Large */ - --radius-sm: 8px; - - /* Medium/small fallback */ - --radius-pill: 9999px; - - /* Motion */ - --ease: cubic-bezier(0.2, 0, 0, 1); - --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); - --duration: 0.2s; - - /* Font */ - --font-stack: 'Roboto', 'Noto Sans TC', sans-serif; + /* Overlay and emphasis */ + --color-header-bg: rgba(28, 28, 30, 0.72); + --color-modal-overlay: rgba(0, 0, 0, 0.45); + --color-tour-overlay: rgba(7, 10, 18, 0.68); + --color-tour-highlight: rgba(168, 199, 250, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.15); + --color-tour-highlight-glow: rgba(168, 199, 250, 0.45); + --color-selection: rgba(0, 122, 255, 0.25); + --color-focus-ring: rgba(0, 122, 255, 0.28); } - - +:root[data-theme='light'] { + color-scheme: light; + + --md-sys-color-primary: #2d5daa; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #d7e3ff; + --md-sys-color-on-primary-container: #12326a; + + --md-sys-color-secondary: #565f71; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #dae2f9; + --md-sys-color-on-secondary-container: #131c2b; + + --md-sys-color-error: #ba1a1a; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #ffdad6; + --md-sys-color-on-error-container: #410002; + + --md-sys-color-background: #f8f9ff; + --md-sys-color-on-background: #181b21; + --md-sys-color-surface: #f8f9ff; + --md-sys-color-on-surface: #181b21; + --md-sys-color-surface-variant: #e1e2ec; + --md-sys-color-on-surface-variant: #444750; + --md-sys-color-outline: #74777f; + --md-sys-color-outline-variant: #c5c6d0; + + --color-success: #126738; + --color-success-muted: rgba(18, 103, 56, 0.12); + --color-warning: #946c00; + --color-warning-muted: rgba(148, 108, 0, 0.14); + --color-purple: #635b86; + --color-purple-muted: #ece7f8; + + --color-surface-elevated: #ffffff; + --color-surface-hover: #eef1fa; + --color-border-subtle: rgba(116, 119, 127, 0.25); + + --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); + --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); + --shadow-card: 0 1px 2px 0 rgba(24, 27, 33, 0.04); + + --color-header-bg: rgba(248, 249, 255, 0.88); + --color-modal-overlay: rgba(17, 19, 24, 0.3); + --color-tour-overlay: rgba(17, 19, 24, 0.52); + --color-tour-highlight: rgba(45, 93, 170, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.85); + --color-tour-highlight-glow: rgba(45, 93, 170, 0.35); + --color-selection: rgba(45, 93, 170, 0.22); + --color-focus-ring: rgba(45, 93, 170, 0.2); +} diff --git a/frontend/styles/utilities.css b/frontend/styles/utilities.css index 385457d..b6c177c 100644 --- a/frontend/styles/utilities.css +++ b/frontend/styles/utilities.css @@ -74,6 +74,25 @@ } +.theme-toggle-btn { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + background: var(--color-surface-elevated); + color: var(--color-text-main); + border: 1px solid var(--color-border-subtle); +} + +.theme-toggle-btn:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.theme-toggle-btn .dropdown-icon { + font-size: 18px; +} + /* share.js utils */ diff --git a/frontend/theme.js b/frontend/theme.js new file mode 100644 index 0000000..9cc3f48 --- /dev/null +++ b/frontend/theme.js @@ -0,0 +1,50 @@ +const STORAGE_KEY = 'grades-theme'; + +function getInitialTheme() { + const storedTheme = localStorage.getItem(STORAGE_KEY); + if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; + return 'dark'; +} + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + } else { + root.removeAttribute('data-theme'); + } + + const toggleBtn = document.getElementById('themeToggleBtn'); + const sunIcon = toggleBtn?.querySelector('.theme-icon-sun'); + const moonIcon = toggleBtn?.querySelector('.theme-icon-moon'); + + if (sunIcon && moonIcon) { + const isLight = theme === 'light'; + sunIcon.classList.toggle('hidden', !isLight); + moonIcon.classList.toggle('hidden', isLight); + } + + if (toggleBtn) { + const nextThemeLabel = theme === 'light' ? '深色' : '淺色'; + toggleBtn.setAttribute('aria-label', `切換至${nextThemeLabel}模式`); + toggleBtn.setAttribute('title', `切換至${nextThemeLabel}模式`); + } + + document.dispatchEvent(new CustomEvent('themechange', { + detail: { theme } + })); +} + +export function setupThemeToggle() { + const toggleBtn = document.getElementById('themeToggleBtn'); + if (!toggleBtn) return; + + let currentTheme = getInitialTheme(); + applyTheme(currentTheme); + + toggleBtn.addEventListener('click', () => { + currentTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(currentTheme); + localStorage.setItem(STORAGE_KEY, currentTheme); + }); +} diff --git a/public/index.html b/public/index.html index 619c31d..7099f99 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,15 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - + + @@ -78,7 +86,24 @@

成績分析平台

資料更新時間 --
-
+
+
- + - \ No newline at end of file + From 45abbf6ff6f2265bb8be5e817b3da6510fdaaff7 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:10:46 +0800 Subject: [PATCH 06/11] fix: swap theme toggle to md3-style svg icons --- frontend/charts.js | 101 +++++++++++++++++--- frontend/main.js | 2 + frontend/styles/header.css | 17 +++- frontend/styles/modal.css | 4 +- frontend/styles/motion.css | 2 +- frontend/styles/onboarding.css | 12 +-- frontend/styles/responsive.css | 7 +- frontend/styles/tokens.css | 162 ++++++++++++++------------------- frontend/styles/utilities.css | 19 ++++ frontend/theme.js | 50 ++++++++++ public/index.html | 33 ++++++- 11 files changed, 279 insertions(+), 130 deletions(-) create mode 100644 frontend/theme.js diff --git a/frontend/charts.js b/frontend/charts.js index 1a84099..5fc5021 100644 --- a/frontend/charts.js +++ b/frontend/charts.js @@ -13,6 +13,12 @@ let barChartInstance = null; let chartJsLoadPromise = null; let latestRenderToken = 0; +if (typeof document !== 'undefined') { + document.addEventListener('themechange', () => { + applyThemeToExistingCharts(); + }); +} + export function generateCharts(subjects) { if (!Array.isArray(subjects) || subjects.length === 0) return; @@ -103,6 +109,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { const radarCtx = radarCanvas?.getContext('2d'); if (!radarCtx) return; + const palette = getChartThemePalette(); + if (!radarChartInstance) { radarChartInstance = new ChartCtor(radarCtx, { type: 'radar', @@ -116,8 +124,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { borderColor: '#6366f1', borderWidth: 2, pointBackgroundColor: '#6366f1', - pointBorderColor: '#fff', - pointHoverBackgroundColor: '#fff', + pointBorderColor: palette.surface, + pointHoverBackgroundColor: palette.surface, pointHoverBorderColor: '#6366f1' }, { @@ -127,8 +135,8 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { borderColor: '#10b981', borderWidth: 2, pointBackgroundColor: '#10b981', - pointBorderColor: '#fff', - pointHoverBackgroundColor: '#fff', + pointBorderColor: palette.surface, + pointHoverBackgroundColor: palette.surface, pointHoverBorderColor: '#10b981' } ] @@ -142,17 +150,17 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { max: 100, ticks: { stepSize: 20, - color: '#94a3b8', + color: palette.textMuted, backdropColor: 'transparent' }, grid: { - color: 'rgba(148, 163, 184, 0.2)' + color: palette.grid }, angleLines: { - color: 'rgba(148, 163, 184, 0.2)' + color: palette.grid }, pointLabels: { - color: '#f8fafc', + color: palette.textSecondary, font: { size: 12 } @@ -163,7 +171,7 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { legend: { position: 'bottom', labels: { - color: '#f8fafc', + color: palette.textMain, padding: 20, font: { size: 13 @@ -187,6 +195,8 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { const barCtx = barCanvas?.getContext('2d'); if (!barCtx) return; + const palette = getChartThemePalette(); + if (!barChartInstance) { barChartInstance = new ChartCtor(barCtx, { type: 'bar', @@ -217,20 +227,20 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { scales: { x: { grid: { - color: 'rgba(148, 163, 184, 0.1)' + color: palette.gridSubtle }, ticks: { - color: '#94a3b8' + color: palette.textMuted } }, y: { beginAtZero: true, max: 100, grid: { - color: 'rgba(148, 163, 184, 0.1)' + color: palette.gridSubtle }, ticks: { - color: '#94a3b8' + color: palette.textMuted } } }, @@ -238,7 +248,7 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { legend: { position: 'bottom', labels: { - color: '#f8fafc', + color: palette.textMain, padding: 20, font: { size: 13 @@ -257,6 +267,69 @@ function updateBarChart(ChartCtor, labels, myScores, avgScores) { barChartInstance.update('none'); } +function getChartThemePalette() { + const styles = getComputedStyle(document.documentElement); + const readVar = (name, fallback) => { + const value = styles.getPropertyValue(name).trim(); + return value || fallback; + }; + + return { + textMain: readVar('--color-text-main', '#0f172a'), + textSecondary: readVar('--color-text-secondary', '#334155'), + textMuted: readVar('--color-text-muted', '#64748b'), + surface: readVar('--color-surface-elevated', '#ffffff'), + grid: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.22)'), + gridSubtle: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.16)') + }; +} + +function applyThemeToExistingCharts() { + const palette = getChartThemePalette(); + + if (radarChartInstance) { + const radarOptions = radarChartInstance.options; + const radarScale = radarOptions.scales?.r; + const radarLegendLabels = radarOptions.plugins?.legend?.labels; + if (radarScale) { + radarScale.ticks.color = palette.textMuted; + radarScale.grid.color = palette.grid; + radarScale.angleLines.color = palette.grid; + radarScale.pointLabels.color = palette.textSecondary; + } + if (radarLegendLabels) { + radarLegendLabels.color = palette.textMain; + } + + radarChartInstance.data.datasets.forEach((dataset) => { + dataset.pointBorderColor = palette.surface; + dataset.pointHoverBackgroundColor = palette.surface; + }); + + radarChartInstance.update('none'); + } + + if (barChartInstance) { + const barOptions = barChartInstance.options; + const xScale = barOptions.scales?.x; + const yScale = barOptions.scales?.y; + const barLegendLabels = barOptions.plugins?.legend?.labels; + if (xScale) { + xScale.grid.color = palette.gridSubtle; + xScale.ticks.color = palette.textMuted; + } + if (yScale) { + yScale.grid.color = palette.gridSubtle; + yScale.ticks.color = palette.textMuted; + } + if (barLegendLabels) { + barLegendLabels.color = palette.textMain; + } + + barChartInstance.update('none'); + } +} + function clearCanvas(canvasId) { const canvas = document.getElementById(canvasId); const context = canvas?.getContext?.('2d'); diff --git a/frontend/main.js b/frontend/main.js index 4cd0fd4..bdbbff9 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -4,6 +4,7 @@ import './style.css'; import { checkDisclaimer, loadGradesData } from './storage.js'; +import { setupThemeToggle } from './theme.js'; // ── Lazy init 狀態 ────────────────────── let syncInited = false; @@ -27,6 +28,7 @@ export async function ensureShareReady() { // ── 首屏必要初始化 ────────────────────── document.addEventListener('DOMContentLoaded', () => { + setupThemeToggle(); checkDisclaimer(); loadGradesData(); diff --git a/frontend/styles/header.css b/frontend/styles/header.css index 19176b5..309a289 100644 --- a/frontend/styles/header.css +++ b/frontend/styles/header.css @@ -16,7 +16,7 @@ padding-right: 24px; - background: rgba(28, 28, 30, 0.72); + background: var(--color-header-bg); backdrop-filter: blur(20px) saturate(1.8); @@ -154,6 +154,11 @@ } +.header-actions { + justify-content: flex-end; + flex-wrap: nowrap; +} + .time-info { @@ -238,7 +243,7 @@ height: 40px; - padding: 0 24px; + padding: 0 18px; background: var(--color-primary); @@ -248,7 +253,7 @@ color: var(--md-sys-color-on-primary); - font-size: 14px; + font-size: 13px; font-weight: 500; @@ -260,6 +265,10 @@ touch-action: manipulation; + white-space: nowrap; + + flex-shrink: 0; + } @@ -476,5 +485,3 @@ visibility: visible; } - - diff --git a/frontend/styles/modal.css b/frontend/styles/modal.css index 52bc2df..063d07c 100644 --- a/frontend/styles/modal.css +++ b/frontend/styles/modal.css @@ -6,7 +6,7 @@ inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--color-modal-overlay); backdrop-filter: blur(10px); @@ -382,7 +382,7 @@ visibility: visible; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2); + box-shadow: 0 0 0 3px var(--color-focus-ring); } diff --git a/frontend/styles/motion.css b/frontend/styles/motion.css index 03a5b43..c9def0b 100644 --- a/frontend/styles/motion.css +++ b/frontend/styles/motion.css @@ -132,7 +132,7 @@ ::selection { - background: rgba(0, 122, 255, 0.25); + background: var(--color-selection); } diff --git a/frontend/styles/onboarding.css b/frontend/styles/onboarding.css index 776fabe..0f142e8 100644 --- a/frontend/styles/onboarding.css +++ b/frontend/styles/onboarding.css @@ -20,7 +20,7 @@ inset: 0; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); } @@ -30,17 +30,17 @@ position: fixed; - border: 2px solid rgba(168, 199, 250, 0.95); + border: 2px solid var(--color-tour-highlight); border-radius: 12px; box-shadow: - 0 0 0 9999px rgba(7, 10, 18, 0.68), + 0 0 0 9999px var(--color-tour-overlay), - 0 0 0 1px rgba(255, 255, 255, 0.15) inset, + 0 0 0 1px var(--color-tour-highlight-inset) inset, - 0 0 24px rgba(168, 199, 250, 0.45); + 0 0 24px var(--color-tour-highlight-glow); transition: all 0.2s var(--ease); @@ -164,7 +164,7 @@ z-index: 1550; - background: rgba(7, 10, 18, 0.68); + background: var(--color-tour-overlay); display: flex; diff --git a/frontend/styles/responsive.css b/frontend/styles/responsive.css index 61b2cd6..b358f42 100644 --- a/frontend/styles/responsive.css +++ b/frontend/styles/responsive.css @@ -40,6 +40,11 @@ } + .header-actions { + width: auto; + justify-content: flex-end; + } + .logo-text h1 { @@ -190,5 +195,3 @@ } - - diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 31b26e6..5157287 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -1,168 +1,138 @@ :root { + color-scheme: dark; --md-sys-color-primary: #a8c7fa; - --md-sys-color-on-primary: #062e6f; - --md-sys-color-primary-container: #224a94; - --md-sys-color-on-primary-container: #d6e3ff; - - --md-sys-color-secondary: #c2c7cf; - --md-sys-color-on-secondary: #2b313a; - --md-sys-color-secondary-container: #424751; - --md-sys-color-on-secondary-container: #dee3ec; - - --md-sys-color-error: #ffb4ab; - --md-sys-color-on-error: #690005; - --md-sys-color-error-container: #93000a; - --md-sys-color-on-error-container: #ffdad6; - - --md-sys-color-background: #111318; - --md-sys-color-on-background: #e1e2e8; - - - --md-sys-color-surface: #111318; - --md-sys-color-on-surface: #e1e2e8; - - - --md-sys-color-surface-variant: #44474e; - --md-sys-color-on-surface-variant: #c4c6d0; - - - --md-sys-color-outline: #8e9099; - --md-sys-color-outline-variant: #44474e; - - /* Semantic mapping */ - --color-primary: var(--md-sys-color-primary); - --color-primary-hover: var(--md-sys-color-on-primary-container); - --color-primary-muted: var(--md-sys-color-primary-container); - - --color-success: #81c995; - --color-success-muted: rgba(129, 201, 149, 0.15); - --color-warning: #fde293; - --color-warning-muted: rgba(253, 226, 147, 0.15); - --color-danger: var(--md-sys-color-error); - --color-danger-muted: var(--md-sys-color-error-container); - --color-purple: #dfbcf1; - --color-purple-muted: #583f68; - - - /* Surfaces ??MD3 Elevations */ - + /* Surfaces */ --color-bg: var(--md-sys-color-background); - --color-surface: var(--md-sys-color-surface); - --color-surface-elevated: #1e2025; - - /* elevation 1 */ - --color-surface-hover: #292b30; - - /* elevation 2 */ - --color-fill: var(--md-sys-color-surface-variant); - - /* Text */ - --color-text-main: var(--md-sys-color-on-surface); - --color-text-secondary: var(--md-sys-color-on-surface-variant); - --color-text-muted: var(--md-sys-color-outline); - - /* Separator */ - --color-border: var(--md-sys-color-outline-variant); - --color-border-subtle: rgba(142, 144, 153, 0.2); - - - /* Shadow ??MD3 Elevations */ - - --shadow-soft: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); - - --shadow-hover: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15); - + /* Shadows */ + --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --shadow-hover: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); --shadow-card: none; - /* M3 relies on surface color in dark mode */ - - - - /* Radius ??MD3 standard */ - + /* Radius */ --radius-lg: 24px; - - /* Extra large */ - --radius-md: 16px; - - /* Large */ - --radius-sm: 8px; - - /* Medium/small fallback */ - --radius-pill: 9999px; - - /* Motion */ - --ease: cubic-bezier(0.2, 0, 0, 1); - --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); - --duration: 0.2s; - - /* Font */ - --font-stack: 'Roboto', 'Noto Sans TC', sans-serif; + /* Overlay and emphasis */ + --color-header-bg: rgba(28, 28, 30, 0.72); + --color-modal-overlay: rgba(0, 0, 0, 0.45); + --color-tour-overlay: rgba(7, 10, 18, 0.68); + --color-tour-highlight: rgba(168, 199, 250, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.15); + --color-tour-highlight-glow: rgba(168, 199, 250, 0.45); + --color-selection: rgba(0, 122, 255, 0.25); + --color-focus-ring: rgba(0, 122, 255, 0.28); } - - +:root[data-theme='light'] { + color-scheme: light; + + --md-sys-color-primary: #2d5daa; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #d7e3ff; + --md-sys-color-on-primary-container: #12326a; + + --md-sys-color-secondary: #565f71; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #dae2f9; + --md-sys-color-on-secondary-container: #131c2b; + + --md-sys-color-error: #ba1a1a; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #ffdad6; + --md-sys-color-on-error-container: #410002; + + --md-sys-color-background: #f8f9ff; + --md-sys-color-on-background: #181b21; + --md-sys-color-surface: #f8f9ff; + --md-sys-color-on-surface: #181b21; + --md-sys-color-surface-variant: #e1e2ec; + --md-sys-color-on-surface-variant: #444750; + --md-sys-color-outline: #74777f; + --md-sys-color-outline-variant: #c5c6d0; + + --color-success: #126738; + --color-success-muted: rgba(18, 103, 56, 0.12); + --color-warning: #946c00; + --color-warning-muted: rgba(148, 108, 0, 0.14); + --color-purple: #635b86; + --color-purple-muted: #ece7f8; + + --color-surface-elevated: #ffffff; + --color-surface-hover: #eef1fa; + --color-border-subtle: rgba(116, 119, 127, 0.25); + + --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); + --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); + --shadow-card: 0 1px 2px 0 rgba(24, 27, 33, 0.04); + + --color-header-bg: rgba(248, 249, 255, 0.88); + --color-modal-overlay: rgba(17, 19, 24, 0.3); + --color-tour-overlay: rgba(17, 19, 24, 0.52); + --color-tour-highlight: rgba(45, 93, 170, 0.95); + --color-tour-highlight-inset: rgba(255, 255, 255, 0.85); + --color-tour-highlight-glow: rgba(45, 93, 170, 0.35); + --color-selection: rgba(45, 93, 170, 0.22); + --color-focus-ring: rgba(45, 93, 170, 0.2); +} diff --git a/frontend/styles/utilities.css b/frontend/styles/utilities.css index 385457d..b6c177c 100644 --- a/frontend/styles/utilities.css +++ b/frontend/styles/utilities.css @@ -74,6 +74,25 @@ } +.theme-toggle-btn { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + background: var(--color-surface-elevated); + color: var(--color-text-main); + border: 1px solid var(--color-border-subtle); +} + +.theme-toggle-btn:hover { + background: var(--color-surface-hover); + border-color: var(--color-border); +} + +.theme-toggle-btn .dropdown-icon { + font-size: 18px; +} + /* share.js utils */ diff --git a/frontend/theme.js b/frontend/theme.js new file mode 100644 index 0000000..9cc3f48 --- /dev/null +++ b/frontend/theme.js @@ -0,0 +1,50 @@ +const STORAGE_KEY = 'grades-theme'; + +function getInitialTheme() { + const storedTheme = localStorage.getItem(STORAGE_KEY); + if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; + return 'dark'; +} + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + } else { + root.removeAttribute('data-theme'); + } + + const toggleBtn = document.getElementById('themeToggleBtn'); + const sunIcon = toggleBtn?.querySelector('.theme-icon-sun'); + const moonIcon = toggleBtn?.querySelector('.theme-icon-moon'); + + if (sunIcon && moonIcon) { + const isLight = theme === 'light'; + sunIcon.classList.toggle('hidden', !isLight); + moonIcon.classList.toggle('hidden', isLight); + } + + if (toggleBtn) { + const nextThemeLabel = theme === 'light' ? '深色' : '淺色'; + toggleBtn.setAttribute('aria-label', `切換至${nextThemeLabel}模式`); + toggleBtn.setAttribute('title', `切換至${nextThemeLabel}模式`); + } + + document.dispatchEvent(new CustomEvent('themechange', { + detail: { theme } + })); +} + +export function setupThemeToggle() { + const toggleBtn = document.getElementById('themeToggleBtn'); + if (!toggleBtn) return; + + let currentTheme = getInitialTheme(); + applyTheme(currentTheme); + + toggleBtn.addEventListener('click', () => { + currentTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(currentTheme); + localStorage.setItem(STORAGE_KEY, currentTheme); + }); +} diff --git a/public/index.html b/public/index.html index 619c31d..a1dd31d 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,15 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - + + @@ -78,7 +86,24 @@

成績分析平台

資料更新時間 --
-
+
+
- + - \ No newline at end of file + From 2e9d751c249a83fe6cade8013c9447cc6dcaabba Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:27:38 +0800 Subject: [PATCH 07/11] Update light and dark mode toggle SVG icons --- public/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/index.html b/public/index.html index a1dd31d..07ea48e 100644 --- a/public/index.html +++ b/public/index.html @@ -93,14 +93,14 @@

成績分析平台

+ d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q14 0 27.5 1t26.5 3q-41 29-65.5 75.5T444-660q0 90 63 153t153 63q55 0 101-24.5t75-65.5q2 13 3 26.5t1 27.5q0 150-105 255T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z" /> From 974f03f2d20dae072a64a5aea4de5ce110c75392 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:52:58 +0800 Subject: [PATCH 08/11] Fix typo in heading from 'vs' to 'vs.' --- public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 07ea48e..458c451 100644 --- a/public/index.html +++ b/public/index.html @@ -422,7 +422,7 @@

d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q56 0 105.5-17.5T676-227l-57-57q-29 21-64.5 32.5T480-240q-100 0-170-70t-70-170q0-100 70-170t170-70q100 0 170 70t70 170q0 39-12 75t-33 65l57 57q32-41 50-91t18-106q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-160q22 0 42.5-5.5T561-342l-61-61q-5 2-10 2.5t-10 .5q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 6-.5 11.5T557-458l60 60q11-18 17-38.5t6-43.5q0-66-47-113t-113-47q-66 0-113 47t-47 113q0 66 47 113t113 47Z" /> - 個人 vs 班級平均 + 個人 vs. 班級平均

From 5099d53f605228fb1290abc6b30a3f0de72705d7 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:15:11 +0800 Subject: [PATCH 09/11] Fix theme init CSP and improve theme/chart test coverage --- frontend/charts.js | 20 ++--- frontend/styles/tokens.css | 2 + frontend/theme.js | 4 +- public/index.html | 13 +-- public/theme-init.js | 6 ++ tests/frontend/theme.test.js | 150 +++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 public/theme-init.js create mode 100644 tests/frontend/theme.test.js diff --git a/frontend/charts.js b/frontend/charts.js index 5fc5021..d8f1060 100644 --- a/frontend/charts.js +++ b/frontend/charts.js @@ -25,13 +25,14 @@ export function generateCharts(subjects) { const labels = subjects.map((subject) => shortenName(subject.SubjectName)); const myScores = subjects.map((subject) => subject.scoreValue ?? getNumericScore(subject.ScoreDisplay, subject.Score)); const avgScores = subjects.map((subject) => subject.classAvgValue ?? getNumericScore(subject.ClassAVGScoreDisplay, subject.ClassAVGScore)); + const palette = getChartThemePalette(); const renderToken = ++latestRenderToken; ensureChartJsLoaded() .then((ChartCtor) => { if (renderToken !== latestRenderToken) return; - updateRadarChart(ChartCtor, labels, myScores, avgScores); - updateBarChart(ChartCtor, labels, myScores, avgScores); + updateRadarChart(ChartCtor, labels, myScores, avgScores, palette); + updateBarChart(ChartCtor, labels, myScores, avgScores, palette); }) .catch((error) => { console.warn('Failed to load Chart.js', error); @@ -104,13 +105,11 @@ function ensureChartJsLoaded() { return chartJsLoadPromise; } -function updateRadarChart(ChartCtor, labels, myScores, avgScores) { +function updateRadarChart(ChartCtor, labels, myScores, avgScores, palette) { const radarCanvas = document.getElementById('radarChart'); const radarCtx = radarCanvas?.getContext('2d'); if (!radarCtx) return; - const palette = getChartThemePalette(); - if (!radarChartInstance) { radarChartInstance = new ChartCtor(radarCtx, { type: 'radar', @@ -190,13 +189,11 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { radarChartInstance.update('none'); } -function updateBarChart(ChartCtor, labels, myScores, avgScores) { +function updateBarChart(ChartCtor, labels, myScores, avgScores, palette) { const barCanvas = document.getElementById('barChart'); const barCtx = barCanvas?.getContext('2d'); if (!barCtx) return; - const palette = getChartThemePalette(); - if (!barChartInstance) { barChartInstance = new ChartCtor(barCtx, { type: 'bar', @@ -280,7 +277,7 @@ function getChartThemePalette() { textMuted: readVar('--color-text-muted', '#64748b'), surface: readVar('--color-surface-elevated', '#ffffff'), grid: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.22)'), - gridSubtle: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.16)') + gridSubtle: readVar('--color-border-extra-subtle', 'rgba(148, 163, 184, 0.16)') }; } @@ -330,6 +327,11 @@ function applyThemeToExistingCharts() { } } +export function __setChartInstancesForTest(instances = {}) { + if (Object.hasOwn(instances, 'radar')) radarChartInstance = instances.radar; + if (Object.hasOwn(instances, 'bar')) barChartInstance = instances.bar; +} + function clearCanvas(canvasId) { const canvas = document.getElementById(canvasId); const context = canvas?.getContext?.('2d'); diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 5157287..5a416a4 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -54,6 +54,7 @@ /* Separator */ --color-border: var(--md-sys-color-outline-variant); --color-border-subtle: rgba(142, 144, 153, 0.2); + --color-border-extra-subtle: rgba(142, 144, 153, 0.14); /* Shadows */ --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); @@ -122,6 +123,7 @@ --color-surface-elevated: #ffffff; --color-surface-hover: #eef1fa; --color-border-subtle: rgba(116, 119, 127, 0.25); + --color-border-extra-subtle: rgba(116, 119, 127, 0.16); --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); diff --git a/frontend/theme.js b/frontend/theme.js index 9cc3f48..06fcf14 100644 --- a/frontend/theme.js +++ b/frontend/theme.js @@ -1,12 +1,12 @@ const STORAGE_KEY = 'grades-theme'; -function getInitialTheme() { +export function getInitialTheme() { const storedTheme = localStorage.getItem(STORAGE_KEY); if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; return 'dark'; } -function applyTheme(theme) { +export function applyTheme(theme) { const root = document.documentElement; if (theme === 'light') { root.setAttribute('data-theme', 'light'); diff --git a/public/index.html b/public/index.html index 458c451..26933f0 100644 --- a/public/index.html +++ b/public/index.html @@ -39,15 +39,8 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - - + + @@ -530,7 +523,7 @@

- + diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 0000000..5393ab2 --- /dev/null +++ b/public/theme-init.js @@ -0,0 +1,6 @@ +(() => { + const storedTheme = localStorage.getItem('grades-theme'); + if (storedTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } +})(); diff --git a/tests/frontend/theme.test.js b/tests/frontend/theme.test.js new file mode 100644 index 0000000..f79f433 --- /dev/null +++ b/tests/frontend/theme.test.js @@ -0,0 +1,150 @@ +import { beforeEach, afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +let dom; +let cleanupGlobals = []; + +function installDom(html = '') { + dom = new JSDOM(html, { url: 'https://example.test' }); + const map = { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + CustomEvent: dom.window.CustomEvent, + getComputedStyle: dom.window.getComputedStyle + }; + + cleanupGlobals = Object.entries(map).map(([key, value]) => { + const previous = globalThis[key]; + globalThis[key] = value; + return [key, previous]; + }); +} + +function uninstallDom() { + for (const [key, previous] of cleanupGlobals) { + if (previous === undefined) { + delete globalThis[key]; + } else { + globalThis[key] = previous; + } + } + cleanupGlobals = []; + dom?.window?.close(); +} + +describe('theme.js', () => { + beforeEach(() => { + installDom(` + + `); + }); + + afterEach(() => { + uninstallDom(); + }); + + it('getInitialTheme should handle stored light/dark/invalid/missing values', async () => { + const { getInitialTheme } = await import(`../../frontend/theme.js?case=${Math.random()}`); + + localStorage.setItem('grades-theme', 'light'); + assert.equal(getInitialTheme(), 'light'); + + localStorage.setItem('grades-theme', 'dark'); + assert.equal(getInitialTheme(), 'dark'); + + localStorage.setItem('grades-theme', 'unexpected'); + assert.equal(getInitialTheme(), 'dark'); + + localStorage.removeItem('grades-theme'); + assert.equal(getInitialTheme(), 'dark'); + }); + + it('applyTheme should update data-theme, icon states, aria-label and title', async () => { + const { applyTheme } = await import(`../../frontend/theme.js?case=${Math.random()}`); + const root = document.documentElement; + const btn = document.getElementById('themeToggleBtn'); + const moon = btn.querySelector('.theme-icon-moon'); + const sun = btn.querySelector('.theme-icon-sun'); + + applyTheme('light'); + assert.equal(root.getAttribute('data-theme'), 'light'); + assert.equal(sun.classList.contains('hidden'), false); + assert.equal(moon.classList.contains('hidden'), true); + assert.equal(btn.getAttribute('aria-label'), '切換至深色模式'); + assert.equal(btn.getAttribute('title'), '切換至深色模式'); + + applyTheme('dark'); + assert.equal(root.hasAttribute('data-theme'), false); + assert.equal(sun.classList.contains('hidden'), true); + assert.equal(moon.classList.contains('hidden'), false); + assert.equal(btn.getAttribute('aria-label'), '切換至淺色模式'); + assert.equal(btn.getAttribute('title'), '切換至淺色模式'); + }); + + it('setupThemeToggle should toggle theme, persist localStorage and dispatch themechange', async () => { + const { setupThemeToggle } = await import(`../../frontend/theme.js?case=${Math.random()}`); + localStorage.setItem('grades-theme', 'dark'); + + const events = []; + document.addEventListener('themechange', (event) => events.push(event.detail.theme)); + + setupThemeToggle(); + const btn = document.getElementById('themeToggleBtn'); + + btn.click(); + assert.equal(document.documentElement.getAttribute('data-theme'), 'light'); + assert.equal(localStorage.getItem('grades-theme'), 'light'); + + btn.click(); + assert.equal(document.documentElement.hasAttribute('data-theme'), false); + assert.equal(localStorage.getItem('grades-theme'), 'dark'); + + assert.deepEqual(events, ['dark', 'light', 'dark']); + }); + + it('chart integration should re-apply chart palette on themechange', async () => { + document.documentElement.style.setProperty('--color-text-main', 'rgb(1, 2, 3)'); + document.documentElement.style.setProperty('--color-text-secondary', 'rgb(10, 20, 30)'); + document.documentElement.style.setProperty('--color-text-muted', 'rgb(40, 50, 60)'); + document.documentElement.style.setProperty('--color-surface-elevated', 'rgb(70, 80, 90)'); + document.documentElement.style.setProperty('--color-border-subtle', 'rgba(1,1,1,0.3)'); + document.documentElement.style.setProperty('--color-border-extra-subtle', 'rgba(2,2,2,0.2)'); + + const charts = await import(`../../frontend/charts.js?case=${Math.random()}`); + const { applyTheme } = await import(`../../frontend/theme.js?case=${Math.random()}`); + + const radar = { + options: { + scales: { r: { ticks: {}, grid: {}, angleLines: {}, pointLabels: {} } }, + plugins: { legend: { labels: {} } } + }, + data: { datasets: [{}, {}] }, + updateCalls: 0, + update() { this.updateCalls += 1; } + }; + + const bar = { + options: { + scales: { x: { ticks: {}, grid: {} }, y: { ticks: {}, grid: {} } }, + plugins: { legend: { labels: {} } } + }, + data: { datasets: [] }, + updateCalls: 0, + update() { this.updateCalls += 1; } + }; + + charts.__setChartInstancesForTest({ radar, bar }); + applyTheme('light'); + + assert.equal(radar.options.scales.r.grid.color, 'rgba(1,1,1,0.3)'); + assert.equal(bar.options.scales.x.grid.color, 'rgba(2,2,2,0.2)'); + assert.equal(radar.data.datasets[0].pointBorderColor, 'rgb(70, 80, 90)'); + assert.equal(radar.updateCalls, 1); + assert.equal(bar.updateCalls, 1); + }); +}); From 4f8446503d7e8b7e6d6e241e20a5eaf560785100 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:22:42 +0800 Subject: [PATCH 10/11] Fix chart palette staleness and stabilize theme tests --- frontend/charts.js | 32 ++++--- frontend/styles/tokens.css | 2 + frontend/theme.js | 4 +- public/index.html | 13 +-- public/theme-init.js | 6 ++ tests/frontend/theme.test.js | 158 +++++++++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 public/theme-init.js create mode 100644 tests/frontend/theme.test.js diff --git a/frontend/charts.js b/frontend/charts.js index 5fc5021..9262639 100644 --- a/frontend/charts.js +++ b/frontend/charts.js @@ -12,9 +12,11 @@ let radarChartInstance = null; let barChartInstance = null; let chartJsLoadPromise = null; let latestRenderToken = 0; +let cachedChartPalette = null; if (typeof document !== 'undefined') { document.addEventListener('themechange', () => { + cachedChartPalette = null; applyThemeToExistingCharts(); }); } @@ -30,8 +32,10 @@ export function generateCharts(subjects) { ensureChartJsLoaded() .then((ChartCtor) => { if (renderToken !== latestRenderToken) return; - updateRadarChart(ChartCtor, labels, myScores, avgScores); - updateBarChart(ChartCtor, labels, myScores, avgScores); + + const palette = getCachedChartThemePalette(); + updateRadarChart(ChartCtor, labels, myScores, avgScores, palette); + updateBarChart(ChartCtor, labels, myScores, avgScores, palette); }) .catch((error) => { console.warn('Failed to load Chart.js', error); @@ -104,13 +108,11 @@ function ensureChartJsLoaded() { return chartJsLoadPromise; } -function updateRadarChart(ChartCtor, labels, myScores, avgScores) { +function updateRadarChart(ChartCtor, labels, myScores, avgScores, palette) { const radarCanvas = document.getElementById('radarChart'); const radarCtx = radarCanvas?.getContext('2d'); if (!radarCtx) return; - const palette = getChartThemePalette(); - if (!radarChartInstance) { radarChartInstance = new ChartCtor(radarCtx, { type: 'radar', @@ -190,13 +192,11 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { radarChartInstance.update('none'); } -function updateBarChart(ChartCtor, labels, myScores, avgScores) { +function updateBarChart(ChartCtor, labels, myScores, avgScores, palette) { const barCanvas = document.getElementById('barChart'); const barCtx = barCanvas?.getContext('2d'); if (!barCtx) return; - const palette = getChartThemePalette(); - if (!barChartInstance) { barChartInstance = new ChartCtor(barCtx, { type: 'bar', @@ -280,12 +280,19 @@ function getChartThemePalette() { textMuted: readVar('--color-text-muted', '#64748b'), surface: readVar('--color-surface-elevated', '#ffffff'), grid: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.22)'), - gridSubtle: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.16)') + gridSubtle: readVar('--color-border-extra-subtle', 'rgba(148, 163, 184, 0.16)') }; } +function getCachedChartThemePalette() { + if (!cachedChartPalette) { + cachedChartPalette = getChartThemePalette(); + } + return cachedChartPalette; +} + function applyThemeToExistingCharts() { - const palette = getChartThemePalette(); + const palette = getCachedChartThemePalette(); if (radarChartInstance) { const radarOptions = radarChartInstance.options; @@ -330,6 +337,11 @@ function applyThemeToExistingCharts() { } } +export function __setChartInstancesForTest(instances = {}) { + if (Object.hasOwn(instances, 'radar')) radarChartInstance = instances.radar; + if (Object.hasOwn(instances, 'bar')) barChartInstance = instances.bar; +} + function clearCanvas(canvasId) { const canvas = document.getElementById(canvasId); const context = canvas?.getContext?.('2d'); diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 5157287..5a416a4 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -54,6 +54,7 @@ /* Separator */ --color-border: var(--md-sys-color-outline-variant); --color-border-subtle: rgba(142, 144, 153, 0.2); + --color-border-extra-subtle: rgba(142, 144, 153, 0.14); /* Shadows */ --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); @@ -122,6 +123,7 @@ --color-surface-elevated: #ffffff; --color-surface-hover: #eef1fa; --color-border-subtle: rgba(116, 119, 127, 0.25); + --color-border-extra-subtle: rgba(116, 119, 127, 0.16); --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); diff --git a/frontend/theme.js b/frontend/theme.js index 9cc3f48..06fcf14 100644 --- a/frontend/theme.js +++ b/frontend/theme.js @@ -1,12 +1,12 @@ const STORAGE_KEY = 'grades-theme'; -function getInitialTheme() { +export function getInitialTheme() { const storedTheme = localStorage.getItem(STORAGE_KEY); if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; return 'dark'; } -function applyTheme(theme) { +export function applyTheme(theme) { const root = document.documentElement; if (theme === 'light') { root.setAttribute('data-theme', 'light'); diff --git a/public/index.html b/public/index.html index 458c451..26933f0 100644 --- a/public/index.html +++ b/public/index.html @@ -39,15 +39,8 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - - + + @@ -530,7 +523,7 @@

- + diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 0000000..5393ab2 --- /dev/null +++ b/public/theme-init.js @@ -0,0 +1,6 @@ +(() => { + const storedTheme = localStorage.getItem('grades-theme'); + if (storedTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } +})(); diff --git a/tests/frontend/theme.test.js b/tests/frontend/theme.test.js new file mode 100644 index 0000000..84419d8 --- /dev/null +++ b/tests/frontend/theme.test.js @@ -0,0 +1,158 @@ +import { beforeEach, afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { getInitialTheme, applyTheme, setupThemeToggle } from '../../frontend/theme.js'; + +let dom; +let cleanupGlobals = []; + +function installDom(html = '') { + dom = new JSDOM(html, { url: 'https://example.test' }); + const map = { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + CustomEvent: dom.window.CustomEvent, + getComputedStyle: dom.window.getComputedStyle + }; + + cleanupGlobals = Object.entries(map).map(([key, value]) => { + const previous = globalThis[key]; + globalThis[key] = value; + return [key, previous]; + }); +} + +function uninstallDom() { + for (const [key, previous] of cleanupGlobals) { + if (previous === undefined) { + delete globalThis[key]; + } else { + globalThis[key] = previous; + } + } + cleanupGlobals = []; + dom?.window?.close(); +} + +describe('theme.js', () => { + beforeEach(() => { + installDom(` + + `); + }); + + afterEach(() => { + uninstallDom(); + }); + + it('getInitialTheme should handle stored light/dark/invalid/missing values', async () => { + localStorage.setItem('grades-theme', 'light'); + assert.equal(getInitialTheme(), 'light'); + + localStorage.setItem('grades-theme', 'dark'); + assert.equal(getInitialTheme(), 'dark'); + + localStorage.setItem('grades-theme', 'unexpected'); + assert.equal(getInitialTheme(), 'dark'); + + localStorage.removeItem('grades-theme'); + assert.equal(getInitialTheme(), 'dark'); + }); + + it('applyTheme should update data-theme, icon states, aria-label and title', async () => { + const root = document.documentElement; + const btn = document.getElementById('themeToggleBtn'); + const moon = btn.querySelector('.theme-icon-moon'); + const sun = btn.querySelector('.theme-icon-sun'); + + applyTheme('light'); + assert.equal(root.getAttribute('data-theme'), 'light'); + assert.equal(sun.classList.contains('hidden'), false); + assert.equal(moon.classList.contains('hidden'), true); + assert.equal(btn.getAttribute('aria-label'), '切換至深色模式'); + assert.equal(btn.getAttribute('title'), '切換至深色模式'); + + applyTheme('dark'); + assert.equal(root.hasAttribute('data-theme'), false); + assert.equal(sun.classList.contains('hidden'), true); + assert.equal(moon.classList.contains('hidden'), false); + assert.equal(btn.getAttribute('aria-label'), '切換至淺色模式'); + assert.equal(btn.getAttribute('title'), '切換至淺色模式'); + }); + + it('setupThemeToggle should toggle theme, persist localStorage and dispatch themechange', async () => { + localStorage.setItem('grades-theme', 'dark'); + + const events = []; + document.addEventListener('themechange', (event) => events.push(event.detail.theme)); + + setupThemeToggle(); + const btn = document.getElementById('themeToggleBtn'); + + btn.click(); + assert.equal(document.documentElement.getAttribute('data-theme'), 'light'); + assert.equal(localStorage.getItem('grades-theme'), 'light'); + + btn.click(); + assert.equal(document.documentElement.hasAttribute('data-theme'), false); + assert.equal(localStorage.getItem('grades-theme'), 'dark'); + + assert.deepEqual(events, ['dark', 'light', 'dark']); + }); + + it('chart integration should re-apply chart palette on themechange', async () => { + document.documentElement.style.setProperty('--color-text-main', 'rgb(1, 2, 3)'); + document.documentElement.style.setProperty('--color-text-secondary', 'rgb(10, 20, 30)'); + document.documentElement.style.setProperty('--color-text-muted', 'rgb(40, 50, 60)'); + document.documentElement.style.setProperty('--color-surface-elevated', 'rgb(70, 80, 90)'); + document.documentElement.style.setProperty('--color-border-subtle', 'rgba(1,1,1,0.3)'); + document.documentElement.style.setProperty('--color-border-extra-subtle', 'rgba(2,2,2,0.2)'); + + const charts = await import('../../frontend/charts.js'); + + const radar = { + options: { + scales: { r: { ticks: {}, grid: {}, angleLines: {}, pointLabels: {} } }, + plugins: { legend: { labels: {} } } + }, + data: { datasets: [{}, {}] }, + updateCalls: 0, + update() { this.updateCalls += 1; } + }; + + const bar = { + options: { + scales: { x: { ticks: {}, grid: {} }, y: { ticks: {}, grid: {} } }, + plugins: { legend: { labels: {} } } + }, + data: { datasets: [] }, + updateCalls: 0, + update() { this.updateCalls += 1; } + }; + + charts.__setChartInstancesForTest({ radar, bar }); + applyTheme('light'); + + assert.equal(radar.options.scales.r.grid.color, 'rgba(1,1,1,0.3)'); + assert.equal(bar.options.scales.x.grid.color, 'rgba(2,2,2,0.2)'); + assert.equal(radar.data.datasets[0].pointBorderColor, 'rgb(70, 80, 90)'); + assert.equal(radar.updateCalls, 1); + assert.equal(bar.updateCalls, 1); + + document.documentElement.style.setProperty('--color-border-subtle', 'rgba(9,9,9,0.9)'); + document.documentElement.style.setProperty('--color-border-extra-subtle', 'rgba(8,8,8,0.8)'); + document.documentElement.style.setProperty('--color-surface-elevated', 'rgb(7, 8, 9)'); + + applyTheme('dark'); + + assert.equal(radar.options.scales.r.grid.color, 'rgba(9,9,9,0.9)'); + assert.equal(bar.options.scales.x.grid.color, 'rgba(8,8,8,0.8)'); + assert.equal(radar.data.datasets[0].pointBorderColor, 'rgb(7, 8, 9)'); + assert.equal(radar.updateCalls, 2); + assert.equal(bar.updateCalls, 2); + }); +}); From d36cb9b6b74f5c6c98db31a5ffbe3a5a8a189488 Mon Sep 17 00:00:00 2001 From: BST <107313913+alvin000009238@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:41:59 +0800 Subject: [PATCH 11/11] Harden early theme init against storage access failures --- frontend/charts.js | 32 ++++--- frontend/styles/tokens.css | 2 + frontend/theme.js | 4 +- public/index.html | 13 +-- public/theme-init.js | 11 +++ tests/frontend/theme.test.js | 158 +++++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 public/theme-init.js create mode 100644 tests/frontend/theme.test.js diff --git a/frontend/charts.js b/frontend/charts.js index 5fc5021..9262639 100644 --- a/frontend/charts.js +++ b/frontend/charts.js @@ -12,9 +12,11 @@ let radarChartInstance = null; let barChartInstance = null; let chartJsLoadPromise = null; let latestRenderToken = 0; +let cachedChartPalette = null; if (typeof document !== 'undefined') { document.addEventListener('themechange', () => { + cachedChartPalette = null; applyThemeToExistingCharts(); }); } @@ -30,8 +32,10 @@ export function generateCharts(subjects) { ensureChartJsLoaded() .then((ChartCtor) => { if (renderToken !== latestRenderToken) return; - updateRadarChart(ChartCtor, labels, myScores, avgScores); - updateBarChart(ChartCtor, labels, myScores, avgScores); + + const palette = getCachedChartThemePalette(); + updateRadarChart(ChartCtor, labels, myScores, avgScores, palette); + updateBarChart(ChartCtor, labels, myScores, avgScores, palette); }) .catch((error) => { console.warn('Failed to load Chart.js', error); @@ -104,13 +108,11 @@ function ensureChartJsLoaded() { return chartJsLoadPromise; } -function updateRadarChart(ChartCtor, labels, myScores, avgScores) { +function updateRadarChart(ChartCtor, labels, myScores, avgScores, palette) { const radarCanvas = document.getElementById('radarChart'); const radarCtx = radarCanvas?.getContext('2d'); if (!radarCtx) return; - const palette = getChartThemePalette(); - if (!radarChartInstance) { radarChartInstance = new ChartCtor(radarCtx, { type: 'radar', @@ -190,13 +192,11 @@ function updateRadarChart(ChartCtor, labels, myScores, avgScores) { radarChartInstance.update('none'); } -function updateBarChart(ChartCtor, labels, myScores, avgScores) { +function updateBarChart(ChartCtor, labels, myScores, avgScores, palette) { const barCanvas = document.getElementById('barChart'); const barCtx = barCanvas?.getContext('2d'); if (!barCtx) return; - const palette = getChartThemePalette(); - if (!barChartInstance) { barChartInstance = new ChartCtor(barCtx, { type: 'bar', @@ -280,12 +280,19 @@ function getChartThemePalette() { textMuted: readVar('--color-text-muted', '#64748b'), surface: readVar('--color-surface-elevated', '#ffffff'), grid: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.22)'), - gridSubtle: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.16)') + gridSubtle: readVar('--color-border-extra-subtle', 'rgba(148, 163, 184, 0.16)') }; } +function getCachedChartThemePalette() { + if (!cachedChartPalette) { + cachedChartPalette = getChartThemePalette(); + } + return cachedChartPalette; +} + function applyThemeToExistingCharts() { - const palette = getChartThemePalette(); + const palette = getCachedChartThemePalette(); if (radarChartInstance) { const radarOptions = radarChartInstance.options; @@ -330,6 +337,11 @@ function applyThemeToExistingCharts() { } } +export function __setChartInstancesForTest(instances = {}) { + if (Object.hasOwn(instances, 'radar')) radarChartInstance = instances.radar; + if (Object.hasOwn(instances, 'bar')) barChartInstance = instances.bar; +} + function clearCanvas(canvasId) { const canvas = document.getElementById(canvasId); const context = canvas?.getContext?.('2d'); diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css index 5157287..5a416a4 100644 --- a/frontend/styles/tokens.css +++ b/frontend/styles/tokens.css @@ -54,6 +54,7 @@ /* Separator */ --color-border: var(--md-sys-color-outline-variant); --color-border-subtle: rgba(142, 144, 153, 0.2); + --color-border-extra-subtle: rgba(142, 144, 153, 0.14); /* Shadows */ --shadow-soft: 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); @@ -122,6 +123,7 @@ --color-surface-elevated: #ffffff; --color-surface-hover: #eef1fa; --color-border-subtle: rgba(116, 119, 127, 0.25); + --color-border-extra-subtle: rgba(116, 119, 127, 0.16); --shadow-soft: 0 1px 2px 0 rgba(24, 27, 33, 0.09), 0 1px 4px 1px rgba(24, 27, 33, 0.08); --shadow-hover: 0 1px 3px 0 rgba(24, 27, 33, 0.12), 0 4px 12px 2px rgba(24, 27, 33, 0.12); diff --git a/frontend/theme.js b/frontend/theme.js index 9cc3f48..06fcf14 100644 --- a/frontend/theme.js +++ b/frontend/theme.js @@ -1,12 +1,12 @@ const STORAGE_KEY = 'grades-theme'; -function getInitialTheme() { +export function getInitialTheme() { const storedTheme = localStorage.getItem(STORAGE_KEY); if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme; return 'dark'; } -function applyTheme(theme) { +export function applyTheme(theme) { const root = document.documentElement; if (theme === 'light') { root.setAttribute('data-theme', 'light'); diff --git a/public/index.html b/public/index.html index 458c451..26933f0 100644 --- a/public/index.html +++ b/public/index.html @@ -39,15 +39,8 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap"> - - + + @@ -530,7 +523,7 @@

- + diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 0000000..66a7c16 --- /dev/null +++ b/public/theme-init.js @@ -0,0 +1,11 @@ +(() => { + try { + const storedTheme = localStorage.getItem('grades-theme'); + if (storedTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } + } catch { + // Ignore storage access failures (privacy mode / blocked storage) + // and keep default theme. + } +})(); diff --git a/tests/frontend/theme.test.js b/tests/frontend/theme.test.js new file mode 100644 index 0000000..84419d8 --- /dev/null +++ b/tests/frontend/theme.test.js @@ -0,0 +1,158 @@ +import { beforeEach, afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { getInitialTheme, applyTheme, setupThemeToggle } from '../../frontend/theme.js'; + +let dom; +let cleanupGlobals = []; + +function installDom(html = '') { + dom = new JSDOM(html, { url: 'https://example.test' }); + const map = { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + CustomEvent: dom.window.CustomEvent, + getComputedStyle: dom.window.getComputedStyle + }; + + cleanupGlobals = Object.entries(map).map(([key, value]) => { + const previous = globalThis[key]; + globalThis[key] = value; + return [key, previous]; + }); +} + +function uninstallDom() { + for (const [key, previous] of cleanupGlobals) { + if (previous === undefined) { + delete globalThis[key]; + } else { + globalThis[key] = previous; + } + } + cleanupGlobals = []; + dom?.window?.close(); +} + +describe('theme.js', () => { + beforeEach(() => { + installDom(` + + `); + }); + + afterEach(() => { + uninstallDom(); + }); + + it('getInitialTheme should handle stored light/dark/invalid/missing values', async () => { + localStorage.setItem('grades-theme', 'light'); + assert.equal(getInitialTheme(), 'light'); + + localStorage.setItem('grades-theme', 'dark'); + assert.equal(getInitialTheme(), 'dark'); + + localStorage.setItem('grades-theme', 'unexpected'); + assert.equal(getInitialTheme(), 'dark'); + + localStorage.removeItem('grades-theme'); + assert.equal(getInitialTheme(), 'dark'); + }); + + it('applyTheme should update data-theme, icon states, aria-label and title', async () => { + const root = document.documentElement; + const btn = document.getElementById('themeToggleBtn'); + const moon = btn.querySelector('.theme-icon-moon'); + const sun = btn.querySelector('.theme-icon-sun'); + + applyTheme('light'); + assert.equal(root.getAttribute('data-theme'), 'light'); + assert.equal(sun.classList.contains('hidden'), false); + assert.equal(moon.classList.contains('hidden'), true); + assert.equal(btn.getAttribute('aria-label'), '切換至深色模式'); + assert.equal(btn.getAttribute('title'), '切換至深色模式'); + + applyTheme('dark'); + assert.equal(root.hasAttribute('data-theme'), false); + assert.equal(sun.classList.contains('hidden'), true); + assert.equal(moon.classList.contains('hidden'), false); + assert.equal(btn.getAttribute('aria-label'), '切換至淺色模式'); + assert.equal(btn.getAttribute('title'), '切換至淺色模式'); + }); + + it('setupThemeToggle should toggle theme, persist localStorage and dispatch themechange', async () => { + localStorage.setItem('grades-theme', 'dark'); + + const events = []; + document.addEventListener('themechange', (event) => events.push(event.detail.theme)); + + setupThemeToggle(); + const btn = document.getElementById('themeToggleBtn'); + + btn.click(); + assert.equal(document.documentElement.getAttribute('data-theme'), 'light'); + assert.equal(localStorage.getItem('grades-theme'), 'light'); + + btn.click(); + assert.equal(document.documentElement.hasAttribute('data-theme'), false); + assert.equal(localStorage.getItem('grades-theme'), 'dark'); + + assert.deepEqual(events, ['dark', 'light', 'dark']); + }); + + it('chart integration should re-apply chart palette on themechange', async () => { + document.documentElement.style.setProperty('--color-text-main', 'rgb(1, 2, 3)'); + document.documentElement.style.setProperty('--color-text-secondary', 'rgb(10, 20, 30)'); + document.documentElement.style.setProperty('--color-text-muted', 'rgb(40, 50, 60)'); + document.documentElement.style.setProperty('--color-surface-elevated', 'rgb(70, 80, 90)'); + document.documentElement.style.setProperty('--color-border-subtle', 'rgba(1,1,1,0.3)'); + document.documentElement.style.setProperty('--color-border-extra-subtle', 'rgba(2,2,2,0.2)'); + + const charts = await import('../../frontend/charts.js'); + + const radar = { + options: { + scales: { r: { ticks: {}, grid: {}, angleLines: {}, pointLabels: {} } }, + plugins: { legend: { labels: {} } } + }, + data: { datasets: [{}, {}] }, + updateCalls: 0, + update() { this.updateCalls += 1; } + }; + + const bar = { + options: { + scales: { x: { ticks: {}, grid: {} }, y: { ticks: {}, grid: {} } }, + plugins: { legend: { labels: {} } } + }, + data: { datasets: [] }, + updateCalls: 0, + update() { this.updateCalls += 1; } + }; + + charts.__setChartInstancesForTest({ radar, bar }); + applyTheme('light'); + + assert.equal(radar.options.scales.r.grid.color, 'rgba(1,1,1,0.3)'); + assert.equal(bar.options.scales.x.grid.color, 'rgba(2,2,2,0.2)'); + assert.equal(radar.data.datasets[0].pointBorderColor, 'rgb(70, 80, 90)'); + assert.equal(radar.updateCalls, 1); + assert.equal(bar.updateCalls, 1); + + document.documentElement.style.setProperty('--color-border-subtle', 'rgba(9,9,9,0.9)'); + document.documentElement.style.setProperty('--color-border-extra-subtle', 'rgba(8,8,8,0.8)'); + document.documentElement.style.setProperty('--color-surface-elevated', 'rgb(7, 8, 9)'); + + applyTheme('dark'); + + assert.equal(radar.options.scales.r.grid.color, 'rgba(9,9,9,0.9)'); + assert.equal(bar.options.scales.x.grid.color, 'rgba(8,8,8,0.8)'); + assert.equal(radar.data.datasets[0].pointBorderColor, 'rgb(7, 8, 9)'); + assert.equal(radar.updateCalls, 2); + assert.equal(bar.updateCalls, 2); + }); +});