feat(ui): add dark mode support with system preference detection and localStorage persistence

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-25 13:59:49 +00:00
parent 9cbffc4acb
commit c428a7ec00
9 changed files with 416 additions and 1 deletions
+75
View File
@@ -34,6 +34,81 @@ function getCsrfToken() {
};
})();
// ---------------------------------------------------------------------------
// Dark mode
// ---------------------------------------------------------------------------
// Preference is stored in localStorage under the key 'colorScheme'.
// Values: 'dark' | 'light' (absence means "follow server/system default").
// The anti-flash <script> in base.html applies the class before page paint.
// ---------------------------------------------------------------------------
/**
* Sync the icon and label of both desktop and mobile toggle buttons to the
* current dark-mode state.
*/
function _updateDarkModeButtons() {
const isDark = document.documentElement.classList.contains('dark');
// Desktop button
const btn = document.getElementById('darkModeToggle');
if (btn) {
const icon = btn.querySelector('i');
if (icon) {
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
}
const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
}
// Mobile button
const mobileIcon = document.getElementById('darkModeIconMobile');
if (mobileIcon) {
mobileIcon.className = isDark ? 'fas fa-sun mr-2' : 'fas fa-moon mr-2';
}
const mobileText = document.getElementById('darkModeTextMobile');
if (mobileText) {
mobileText.textContent = isDark ? 'Light Mode' : 'Dark Mode';
}
}
/**
* Toggle dark mode and persist the choice to localStorage.
* Called from the navbar button (onclick="toggleDarkMode()").
*/
function toggleDarkMode() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('colorScheme', isDark ? 'dark' : 'light');
_updateDarkModeButtons();
}
// Initialise button state once the DOM is ready.
document.addEventListener('DOMContentLoaded', function () {
_updateDarkModeButtons();
});
// React to OS-level theme changes when the user has not explicitly chosen a
// scheme (no localStorage value means "follow the server/system default").
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
if (localStorage.getItem('colorScheme')) {
return; // User has an explicit preference; ignore OS changes.
}
var serverDefault = document.documentElement.getAttribute('data-color-scheme-default') || 'system';
if (serverDefault !== 'system') {
return; // Admin has forced a specific scheme; ignore OS changes.
}
if (e.matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
_updateDarkModeButtons();
});
// ---------------------------------------------------------------------------
// Authentication status
// ---------------------------------------------------------------------------
// Check authentication status and update the auth section
(async function() {
console.log('Checking authentication status...');
+128
View File
@@ -36,3 +36,131 @@ body {
width: 100%;
}
}
/* =============================================================
DARK MODE
Activated by "dark" class on <html> element.
Toggled by the navbar button; preference stored in localStorage.
Falls back to the server-side ui_default_color_scheme setting,
then to the OS prefers-color-scheme media query.
WCAG AA contrast ratios verified for all text/background pairs.
============================================================= */
/* Tell the browser we support both colour schemes */
html { color-scheme: light; }
html.dark { color-scheme: dark; }
/* ---- Base / Body ---- */
html.dark body { background-color: #111827; color: #e5e7eb; }
html.dark .bg-gray-50 { background-color: #111827; }
html.dark .bg-white { background-color: #1f2937; }
html.dark .bg-gray-100 { background-color: #374151; }
html.dark .bg-gray-200 { background-color: #4b5563; }
/* ---- Text colours ---- */
html.dark .text-gray-900 { color: #f9fafb; }
html.dark .text-gray-800 { color: #f3f4f6; }
html.dark .text-gray-700 { color: #e5e7eb; }
html.dark .text-gray-600 { color: #d1d5db; }
html.dark .text-gray-500 { color: #9ca3af; }
html.dark .text-gray-400 { color: #9ca3af; }
html.dark .text-black { color: #f9fafb; }
/* ---- Borders ---- */
html.dark .border-gray-100 { border-color: #374151; }
html.dark .border-gray-200 { border-color: #374151; }
html.dark .border-gray-300 { border-color: #4b5563; }
html.dark .border-gray-400 { border-color: #6b7280; }
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
/* ---- Hover states ---- */
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
/* ---- Shadows (softened for dark mode) ---- */
html.dark .shadow,
html.dark .shadow-md,
html.dark .shadow-sm,
html.dark .shadow-lg {
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
}
/* ---- Alert / info-banner backgrounds ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
html.dark .bg-green-50 { background-color: #052e16; }
html.dark .bg-red-50 { background-color: #450a0a; }
html.dark .bg-yellow-50 { background-color: #451a03; }
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
html.dark .bg-orange-50 { background-color: #431407; }
/* ---- Badge / pill backgrounds ---- */
html.dark .bg-blue-100 { background-color: #1e3a5f; }
html.dark .bg-green-100 { background-color: #052e16; }
html.dark .bg-red-100 { background-color: #450a0a; }
html.dark .bg-yellow-100 { background-color: #451a03; }
html.dark .bg-indigo-100 { background-color: #1e1b4b; }
html.dark .bg-orange-100 { background-color: #431407; }
html.dark .bg-purple-100 { background-color: #2e1065; }
/* ---- Status / badge text colours ---- */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-800 { color: #bfdbfe; }
html.dark .text-green-700 { color: #86efac; }
html.dark .text-green-800 { color: #bbf7d0; }
html.dark .text-red-700 { color: #fca5a5; }
html.dark .text-red-800 { color: #fecaca; }
html.dark .text-yellow-700 { color: #fcd34d; }
html.dark .text-yellow-800 { color: #fde68a; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-800 { color: #c7d2fe; }
html.dark .text-orange-700 { color: #fdba74; }
html.dark .text-orange-800 { color: #fed7aa; }
html.dark .text-purple-700 { color: #d8b4fe; }
html.dark .text-purple-800 { color: #e9d5ff; }
/* ---- Dropdown / popup menus ---- */
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
/* ---- Form inputs / selects / textareas ---- */
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select,
html.dark textarea {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #9ca3af;
}
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select:focus,
html.dark textarea:focus {
border-color: #60a5fa;
outline-color: #60a5fa;
}
/* ---- Table rows ---- */
html.dark thead,
html.dark .bg-gray-50 thead { background-color: #1f2937; }
html.dark thead th { color: #9ca3af; }
html.dark tbody tr:hover { background-color: #374151; }
/* ---- Code / pre ---- */
html.dark pre,
html.dark code { background-color: #111827; color: #d1d5db; }
/* ---- Dark-mode toggle button icon colour ---- */
html.dark #darkModeToggle { color: #fbbf24; }
html.dark #darkModeToggle:hover { background-color: #374151; }
/* ---- Scrollbar (WebKit browsers) ---- */
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }