From 6defd3e8ce762b79d2bc634f54d23c010913625f Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Tue, 10 Mar 2026 08:51:13 -0700 Subject: [PATCH] ui: Use inline SVG images for app icons for dark mode adaptation - Use 'currentColor' as color in SVG images to make them adapt to light/dark modes. - Introduce a new {% icon %} template tag to read SVG files, attach attributes, remove header, and make all IDs inside unique. - Use the {% icon %} template tag to inline SVGs in app page, home page, app pages, and notifications. - Relax the content security policy to allow inline styling with 'style=' attribute. Tests: - Allow the icons looks as before in the following pages: home page, apps page, app specific page, and in notifications. - Icons that are fully dark become white when theme is switched to dark mode. - All the inlined SVG icons have a prefixed unique ID. - W3C HTML validator shows no new errors. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/middleware.py | 2 +- plinth/templates/app-header.html | 5 ++- plinth/templates/card.html | 5 ++- plinth/templates/index.html | 6 +-- plinth/templates/notifications.html | 9 ++--- plinth/templatetags/plinth_extras.py | 35 +++++++++++++++++ plinth/tests/test_templatetags.py | 58 ++++++++++++++++++++++++++++ plinth/tests/test_web_server.py | 42 +++++++++++++++++++- plinth/web_server.py | 26 +++++++++++++ static/themes/default/css/main.css | 19 +++++++-- 10 files changed, 187 insertions(+), 20 deletions(-) diff --git a/plinth/middleware.py b/plinth/middleware.py index aeee61853..b3b57140c 100644 --- a/plinth/middleware.py +++ b/plinth/middleware.py @@ -230,7 +230,7 @@ CONTENT_SECURITY_POLICY = CSPDict({ # scripts). 'script-src': "'self'", # Allow inline CSS and CSS files from Freedombox itself. - 'style-src': "'self'", + 'style-src': "'self' 'unsafe-inline'", # Web worker sources are allowed only from FreedomBox itself (for # JSXC). 'worker-src': "'self'", diff --git a/plinth/templates/app-header.html b/plinth/templates/app-header.html index e10945a86..3fb3a7001 100644 --- a/plinth/templates/app-header.html +++ b/plinth/templates/app-header.html @@ -5,15 +5,16 @@ {% load bootstrap %} {% load i18n %} {% load static %} +{% load plinth_extras %}
{% if app_info.icon_filename %} {% if app_info.app_id %} - {{ app_info.name  }} + {% icon app_info.app_id|add:'/icons/'|add:app_info.icon_filename|add:'.svg' %} {% else %} - {{ app_info.name  }} + {% icon app_info.icon_filename %} {% endif %} {% endif %} diff --git a/plinth/templates/card.html b/plinth/templates/card.html index 13e912374..22c157418 100644 --- a/plinth/templates/card.html +++ b/plinth/templates/card.html @@ -4,6 +4,7 @@ {% load i18n %} {% load static %} +{% load plinth_extras %} diff --git a/plinth/templates/index.html b/plinth/templates/index.html index be48ead79..4586a9f7c 100644 --- a/plinth/templates/index.html +++ b/plinth/templates/index.html @@ -5,7 +5,7 @@ {% load i18n %} {% load static %} - +{% load plinth_extras %} {% block body_class %}index-page{% endblock %} @@ -66,9 +66,9 @@ {% else %} {% if shortcut.app_id %} - + {% icon shortcut.app_id|add:'/icons/'|add:shortcut.icon|add:'.svg' class='card-icon' %} {% else %} - + {% icon shortcut.icon class='card-icon' %} {% endif %} {% endif %} diff --git a/plinth/templates/notifications.html b/plinth/templates/notifications.html index 1880cbb61..2b91d00e8 100644 --- a/plinth/templates/notifications.html +++ b/plinth/templates/notifications.html @@ -6,6 +6,7 @@ {% load i18n %} {% load static %} +{% load plinth_extras %} {% if notifications %}
@@ -27,13 +28,9 @@
{% elif note.data.app_icon_filename %} {% if note.app_id %} - {{ note.data.app_name }} + {% icon note.app_id|add:'/icons/'|add:note.data.app_icon_filename|add:'.svg' alt=note.data.app_name class='notification-icon' %} {% else %} - {{ note.data.app_name }} + {% icon note.data.app_icon_filename alt=note.data.app_name class='notification-icon' %} {% endif %} {% endif %} {{ note.data.app_name }} diff --git a/plinth/templatetags/plinth_extras.py b/plinth/templatetags/plinth_extras.py index ce471f6a0..ea679a8fd 100644 --- a/plinth/templatetags/plinth_extras.py +++ b/plinth/templatetags/plinth_extras.py @@ -4,8 +4,10 @@ import os from urllib.parse import urlparse from django import template +from django.utils.safestring import mark_safe from plinth import clients as clients_module +from plinth import utils, web_server register = template.Library() @@ -88,3 +90,36 @@ def clients_get_platforms(clients): other.append(platform) return {'web': web, 'other': other} + + +@register.simple_tag(name='icon') +def icon(url: str, *args, **kwargs): + """Insert an SVG icon inline.""" + icon_name = url.rpartition('/')[2].partition('.')[0] + + def add_attributes(text: str) -> str: + """Append attributes to the elment.""" + if 'class' not in kwargs: + kwargs['class'] = 'svg-icon' + + kwargs['data-icon-name'] = icon_name + attributes = ' '.join( + (f'{key}="{value}"' for key, value in kwargs.items())) + text = text.replace(' header. + icon_text = add_attributes('\n'.join(icon_lines[1:])) + + return mark_safe(icon_text) diff --git a/plinth/tests/test_templatetags.py b/plinth/tests/test_templatetags.py index c3b51ec65..6b603ca2c 100644 --- a/plinth/tests/test_templatetags.py +++ b/plinth/tests/test_templatetags.py @@ -3,6 +3,8 @@ Test module for custom Django template tags. """ +from unittest.mock import patch + from plinth.templatetags import plinth_extras @@ -45,3 +47,59 @@ def test_highlighting(): menu = plinth_extras.mark_active_menuitem(menu, check_path) _assert_active_url(menu, expected_active_path) assert _verify_active_menuitems(menu) + + +@patch('plinth.web_server.resolve_static_path') +def test_icon(resolve_static_path, tmp_path): + """Test that the icon tag works for basic usage.""" + icon1 = tmp_path / 'icon1.svg' + icon1.write_text('') + resolve_static_path.return_value = icon1 + return_value = plinth_extras.icon('icon1') + assert return_value == ('') + + +@patch('plinth.web_server.resolve_static_path') +def test_icon_attributes(resolve_static_path, tmp_path): + """Test that the icon tag works with attributes.""" + icon1 = tmp_path / 'icon1.svg' + icon1.write_text('') + resolve_static_path.return_value = icon1 + + attributes = {'class': 'test-class'} + return_value = plinth_extras.icon('icon1', **attributes) + assert return_value == ('') + + attributes = {'data-test': 'test-value'} + return_value = plinth_extras.icon('icon1', **attributes) + assert return_value == ('') + + +@patch('plinth.utils.random_string') +@patch('plinth.web_server.resolve_static_path') +def test_icon_auto_ids(resolve_static_path, random_string, tmp_path): + """Test that the icon tag works for implementing automatic IDs.""" + random_string.return_value = 'randomvalue' + icon2 = tmp_path / 'icon2.svg' + icon2.write_text('' + '') + resolve_static_path.return_value = icon2 + return_value = plinth_extras.icon('icon2') + assert return_value == ('' + '' + '') + + +@patch('plinth.web_server.resolve_static_path') +def test_icon_xml_stripping(resolve_static_path, tmp_path): + """Test that the icon tag strips the XML header.""" + icon2 = tmp_path / 'icon2.svg' + icon2.write_text('\n' + '') + resolve_static_path.return_value = icon2 + return_value = plinth_extras.icon('icon2') + assert return_value == ('' + '') diff --git a/plinth/tests/test_web_server.py b/plinth/tests/test_web_server.py index 7cb7fcbde..50ecb5d60 100644 --- a/plinth/tests/test_web_server.py +++ b/plinth/tests/test_web_server.py @@ -3,11 +3,12 @@ Tests for CherryPy web server setup and its components. """ -from unittest.mock import call, patch +import pathlib +from unittest.mock import Mock, call, patch import pytest -from plinth.web_server import StaticFiles +from plinth.web_server import StaticFiles, resolve_static_path @pytest.fixture(autouse=True) @@ -61,3 +62,40 @@ def test_static_files_mount(mount, load_cfg): }) ] mount.assert_has_calls(calls) + + +@patch('sys.modules') +@patch('plinth.app.App.list') +def test_resolve_static_path(app_list, sys_modules, tmp_path): + """Test that resolving a static path works as expected.""" + app_list.return_value = [] + expected_path = (pathlib.Path(__file__).parent.parent.parent / + 'static/theme/icons/test.svg') + assert resolve_static_path('theme/icons/test.svg') == expected_path + + app = Mock() + app.app_id = 'test-app' + app.__module__ = 'test-module' + app_list.return_value = [app] + expected_path = (pathlib.Path(__file__).parent.parent.parent / + 'static/theme/icons/test.svg') + + sys_modules.__getitem__.side_effect = {}.__getitem__ + assert resolve_static_path('theme/icons/test.svg') == expected_path + with pytest.raises(ValueError, match='Module for app not loaded'): + resolve_static_path('test-app/test.svg') + + module = Mock() + sys_modules.__getitem__.side_effect = {'test-module': module}.__getitem__ + with pytest.raises(ValueError, + match='Module file for app could not be found'): + resolve_static_path('test-app/test.svg') + + module.__file__ = tmp_path / 'test-module.py' + with pytest.raises(ValueError, + match='No static directory available for app test-app'): + resolve_static_path('test-app/test.svg') + + (tmp_path / 'static').mkdir() + assert resolve_static_path( + 'test-app/test.svg') == tmp_path / 'static/test.svg' diff --git a/plinth/web_server.py b/plinth/web_server.py index 70ebe617a..b8edc5982 100644 --- a/plinth/web_server.py +++ b/plinth/web_server.py @@ -185,3 +185,29 @@ class StaticFiles(app_module.FollowerComponent): for web_path, file_path in self.directory_map.items(): web_path = '%s%s' % (cfg.server_dir, web_path) _mount_static_directory(file_path, web_path) + + +def resolve_static_path(url: str) -> pathlib.Path: + """Convert a URL for static file into a file path.""" + url_parts = url.split('/') + for app in app_module.App.list(): + if url_parts[0] != app.app_id: + continue + + try: + module = sys.modules[app.__module__] + except KeyError: + raise ValueError('Module for app not loaded') + + if not hasattr(module, '__file__') or not module.__file__: + raise ValueError('Module file for app could not be found') + + module_path = pathlib.Path(module.__file__).parent + static_dir = module_path / 'static' + if not static_dir.is_dir(): + raise ValueError( + f'No static directory available for app {app.app_id}') + + return static_dir / '/'.join(url_parts[1:]) + + return pathlib.Path(__file__).parent.parent / 'static' / url diff --git a/static/themes/default/css/main.css b/static/themes/default/css/main.css index d5808f69b..919e3cc4d 100644 --- a/static/themes/default/css/main.css +++ b/static/themes/default/css/main.css @@ -354,6 +354,16 @@ footer { padding-top: 20rem; } +/* + * Inline SVG Icons + */ +.svg-icon { + width: auto; + height: 0.875em; + vertical-align: sub; + margin: 0.125em 0.25em; +} + /* * Bootstrap extensions */ @@ -587,7 +597,7 @@ footer { } .card-icon span, -.card-icon img { +.card-icon svg { width: 6.25rem; height: 6.25rem; font-size: 5rem; @@ -816,9 +826,10 @@ input[type='submit'].running-status-button { margin-bottom: 1.25rem; } -.app-header > img { +.app-header > svg { margin: 0 auto; width: 100%; + height: auto; } section.app-description { @@ -867,7 +878,7 @@ section.app-description { flex-flow: column; } - .app-header img { + .app-header svg { width: 9.375rem; height: 9.375rem; margin-top: 0; @@ -974,7 +985,7 @@ section.app-description { font-weight: bold; } -img.notification-icon { +.notification-icon { display: inline-block; width: 0.875rem; height: 0.875rem;