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 @@