Implement light/dark theme toggle with CSS tokens and chart theming - #91
Conversation
Restructure CI workflows for Docker deployment and add styles
…heme-toggle feat: add accessible light theme and header theme toggle
…heme-toggle-harb4d Add light/dark theme with toggle, CSS token refactor and theme persistence
…heme-toggle-ggawno Add light/dark theme support with toggle and CSS design tokens
…heme-toggle-zya5rs Add light/dark theme support with toggle and Chart.js theming
…heme-toggle-p94oog Add theme system and toggle (light/dark) with CSS tokens and chart theming
…heme-toggle-efl5ta Add light/dark theme support with toggle, CSS tokens, and Chart.js theme sync
…rk-mode-icons Update light/dark theme toggle icons
There was a problem hiding this comment.
Code Review
This pull request implements a theme switching system, introducing a light mode alongside the existing dark mode. Key changes include refactoring CSS to use variables, adding a theme toggle component with local storage persistence, and ensuring chart instances update dynamically via a new theme change event. Feedback suggests optimizing performance by caching the theme palette instead of repeatedly calling getComputedStyle during chart updates. Additionally, it was noted that the grid and gridSubtle palette properties currently map to the same CSS variable, which prevents the intended visual distinction between different grid line intensities.
| const radarCtx = radarCanvas?.getContext('2d'); | ||
| if (!radarCtx) return; | ||
|
|
||
| const palette = getChartThemePalette(); |
There was a problem hiding this comment.
Calling getChartThemePalette() here (and again in updateBarChart) results in redundant getComputedStyle calls during every chart update. Since generateCharts calls both update functions, it would be more efficient to fetch the palette once in generateCharts and pass it as an argument, or cache the palette object and only refresh it when a themechange event occurs.
| grid: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.22)'), | ||
| gridSubtle: readVar('--color-border-subtle', 'rgba(148, 163, 184, 0.16)') |
There was a problem hiding this comment.
Both grid and gridSubtle are currently mapped to the same CSS variable --color-border-subtle. This makes them identical in practice, which ignores the intended difference in transparency (0.22 vs 0.16 fallback). If the bar chart is intended to have more subtle grid lines than the radar chart, you should define a separate CSS variable (e.g., --color-border-extra-subtle) or apply an opacity modifier in JavaScript.
There was a problem hiding this comment.
Pull request overview
Adds a user-facing light/dark theme toggle using CSS tokens, and updates Chart.js rendering to follow the active theme.
Changes:
- Introduce theme persistence + toggle UI and wire it into initial app boot.
- Expand CSS token system to support light theme (including overlays/focus/selection) and adjust related component styles.
- Make existing charts re-theme on a
themechangeevent and use token-derived palette values.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| public/index.html | Adds theme toggle button + an early theme init script; updates hashed asset refs and a small label punctuation tweak. |
| frontend/theme.js | New module to read/write theme from localStorage, update DOM/theme button state, and emit themechange. |
| frontend/main.js | Initializes the theme toggle on DOMContentLoaded. |
| frontend/charts.js | Reads CSS variables for chart palette and re-applies colors to existing charts on theme changes. |
| frontend/styles/tokens.css | Adds light theme token overrides and introduces overlay/focus/selection variables; sets color-scheme. |
| frontend/styles/utilities.css | Styles the new theme toggle button. |
| frontend/styles/header.css | Adds .header-actions styling and tweaks button sizing/wrapping behavior. |
| frontend/styles/responsive.css | Ensures header action layout behaves on smaller viewports. |
| frontend/styles/onboarding.css | Replaces hardcoded overlay/highlight colors with theme tokens. |
| frontend/styles/motion.css | Uses token for selection highlight color. |
| frontend/styles/modal.css | Uses tokens for modal overlay and focus ring. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <script> | ||
| (() => { | ||
| const storedTheme = localStorage.getItem('grades-theme'); | ||
| if (storedTheme === 'light') { | ||
| document.documentElement.setAttribute('data-theme', 'light'); | ||
| } | ||
| })(); | ||
| </script> |
There was a problem hiding this comment.
The new inline <script> theme init block will be blocked by the current CSP: the page sets script-src-elem 'self' ... without 'unsafe-inline' or a nonce/hash, so inline script elements are disallowed even though script-src includes 'unsafe-inline'. Consider moving this logic into an external script from 'self' (e.g., a small Vite entry) or allow just this block via a CSP nonce/hash (preferred over loosening script-src-elem).
| <script> | |
| (() => { | |
| const storedTheme = localStorage.getItem('grades-theme'); | |
| if (storedTheme === 'light') { | |
| document.documentElement.setAttribute('data-theme', 'light'); | |
| } | |
| })(); | |
| </script> |
| 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); | ||
| }); | ||
| } |
There was a problem hiding this comment.
New theme behavior (localStorage-backed theme selection, DOM attribute updates, and themechange event dispatch) isn’t covered by frontend unit tests. Since the repo already has Node-based frontend tests, please add tests to validate initial theme selection (stored/invalid), DOM updates (data-theme, aria-label/title, icon visibility), and that a themechange event is dispatched on toggle.
…nline-theme-init-script Make theme init CSP-safe, optimize chart palette usage, add extra subtle grid token and tests
…nline-theme-init-script Fix chart palette staleness and Node 20-safe theme tests
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const storedTheme = localStorage.getItem('grades-theme'); | ||
| if (storedTheme === 'light') { | ||
| document.documentElement.setAttribute('data-theme', 'light'); |
There was a problem hiding this comment.
theme-init.js 直接讀取 localStorage;在某些瀏覽器/隱私設定(封鎖儲存空間、第三方環境、Safari 特定模式)下存取 localStorage 可能丟出 SecurityError,造成此段 script 在 head 早期就報錯。建議用 try/catch 包起來,並在失敗時安靜跳過(保持預設深色),避免影響首屏與後續腳本。
| const storedTheme = localStorage.getItem('grades-theme'); | |
| if (storedTheme === 'light') { | |
| document.documentElement.setAttribute('data-theme', 'light'); | |
| try { | |
| const storedTheme = localStorage.getItem('grades-theme'); | |
| if (storedTheme === 'light') { | |
| document.documentElement.setAttribute('data-theme', 'light'); | |
| } | |
| } catch (_e) { | |
| // Access to localStorage may be blocked (e.g., privacy settings, third-party context). | |
| // In that case, silently fall back to the default theme. |
| 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(); |
There was a problem hiding this comment.
generateCharts() 內的 const palette = getChartThemePalette(); 目前沒有被使用,反而每次呼叫都會額外觸發一次 getComputedStyle()(成本不低)。建議移除這個未使用的變數,或如果原意是要在載入 Chart.js 前先快照當下主題,請改成實際使用(例如初始化 cachedChartPalette)。
| const palette = getChartThemePalette(); |
| export function __setChartInstancesForTest(instances = {}) { | ||
| if (Object.hasOwn(instances, 'radar')) radarChartInstance = instances.radar; | ||
| if (Object.hasOwn(instances, 'bar')) barChartInstance = instances.bar; | ||
| } | ||
|
|
There was a problem hiding this comment.
__setChartInstancesForTest 目前會被打包成正式輸出 API,且專案其他模組看起來沒有類似的 test-only export 慣例。建議將此測試注入點改為在測試環境才可用(例如用條件式匯出/掛到全域測試鉤子/或把 chart instance 存取抽到可注入的依賴),避免未來被誤用成公開介面而增加維護負擔。
| export function __setChartInstancesForTest(instances = {}) { | |
| if (Object.hasOwn(instances, 'radar')) radarChartInstance = instances.radar; | |
| if (Object.hasOwn(instances, 'bar')) barChartInstance = instances.bar; | |
| } | |
| function __setChartInstancesForTest(instances = {}) { | |
| if (Object.hasOwn(instances, 'radar')) radarChartInstance = instances.radar; | |
| if (Object.hasOwn(instances, 'bar')) barChartInstance = instances.bar; | |
| } | |
| if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'test') { | |
| // Expose test-only hook via a global in test environments without making it a public export. | |
| // This avoids leaking a test helper into the formal production API surface. | |
| // eslint-disable-next-line no-undef | |
| globalThis.__setChartInstancesForTest = __setChartInstancesForTest; | |
| } |
| <div class="flex-center-gap-8"> | ||
| <div class="flex-center-gap-8 header-actions"> | ||
| <button class="icon-btn theme-toggle-btn" id="themeToggleBtn" type="button" aria-label="切換至淺色模式" | ||
| title="切換主題"> |
There was a problem hiding this comment.
theme toggle 按鈕初始狀態下 aria-label 與 title 的語意不一致(aria-label 是「切換至淺色模式」,title 是「切換主題」),而 theme.js 之後會把兩者都改成「切換至X模式」。建議在 HTML 初始值就讓 title 與 aria-label 一致,避免在 JS 尚未執行前 tooltip/輔助技術讀到不同訊息。
| title="切換主題"> | |
| title="切換至淺色模式"> |
…nline-theme-init-script-i8s1np Cache chart theme palette, add theme-init, expose chart test hooks, and add theme tests
No description provided.