Add dark/light theme toggle with system preference detection#704
Add dark/light theme toggle with system preference detection#704
Conversation
- Create js/theme-toggle.js with localStorage persistence, prefers-color-scheme detection, keyboard accessibility, and window.riksdagsToggleTheme / riksdagsGetTheme exports - Add [data-theme='dark'] and [data-theme='light'] CSS blocks to styles.css, covering all CSS custom properties plus element-level overrides (scan lines, Orbitron font, glows). Includes cyberpunk design tokens (--dark-bg, --mid-bg, --light-text, --primary-cyan) matching spec requirements. WCAG 2.1 AA contrast in both themes. - Add .theme-toggle-btn CSS with 44px touch target, focus ring, and RTL positioning for Arabic/Hebrew variants. - Update dashboard/styles.css with [data-theme] overrides for chart sections, metric cards, and chart containers. - Update all 14 HTML language files (index.html, index_sv.html, index_da.html, index_no.html, index_fi.html, index_de.html, index_fr.html, index_es.html, index_nl.html, index_ar.html, index_he.html, index_ja.html, index_ko.html, index_zh.html): * Anti-flash inline script in <head> to prevent flash-of-wrong-theme * Theme toggle button in <header> with translated aria-labels * <script src='/js/theme-toggle.js'> before module entry point - Rewrite src/browser/shared/theme.ts with dual-theme support: * DARK_THEME_COLORS / LIGHT_THEME_COLORS constants * getActiveThemeColors() runtime palette resolver * CHART_PALETTE_LIGHT and getChartPalette() for Chart.js * onThemeChange(callback) MutationObserver subscription * THEME_COLORS kept as deprecated alias (v2.0, removal in v3.0) * getPartyColor() uses theme-aware fallback cyan Co-authored-by: Copilot <copilot@github.com>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
There was a problem hiding this comment.
Pull request overview
Adds a dark/light theme system to the static site with an accessible toggle, OS preference detection, and the start of a runtime theme API intended for Chart.js/dashboard consumers.
Changes:
- Introduces
html[data-theme="dark"|"light"]CSS variable overrides plus theme-toggle button styling. - Adds a new
js/theme-toggle.jsscript (localStorage +prefers-color-scheme) and injects anti-flash + toggle markup into all 14 language index pages. - Extends
src/browser/shared/theme.tswith dark/light palettes and runtime helpers (getActiveThemeColors,getChartPalette,onThemeChange).
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| styles.css | Adds [data-theme] variable overrides and .theme-toggle-btn styling to support dark/light themes. |
| src/browser/shared/theme.ts | Adds dual theme palettes and runtime helpers intended for theme-aware Chart.js configuration. |
| js/theme-toggle.js | Implements theme resolution/toggling with persistence and system preference following. |
| dashboard/styles.css | Adds theme-specific dashboard container styling for better dark/light presentation. |
| index.html | Adds anti-flash theme init, header toggle button, and loads js/theme-toggle.js. |
| index_sv.html | Adds anti-flash theme init, header toggle button (SV), and loads js/theme-toggle.js. |
| index_da.html | Adds anti-flash theme init, header toggle button (DA), and loads js/theme-toggle.js. |
| index_no.html | Adds anti-flash theme init, header toggle button (NO), and loads js/theme-toggle.js. |
| index_fi.html | Adds anti-flash theme init, header toggle button (FI), and loads js/theme-toggle.js. |
| index_de.html | Adds anti-flash theme init, header toggle button (DE), and loads js/theme-toggle.js. |
| index_fr.html | Adds anti-flash theme init, header toggle button (FR), and loads js/theme-toggle.js. |
| index_es.html | Adds anti-flash theme init, header toggle button (ES), and loads js/theme-toggle.js. |
| index_nl.html | Adds anti-flash theme init, header toggle button (NL), and loads js/theme-toggle.js. |
| index_ar.html | Adds anti-flash theme init, header toggle button (AR), and loads js/theme-toggle.js. |
| index_he.html | Adds anti-flash theme init, header toggle button (HE), and loads js/theme-toggle.js. |
| index_ja.html | Adds anti-flash theme init, header toggle button (JA), and loads js/theme-toggle.js. |
| index_ko.html | Adds anti-flash theme init, header toggle button (KO), and loads js/theme-toggle.js. |
| index_zh.html | Adds anti-flash theme init, header toggle button (ZH), and loads js/theme-toggle.js. |
styles.css
Outdated
| /* Scan-line effect for dark theme */ | ||
| html[data-theme="dark"] body::after { | ||
| content: ""; | ||
| position: fixed; | ||
| top: 0; | ||
| left: 0; | ||
| width: 100vw; | ||
| height: 100vh; | ||
| background: repeating-linear-gradient( | ||
| transparent 0px, | ||
| rgba(0, 0, 0, 0.05) 1px, | ||
| transparent 2px | ||
| ); | ||
| pointer-events: none; | ||
| z-index: 9999; |
There was a problem hiding this comment.
html[data-theme="dark"] body::after duplicates the existing scan-line effect defined earlier under @media (prefers-color-scheme: dark) { body::after ... }. Duplicating the same styling in two places increases the risk they drift; consider centralizing the shared definition and only toggling it via [data-theme].
| /* Scan-line effect for dark theme */ | |
| html[data-theme="dark"] body::after { | |
| content: ""; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| background: repeating-linear-gradient( | |
| transparent 0px, | |
| rgba(0, 0, 0, 0.05) 1px, | |
| transparent 2px | |
| ); | |
| pointer-events: none; | |
| z-index: 9999; | |
| /* Adjust scan-line effect opacity for dark theme | |
| Base scan-line effect is defined in the prefers-color-scheme media query. */ | |
| html[data-theme="dark"] body::after { |
src/browser/shared/theme.ts
Outdated
| * Theme architecture: | ||
| * – `DARK_THEME_COLORS` → Ingress-inspired neon palette (matches html[data-theme="dark"]) | ||
| * – `LIGHT_THEME_COLORS` → Professional green palette (matches html[data-theme="light"] / :root) | ||
| * – `THEME_COLORS` → Re-exported alias for the active theme (runtime-resolved) |
There was a problem hiding this comment.
The module-level "Theme architecture" doc says THEME_COLORS is a runtime-resolved alias for the active theme, but in the implementation it is a constant alias to DARK_THEME_COLORS (and marked deprecated later). Please update the header comment to match the actual behavior to avoid misleading consumers.
| * – `THEME_COLORS` → Re-exported alias for the active theme (runtime-resolved) | |
| * – `THEME_COLORS` → Deprecated constant alias of `DARK_THEME_COLORS` (kept for backwards compatibility; not runtime-resolved) |
| /** | ||
| * Cyberpunk (dark) theme color palette — kept as the default export for | ||
| * backwards compatibility with existing Chart.js consumers. | ||
| * | ||
| * @deprecated since v2.0 — will be removed in v3.0. | ||
| * Prefer `getActiveThemeColors()` which returns the correct palette for the | ||
| * current `data-theme` value. Migration: | ||
| * ```ts | ||
| * // Before | ||
| * import { THEME_COLORS } from './theme.js'; | ||
| * chart.data.datasets[0].borderColor = THEME_COLORS.cyan; | ||
| * | ||
| * // After | ||
| * import { getActiveThemeColors } from './theme.js'; | ||
| * chart.data.datasets[0].borderColor = getActiveThemeColors().cyan; | ||
| * ``` | ||
| */ | ||
| export const THEME_COLORS: ThemeColors = DARK_THEME_COLORS; | ||
|
|
There was a problem hiding this comment.
THEME_COLORS is still exported as a constant alias to DARK_THEME_COLORS. In the current codebase, multiple dashboards import THEME_COLORS via ../shared/index.js, so switching to light theme will not update chart colors (and can lead to low contrast on light backgrounds). To make charts truly theme-aware, either migrate those consumers to call getActiveThemeColors() (and/or subscribe via onThemeChange()), or provide an exported value that resolves to the active palette at runtime.
js/theme-toggle.js
Outdated
| * already made an explicit choice. | ||
| * | ||
| * Accessibility: | ||
| * - Toggle button carries `role="button"`, `aria-pressed`, and a descriptive |
There was a problem hiding this comment.
The JSDoc claims the toggle button carries role="button", but the implementation relies on a native <button> element (which already has the correct implicit role) and does not set a role attribute. Consider removing/adjusting that statement to keep the accessibility docs accurate.
| * - Toggle button carries `role="button"`, `aria-pressed`, and a descriptive | |
| * - Toggle is a native `<button>` with `aria-pressed` and a descriptive |
js/theme-toggle.js
Outdated
|
|
||
| // Apply before first paint (called synchronously by the anti-flash snippet | ||
| // already present in <head>; this line covers when the module loads later). | ||
| applyTheme(resolveTheme(), false /* already persisted by anti-flash */); |
There was a problem hiding this comment.
This boot comment says the theme was "already persisted by anti-flash", but the inline anti-flash snippet (and this call with persist=false) only reads localStorage and sets data-theme—it doesn't write anything. Please update the comment (or adjust the logic if you intended initial resolution to be persisted) so the behavior is clear.
| applyTheme(resolveTheme(), false /* already persisted by anti-flash */); | |
| applyTheme(resolveTheme(), false /* initial resolution only; do not persist on module boot */); |
| /** | ||
| * Chart color palette for sequential data series. | ||
| * Chart color palette for sequential data series (dark theme). | ||
| */ | ||
| export const CHART_PALETTE = [ | ||
| THEME_COLORS.cyan, | ||
| THEME_COLORS.magenta, | ||
| THEME_COLORS.yellow, | ||
| THEME_COLORS.green, | ||
| THEME_COLORS.orange, | ||
| THEME_COLORS.purple, | ||
| THEME_COLORS.red, | ||
| THEME_COLORS.blue, | ||
| DARK_THEME_COLORS.cyan, | ||
| DARK_THEME_COLORS.magenta, | ||
| DARK_THEME_COLORS.yellow, | ||
| DARK_THEME_COLORS.green, |
There was a problem hiding this comment.
CHART_PALETTE remains hardwired to the dark palette. Since dashboards import CHART_PALETTE today, charts won’t automatically adapt when data-theme changes. Consider switching call sites to getChartPalette() (and updating on theme changes) or otherwise ensuring the exported palette used by charts reflects the active theme.
styles.css
Outdated
| /* Disable dark element-level overrides when user forces light */ | ||
| html[data-theme="light"] body::after, | ||
| html[data-theme="light"] body::before { | ||
| display: none !important; |
There was a problem hiding this comment.
html[data-theme="light"] body::after/body::before uses display: none !important;, but the selector already has higher specificity than the earlier @media (prefers-color-scheme: dark) { body::after/body::before ... } rules. The !important is unnecessary here and makes future overrides harder; consider dropping it.
| display: none !important; | |
| display: none; |
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Applied all 7 review comments in commit
|
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Fixed in commit |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
dashboard/index_sv.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings (as done on the main index_* pages) so screen readers and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="Byt till mörkt tema" | |
| title="Byt till mörkt tema" | |
| data-label-dark="Byt till ljust tema" | |
| data-label-light="Byt till mörkt tema"> |
dashboard/index_fr.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="Passer au thème sombre" | |
| title="Passer au thème sombre" | |
| data-label-dark="Passer au thème clair" | |
| data-label-light="Passer au thème sombre"> |
dashboard/index_nl.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="Schakel over naar donkere modus" | |
| title="Schakel over naar donkere modus" | |
| data-label-dark="Schakel over naar lichte modus" | |
| data-label-light="Schakel over naar donkere modus"> |
dashboard/index_zh.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="切换到深色主题" | |
| title="切换到深色主题" | |
| data-label-dark="切换到浅色主题" | |
| data-label-light="切换到深色主题"> |
dashboard/index_ko.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="다크 테마로 전환" | |
| title="다크 테마로 전환" | |
| data-label-dark="라이트 테마로 전환" | |
| data-label-light="다크 테마로 전환"> |
dashboard/index_de.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="Zum dunklen Design wechseln" | |
| title="Zum dunklen Design wechseln" | |
| data-label-dark="Zum hellen Design wechseln" | |
| data-label-light="Zum dunklen Design wechseln"> |
dashboard/index_es.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="Cambiar a tema oscuro" | |
| title="Cambiar a tema oscuro" | |
| data-label-dark="Cambiar a tema claro" | |
| data-label-light="Cambiar a tema oscuro"> |
dashboard/index_ja.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="ダークテーマに切り替え" | |
| title="ダークテーマに切り替え" | |
| data-label-dark="ライトテーマに切り替え" | |
| data-label-light="ダークテーマに切り替え"> |
dashboard/index_ar.html
Outdated
| aria-label="Switch to dark theme" | ||
| title="Switch to dark theme" | ||
| data-label-dark="Switch to light theme" | ||
| data-label-light="Switch to dark theme"> |
There was a problem hiding this comment.
This dashboard page is localized, but the theme toggle’s aria-label/title/data-label-* strings are still English. Please localize these strings so assistive tech and tooltips match the page language.
| aria-label="Switch to dark theme" | |
| title="Switch to dark theme" | |
| data-label-dark="Switch to light theme" | |
| data-label-light="Switch to dark theme"> | |
| aria-label="التبديل إلى الوضع الداكن" | |
| title="التبديل إلى الوضع الداكن" | |
| data-label-dark="التبديل إلى الوضع الفاتح" | |
| data-label-light="التبديل إلى الوضع الداكن"> |
src/browser/shared/theme.ts
Outdated
| * Returns the active ThemeColors based on the current `data-theme` attribute | ||
| * on `<html>`. When the attribute is absent (e.g. before the anti-flash | ||
| * snippet runs), falls back to `prefers-color-scheme` — consistent with the | ||
| * CSS `@media (prefers-color-scheme: dark)` default. Only hard-defaults to | ||
| * dark when neither attribute nor `matchMedia` is available (SSR/test). |
There was a problem hiding this comment.
The JSDoc for getActiveThemeColors() says it “hard-defaults to dark when neither attribute nor matchMedia is available”, but the implementation returns LIGHT_THEME_COLORS when document exists and window.matchMedia is unavailable. Update the comment (or adjust the fallback) so it matches the actual behavior and the CSS default you intend to mirror.
|
@copilot apply changes based on the comments in this thread |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
…SDoc, remove dead imports Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Applied all 16 review comments in commit
|
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
src/browser/shared/chart-factory.ts
Outdated
| backgroundColor: getActiveThemeColors().tooltipBg, | ||
| titleColor: getActiveThemeColors().cyan, | ||
| bodyColor: getActiveThemeColors().bodyText, | ||
| borderColor: getActiveThemeColors().cyan, | ||
| borderWidth: 1, |
There was a problem hiding this comment.
getResponsiveOptions() updates tooltip colors based on getActiveThemeColors(), but other chart UI colors remain hardcoded for the dark theme (e.g. legend label color, axis tick colors, grid colors). In light theme this will likely reduce contrast/readability and make charts look inconsistent. Consider deriving these remaining colors from the active theme (and cache the resolved ThemeColors once per call to avoid repeated lookups).
There was a problem hiding this comment.
Fixed in commit 9c7e0a2. Added tickColor and gridColor fields to the ThemeColors interface and both palettes (dark: #a0a0a0 / rgba(255,255,255,0.07), light: #555555 / rgba(0,0,0,0.08)). getResponsiveOptions() now calls getActiveThemeColors() once (cached in theme), and derives legend label, axis tick, and grid colors from the active theme — no more hardcoded dark-only values.
js/theme-toggle.js
Outdated
| var mql = window.matchMedia('(prefers-color-scheme: dark)'); | ||
| var handleSchemeChange = function (e) { | ||
| try { | ||
| if (localStorage.getItem(STORAGE_KEY)) return; // explicit choice wins |
There was a problem hiding this comment.
In the system theme-change handler, if (localStorage.getItem(STORAGE_KEY)) return; treats any stored value (including an unexpected/legacy one) as an explicit user preference, which can prevent the page from following system theme changes. Consider validating the stored value (only 'dark'/'light' count as explicit) or clearing invalid values before deciding to ignore system changes.
| if (localStorage.getItem(STORAGE_KEY)) return; // explicit choice wins | |
| var storedTheme = localStorage.getItem(STORAGE_KEY); | |
| if (storedTheme === DARK || storedTheme === LIGHT) { | |
| return; // explicit valid choice wins | |
| } | |
| // Clear invalid or legacy values so system preference can apply | |
| if (storedTheme !== null) { | |
| localStorage.removeItem(STORAGE_KEY); | |
| } |
There was a problem hiding this comment.
Fixed in commit 9c7e0a2. The handler now reads the stored value, only returns early if it equals DARK or LIGHT, and calls localStorage.removeItem for any other non-null value so invalid/legacy entries don't block system preference changes.
|
@copilot apply changes based on the comments in this thread and Quality Checks / typescript-lint (pull_request) |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
…s from active theme Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Applied both review comments and fixed the TypeScript lint CI failure in commit
ESLint is clean, TypeScript compiles without errors, all 2286 tests pass. |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
js/theme-toggle.jsstyles.cssanddashboard/styles.csssrc/browser/shared/theme.ts!important(round 1)addListenercompat,border-bottom: revert, mobile touch target (round 2)header { position: relative; }, add theme to dashboard pages, fixgetActiveThemeColors()fallback, fix tooltip colors (round 3)keydownhandler for Enter/Space from.theme-toggle-btn(round 4)aria-label/title/data-label-*in all 13 non-English dashboard pagesupdateButtontiming andgetActiveThemeColors()JSDoc, remove dead imports (round 5)no-emptyCI failure — added/* storage unavailable */to empty catch blocks intheme-toggle.jstickColor/gridColorfields toThemeColorsinterface and both theme paletteschart-factory.ts; cache singlegetActiveThemeColors()call pergetResponsiveOptions()invocationOriginal prompt
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.