From 9cbffc4acb9a81ef7bfcb705d0453d39cdca43e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:47:41 +0000 Subject: [PATCH 1/2] Initial plan From c428a7ec00e3b600cd2ecbb8edfdd9112ebf5d31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:59:49 +0000 Subject: [PATCH 2/2] feat(ui): add dark mode support with system preference detection and localStorage persistence Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .env.demo | 5 ++ app/config.py | 10 +++ app/utils/settings_service.py | 14 ++++ app/views/base.py | 4 ++ docs/ConfigurationGuide.md | 26 +++++++ frontend/static/js/common.js | 75 ++++++++++++++++++++ frontend/static/styles.css | 128 ++++++++++++++++++++++++++++++++++ frontend/templates/base.html | 38 +++++++++- tests/test_dark_mode.py | 117 +++++++++++++++++++++++++++++++ 9 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 tests/test_dark_mode.py diff --git a/.env.demo b/.env.demo index a54995f2..bdd7ff03 100644 --- a/.env.demo +++ b/.env.demo @@ -6,6 +6,11 @@ EXTERNAL_HOSTNAME=docuelevate.example.com GOTENBERG_URL=http://gotenberg:3000 ALLOW_FILE_DELETE=true # Allow deletion of file records +# **UI / Appearance** +# Default colour scheme: system (follow OS), light, or dark +# Individual users can always override with the navbar dark-mode toggle. +# UI_DEFAULT_COLOR_SCHEME=system + # **Batch Processing Settings** # Control throttling behavior for the /processall endpoint to prevent overwhelming downstream APIs PROCESSALL_THROTTLE_THRESHOLD=20 # Number of files above which throttling is applied (default: 20) diff --git a/app/config.py b/app/config.py index 41398e91..7f5ccbc8 100644 --- a/app/config.py +++ b/app/config.py @@ -382,6 +382,16 @@ class Settings(BaseSettings): ), ) + # UI / Appearance + ui_default_color_scheme: str = Field( + default="system", + description=( + "Default color scheme for the web interface. " + "Options: 'system' (follow OS preference), 'light', 'dark'. " + "Individual users can override this with the in-app toggle; their choice is persisted in localStorage." + ), + ) + # Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md) # Protects against DoS attacks and API abuse rate_limiting_enabled: bool = Field( diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 61039b9b..41faacc8 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -1153,6 +1153,20 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + # UI / Appearance + "ui_default_color_scheme": { + "category": "UI", + "description": ( + "Default color scheme for the web interface. " + "Options: 'system' (follow OS preference), 'light', 'dark'. " + "Individual users can override this with the in-app toggle; their choice is persisted in localStorage." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + "options": ["system", "light", "dark"], + }, } diff --git a/app/views/base.py b/app/views/base.py index c3b338a7..8b10c514 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -34,11 +34,15 @@ def template_response_with_version(*args, **kwargs): req = args[1].get("request") if req is not None and hasattr(req.state, "csrf_token"): args[1].setdefault("csrf_token", req.state.csrf_token) + # Inject default color scheme for dark-mode initialisation + args[1].setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) elif "context" in kwargs and isinstance(kwargs["context"], dict): kwargs["context"].setdefault("version", settings.version) req = kwargs["context"].get("request") if req is not None and hasattr(req.state, "csrf_token"): kwargs["context"].setdefault("csrf_token", req.state.csrf_token) + # Inject default color scheme for dark-mode initialisation + kwargs["context"].setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system")) return original_template_response(*args, **kwargs) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 0e2e330c..90c386df 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -759,6 +759,32 @@ For detailed setup instructions, see the [Notifications Setup Guide](Notificatio | `UPTIME_KUMA_URL` | Uptime Kuma push URL for monitoring the application's health. | | `UPTIME_KUMA_PING_INTERVAL` | How often to ping Uptime Kuma in minutes (default: `5`). | +### UI / Appearance + +DocuElevate supports a **dark mode** toggle in the navbar. Users can switch between light and dark themes at any time; their choice is stored in `localStorage` and persists across page reloads in the same browser. + +Administrators can set the **site-wide default** colour scheme that is applied when a user has not yet made a personal choice: + +| **Variable** | **Description** | **Default** | +|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| `UI_DEFAULT_COLOR_SCHEME` | Default colour scheme for all users. Options: `system` (follow OS preference), `light`, `dark`. Users can always override with the navbar toggle. | `system` | + +**How it works:** + +1. On page load an inline script checks the user's `localStorage` preference first. +2. If no stored preference exists, the server-supplied `UI_DEFAULT_COLOR_SCHEME` is used. +3. When the value is `system` (the default), the OS-level `prefers-color-scheme` media query is respected. +4. Clicking the 🌙 / ☀️ toggle in the navbar saves the new preference to `localStorage` immediately. + +**WCAG AA compliance:** All dark-mode colour pairs have been chosen with a minimum 4.5:1 contrast ratio for normal text and 3:1 for large text. + +**Example:** + +```dotenv +# Force dark mode for all users by default +UI_DEFAULT_COLOR_SCHEME=dark +``` + ## Configuration Examples ### Minimal Configuration diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 07c15e09..2d9dd486 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -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 @@ -93,6 +105,18 @@ About + + +
@@ -152,6 +176,18 @@ About + + +
diff --git a/tests/test_dark_mode.py b/tests/test_dark_mode.py new file mode 100644 index 00000000..82514a20 --- /dev/null +++ b/tests/test_dark_mode.py @@ -0,0 +1,117 @@ +"""Tests for dark mode support. + +Covers: +- ui_default_color_scheme setting in config +- Setting metadata entry in settings_service +- ui_default_color_scheme injected into template context via views/base.py +- base.html renders data-color-scheme-default attribute and toggle button +""" + +import pytest + +from app.config import settings +from app.utils.settings_service import SETTING_METADATA + + +@pytest.mark.unit +class TestDarkModeConfig: + """Tests for the ui_default_color_scheme configuration field.""" + + def test_default_is_system(self): + """ui_default_color_scheme should default to 'system'.""" + assert settings.ui_default_color_scheme == "system" + + def test_valid_values_documented(self): + """Setting metadata should list valid options.""" + meta = SETTING_METADATA.get("ui_default_color_scheme", {}) + assert "options" in meta + assert set(meta["options"]) == {"system", "light", "dark"} + + def test_metadata_category(self): + """Setting should belong to the 'UI' category.""" + meta = SETTING_METADATA.get("ui_default_color_scheme", {}) + assert meta.get("category") == "UI" + + def test_metadata_not_sensitive(self): + """Color scheme preference is not a sensitive setting.""" + meta = SETTING_METADATA.get("ui_default_color_scheme", {}) + assert meta.get("sensitive") is False + + def test_metadata_no_restart_required(self): + """Changing the color scheme should not require a restart.""" + meta = SETTING_METADATA.get("ui_default_color_scheme", {}) + assert meta.get("restart_required") is False + + +@pytest.mark.unit +class TestDarkModeTemplateInjection: + """Tests for ui_default_color_scheme being injected into template context.""" + + def test_default_injected_when_missing(self): + """Wrapper should inject ui_default_color_scheme when absent from context.""" + from unittest.mock import MagicMock, patch + + from app.views.base import template_response_with_version + + captured = {} + + def fake_original(name, ctx, **kw): + captured.update(ctx) + + with patch("app.views.base.original_template_response", side_effect=fake_original): + mock_request = MagicMock() + mock_request.state = MagicMock(spec=[]) # no csrf_token attr + template_response_with_version("page.html", {"request": mock_request}) + + assert "ui_default_color_scheme" in captured + + def test_existing_value_not_overridden(self): + """Wrapper must NOT overwrite an explicitly supplied value.""" + from unittest.mock import MagicMock, patch + + from app.views.base import template_response_with_version + + captured = {} + + def fake_original(name, ctx, **kw): + captured.update(ctx) + + with patch("app.views.base.original_template_response", side_effect=fake_original): + mock_request = MagicMock() + mock_request.state = MagicMock(spec=[]) + template_response_with_version( + "page.html", + {"request": mock_request, "ui_default_color_scheme": "dark"}, + ) + + assert captured["ui_default_color_scheme"] == "dark" + + +@pytest.mark.integration +class TestDarkModeHtml: + """Integration tests verifying the rendered HTML contains dark mode elements.""" + + def test_about_page_contains_color_scheme_attribute(self, client): + """The element should carry a data-color-scheme-default attribute.""" + response = client.get("/about") + assert response.status_code == 200 + assert b"data-color-scheme-default=" in response.content + + def test_about_page_contains_dark_mode_toggle(self, client): + """The navbar should contain a dark mode toggle button.""" + response = client.get("/about") + assert response.status_code == 200 + assert b"darkModeToggle" in response.content + + def test_about_page_contains_anti_flash_script(self, client): + """The page should include the anti-flash colour scheme detection script.""" + response = client.get("/about") + assert response.status_code == 200 + assert b"colorScheme" in response.content + assert b"prefers-color-scheme" in response.content + + def test_about_page_contains_toggle_dark_mode_function(self, client): + """The page should reference toggleDarkMode via common.js.""" + response = client.get("/about") + assert response.status_code == 200 + assert b"toggleDarkMode" in response.content