摘要
今天把 Day 26 的全站樣式變數升級為「可切換主題」:支援 Light/Dark 與 色彩主題(專注藍/活力橘/治癒綠),並把使用者偏好存進 localStorage,下次開啟自動還原。也處理了 Chart.js 的文字/格線色彩同步,避免「換了一半」的違和感。
<html>
與 <body>
切換 .dark;切換 palette;必要時重畫圖表。假設已具備:
<section id="resonanceScreen">
之前(或你想放的任一主要區塊上方),不影響既有結構。<!-- [Day27-NEW] 外觀設定(模式 + 色盤) -->
<section id="appearanceSection" class="toolbar" aria-label="外觀設定">
<label for="themeModeSelect">模式:</label>
<select id="themeModeSelect" aria-label="主題模式">
<option value="system">跟隨系統</option>
<option value="light">淺色</option>
<option value="dark">深色</option>
</select>
<span class="palette-dots" role="radiogroup" aria-label="色彩主題">
<button class="palette-dot" data-palette="blue" aria-pressed="false" title="專注藍"></button>
<button class="palette-dot" data-palette="orange" aria-pressed="false" title="活力橘"></button>
<button class="palette-dot" data-palette="green" aria-pressed="false" title="治癒綠"></button>
</span>
</section>
/* ========== [Day27-NEW] 主題切換與過渡 ========== */
/* 平滑過渡:避免切換閃爍 */
html, body, #recordFormSection, #historySection, #taskScheduleSection, #resonanceScreen,
#moodTestSection, #moodDecisionSection {
transition: background-color .3s ease, color .3s ease, border-color .3s ease;
}
/* 螢幕閱讀器專用的隱藏 */
.sr-only {
position: absolute !important;
width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,1px,1px); white-space: nowrap; border: 0;
}
/* <html> 套用深色變數,避免 DOM 區塊外仍是白色 */
:root.dark {
--bg: #0b0c0f;
--card: #121319;
--text: #e9eef6;
--muted: #9aa6b2;
--border: #1d2330;
--focus: #2563eb;
}
/* 色彩主題:覆蓋品牌主色 */
body.theme-blue { --primary: #3b82f6; --primary-600: #2563eb; }
body.theme-orange{ --primary: #f59e0b; --primary-600: #d97706; }
body.theme-green { --primary: #10b981; --primary-600: #059669; }
/* 外觀設定區域 */
#appearanceSection { max-width: var(--maxw); margin: 0 auto; padding: var(--space-4); }
/* 色盤小圓點(可鍵盤操作) */
.palette-dots { display: inline-flex; gap: .5rem; margin-left: .25rem; }
.palette-dot {
width: 22px; height: 22px; border-radius: 50%;
border: 2px solid var(--border); background: var(--primary);
display: inline-block; cursor: pointer;
}
.palette-dot[data-palette="blue"] { background: #3b82f6; }
.palette-dot[data-palette="orange"] { background: #f59e0b; }
.palette-dot[data-palette="green"] { background: #10b981; }
.palette-dot[aria-pressed="true"] { outline: 2px solid var(--focus); outline-offset: 2px; }
/* 深色時的按鈕背景與邊框 hover 微調 */
body.dark button { background: #151823; border-color: #22283a; }
body.dark button:hover { background: #1a1f2d; }
/* Chart.js 的 canvas 背景維持透明,由容器承擔底色 */
canvas { background: transparent; }
// ========== [Day27-NEW] 主題切換:常數、節點 ==========
const THEME_MODE_KEY = 'theme_mode'; // 'system' | 'light' | 'dark'
const THEME_PALETTE_KEY = 'theme_palette'; // 'blue' | 'orange' | 'green'
const themeModeSelect = document.getElementById('themeModeSelect');
const paletteButtons = document.querySelectorAll('.palette-dot');
// 取得目前應用的實際 mode(考慮 system)
function resolveMode(mode) {
const m = mode || localStorage.getItem(THEME_MODE_KEY) || 'system';
if (m === 'dark') return 'dark';
if (m === 'light') return 'light';
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
? 'dark' : 'light';
}
// 套用主題到 <html> + <body>,並保存偏好
function applyTheme({ mode, palette, save = true } = {}) {
const m = mode ?? (localStorage.getItem(THEME_MODE_KEY) || 'system');
const p = palette ?? (localStorage.getItem(THEME_PALETTE_KEY) || 'blue');
// 先清除
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark', 'theme-blue', 'theme-orange', 'theme-green');
// 模式(同時套 <html> 與 <body>)
if (resolveMode(m) === 'dark') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
}
// 色彩主題
document.body.classList.add(`theme-${p}`);
// 同步 UI 狀態
if (themeModeSelect) themeModeSelect.value = m;
paletteButtons.forEach(btn => {
btn.setAttribute('aria-pressed', String(btn.dataset.palette === p));
});
// 儲存偏好
if (save) {
localStorage.setItem(THEME_MODE_KEY, m);
localStorage.setItem(THEME_PALETTE_KEY, p);
}
// 同步 Chart.js 顏色(若已載入)
try {
if (window.Chart) {
const cs = getComputedStyle(document.documentElement);
const text = cs.getPropertyValue('--text').trim() || '#eaeef3';
const border = cs.getPropertyValue('--border').trim() || '#334155';
Chart.defaults.color = text;
Chart.defaults.borderColor = border;
// 若在歷史頁,重畫一次(避免顏色不同步)
if (historySection && !historySection.hidden && typeof renderMoodChartsAll === 'function') {
renderMoodChartsAll();
}
}
} catch {}
}
// UI 綁定:模式切換
themeModeSelect?.addEventListener('change', (e) => {
applyTheme({ mode: e.target.value, save: true });
});
// UI 綁定:色盤切換(radiogroup 行為)
paletteButtons.forEach(btn => {
btn.addEventListener('click', () => {
const palette = btn.dataset.palette || 'blue';
applyTheme({ palette, save: true });
});
// 可選:鍵盤支援(左右切換)
btn.addEventListener('keydown', (e) => {
const list = Array.from(paletteButtons);
const i = list.indexOf(btn);
if (e.key === 'ArrowRight') list[(i+1)%list.length].focus();
if (e.key === 'ArrowLeft') list[(i-1+list.length)%list.length].focus();
});
});
// 監聽系統主題變更(在「跟隨系統」時即時反應)
try {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener?.('change', () => {
const mode = localStorage.getItem(THEME_MODE_KEY) || 'system';
if (mode === 'system') applyTheme({ mode: 'system', save: true });
});
} catch {}
// 初始化:若尚未設定,給預設(system + blue);並立即套用
(function initTheme() {
if (!localStorage.getItem(THEME_MODE_KEY)) localStorage.setItem(THEME_MODE_KEY, 'system');
if (!localStorage.getItem(THEME_PALETTE_KEY)) localStorage.setItem(THEME_PALETTE_KEY, 'blue');
applyTheme({ save: false }); // 第一次套用不必再存
})();