feat(frontend): rename inj roles to sys-front-*, add sys-svc-cdn, cache-busting lookup

Introduce sys-svc-cdn (cdn_paths/cdn_urls/cdn_dirs) and ensure CDN directories + latest symlink.

Rename sys-srv-web-inj-* → sys-front-inj-*; update includes/templates; serve shared/per-app CSS & JS via CDN.

Add lookup_plugins/local_mtime_qs.py for mtime-based cache busting; split CSS into default.css/bootstrap.css + optional per-app style.css.

CSP: use style-src-elem; drop unsafe-inline for styles. Services: fix SYS_SERVICE_ALL_ENABLED bool and controlled flush.

BREAKING CHANGE: role names changed; replace includes and references accordingly.

Conversation: https://chatgpt.com/share/68b55494-9ec4-800f-b559-44707029141d
This commit is contained in:
2025-09-01 10:10:23 +02:00
parent 3f8e7c1733
commit 231fd567b3
123 changed files with 1789 additions and 1393 deletions

View File

@@ -0,0 +1,34 @@
# 🌍 Global CSS Injection for Nginx
## Description
This Ansible role ensures **consistent global theming** across all Nginx-served applications by injecting CSS files.
The role leverages [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/) to generate a dynamic, customizable color palette for light and dark mode, compatible with popular web tools like **Bootstrap**, **Keycloak**, **Nextcloud**, **Taiga**, **Mastodon**, and many more.
## Overview
This role deploys a centralized global stylesheet that overrides the default theming of web applications served via Nginx. It's optimized to run only once per deployment and generates a **cache-busting version number** based on file modification timestamps.
It includes support for **dark mode**, **custom fonts**, and **extensive Bootstrap and UI component overrides**.
## Purpose
The goal of this role is to provide a **single source of truth for theming** across your infrastructure.
It makes all applications feel like part of the same ecosystem — visually and functionally.
## Features
- 🎨 **Dynamic Theming** via [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/)
- 📁 **Unified CSS Base Configuration** deployment for all Nginx applications
- 🌒 **Dark mode support** out of the box
- 🚫 **No duplication** tasks run once per deployment
- ⏱️ **Versioning logic** to bust browser cache
- 🎯 **Bootstrap override compatibility**
- 🧩 **Theme support for Keycloak, Nextcloud, Gitea, LAM, Peertube, and more**
## Credits 📝
Developed and maintained by **Kevin Veen-Birkenbach**.
Learn more at [www.veen.world](https://www.veen.world)
Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code)
License: [Infinito.Nexus NonCommercial License](https://s.infinito.nexus/license)

View File

@@ -0,0 +1,26 @@
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "Global CSS injection for Nginx-based apps using dynamic colorschemes."
license: "Infinito.Nexus NonCommercial License"
license_url: "https://s.infinito.nexus/license"
company: |
Kevin Veen-Birkenbach
Consulting & Coaching Solutions
https://www.veen.world
min_ansible_version: "2.9"
platforms:
- name: Archlinux
versions:
- rolling
galaxy_tags:
- nginx
- css
- colors
- bootstrap
- theming
- dynamic
- frontend
- global
repository: https://s.infinito.nexus/code
issue_tracker_url: https://s.infinito.nexus/issues
documentation: "https://docs.infinito.nexus/"

View File

@@ -0,0 +1,21 @@
- name: Include dependency 'srv-core'
include_role:
name: srv-core
when: run_once_srv_core is not defined
- name: Generate color palette with colorscheme-generator
set_fact:
color_palette: "{{ lookup('colorscheme', CSS_BASE_COLOR, count=CSS_COUNT, shades=CSS_SHADES) }}"
- name: Generate inverted color palette with colorscheme-generator
set_fact:
inverted_color_palette: "{{ lookup('colorscheme', CSS_BASE_COLOR, count=CSS_COUNT, shades=CSS_SHADES, invert_lightness=True) }}"
- name: Deploy default CSS files
template:
src: "{{ ['css', item ~ '.j2'] | path_join }}"
dest: "{{ [cdn.shared.css, item] | path_join }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'
loop: "{{ CSS_FILES }}"

View File

@@ -0,0 +1,25 @@
- block:
- include_tasks: 01_core.yml
- include_tasks: utils/run_once.yml
when: run_once_sys_front_inj_css is not defined
- name: "Resolve optional app style.css source for '{{ application_id }}'"
vars:
app_role_dir: "{{ playbook_dir }}/roles/{{ application_id }}"
_app_style_src: >-
{{ lookup('first_found', {
'files': ['templates/style.css.j2','files/style.css'],
'paths': [app_role_dir]
}, errors='ignore') | default('', true) }}
set_fact:
app_style_src: "{{ _app_style_src }}"
app_style_present: "{{ _app_style_src | length > 0 }}"
- name: "Deploy per-app '{{ app_style_src }}' to '{{ css_app_dst }}'"
when: app_style_present
copy:
content: "{{ lookup('template', app_style_src) }}"
dest: "{{ css_app_dst }}"
owner: "{{ NGINX.USER }}"
group: "{{ NGINX.USER }}"
mode: '0644'

View File

@@ -0,0 +1,69 @@
/* Buttons (Background, Text, Border, and Shadow)
Now using a button background that is only slightly darker than the overall background */
html[native-dark-active] .btn, .btn {
background-color: var(--color-01-87);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-91), var(--color-01-95), var(--color-01-95));
color: var(--color-01-50);
border-color: var(--color-01-80);
cursor: pointer;
}
/* Navigation (Background and Text Colors) */
.navbar, .navbar-light, .navbar-dark, .navbar.bg-light {
background-color: var(--color-01-90);
/* New Gradient based on original background (90 -5, 90, 90 +1, 90 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-85), var(--color-01-90), var(--color-01-91), var(--color-01-95));
color: var(--color-01-50);
border-color: var(--color-01-85);
}
.navbar a {
color: var(--color-01-40);
}
.navbar a.dropdown-item {
color: var(--color-01-43);
}
/* Cards / Containers (Background, Border, and Shadow)
Cards now use a slightly lighter background and a bold, clear shadow */
.card {
background-color: var(--color-01-90);
/* New Gradient based on original background (90 -5, 90, 90 +1, 90 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-85), var(--color-01-90), var(--color-01-91), var(--color-01-95));
border-color: var(--color-01-85);
color: var(--color-01-12);
}
.card-body {
color: var(--color-01-40);
}
/* Dropdown Menu and Submenu (Background, Text, and Shadow) */
.navbar .dropdown-menu,
.nav-item .dropdown-menu {
background-color: var(--color-01-80);
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
color: var(--color-01-40);
}
.navbar-nav {
--bs-nav-link-hover-color: var(--color-01-17);
}
.dropdown-item {
color: var(--color-01-40);
background-color: var(--color-01-80);
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: var(--color-01-65);
/* New Gradient based on original background (65 -5, 65, 65 +1, 65 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-60), var(--color-01-65), var(--color-01-66), var(--color-01-70));
color: var(--color-01-40);
}

View File

@@ -0,0 +1,297 @@
/***
Global Theming Styles Color and Shadow Variables
HINT:
- Better overwritte CSS variables instead of individual elements.
- Don't use !important. If possible use a specific selector.
*/
{% if design.font.import_url %}
@import url('{{ design.font.import_url }}');
{% endif %}
/* Auto-generated by colorscheme-generator */
:root {
{% for var_name, color in color_palette.items() %}
{{ var_name }}: {{ color }};
{% endfor %}
}
@media (prefers-color-scheme: dark) {
:root {
{% for var_name, color in inverted_color_palette.items() %}
{{ var_name }}: {{ color }};
{% endfor %}
}
}
:root, ::after, ::before, ::backdrop {
/* For Dark Mode Plugin
* @See https://chromewebstore.google.com/detail/dark-mode/dmghijelimhndkbmpgbldicpogfkceaj
*/
--native-dark-accent-color: var(--color-01-60); /* was #a9a9a9 */
--native-dark-bg-color: var(--color-01-10); /* was #292929 */
--native-dark-bg-image-color: rgba(var(--color-01-rgb-01), 0.10); /* remains the same, or adjust if needed */
--native-dark-border-color: var(--color-01-40); /* was #555555 */
--native-dark-box-shadow: 0 0 0 1px rgb(var(--color-01-rgb-99), / 10%);
--native-dark-cite-color: var(--color-01-70); /* was #92de92 you might adjust if a green tone is needed */
--native-dark-fill-color: var(--color-01-50); /* was #7d7d7d */
--native-dark-font-color: var(--color-01-95); /* was #dcdcdc */
--native-dark-link-color: var(--color-01-80); /* was #8db2e5 */
--native-dark-visited-link-color: var(--color-01-85); /* was #c76ed7 */
}
/* Bootstrap Overrides (Color/Shadow Variables Only) */
:root {
--bs-black: var(--color-01-01); /* Original tone: Black (#000) */
--bs-white: var(--color-01-99); /* Original tone: White (#fff) */
--bs-gray: var(--color-01-50); /* Original tone: Gray (#6c757d) */
--bs-gray-dark: var(--color-01-20); /* Original tone: Dark Gray (#343a40) */
{% for i in range(1, 10) %}
{# @see https://chatgpt.com/share/67bcd94e-bb44-800f-bf63-06d1ae0f5096 #}
{% set gray = i * 100 %}
{% set color = 100 - i * 10 %}
--bs-gray-{{ gray }}: var(--color-01-{{ "%02d" % color }});
{% endfor %}
--bs-primary: var(--color-01-65); /* Original tone: Blue (#0d6efd) */
--bs-light: var(--color-01-95); /* Original tone: Light (#f8f9fa) */
--bs-dark: var(--color-01-10); /* Original tone: Dark (#212529) */
--bs-primary-rgb: var(--color-01-rgb-65); /* Original tone: Blue (13, 110, 253) */
--bs-secondary-rgb: var(--color-01-rgb-50); /* Original tone: Grayish (#6c757d / 108, 117, 125) */
--bs-light-rgb: var(--color-01-rgb-95); /* Original tone: Light (248, 249, 250) */
--bs-dark-rgb: var(--color-01-rgb-10); /* Original tone: Dark (33, 37, 41) */
--bs-white-rgb: var(--color-01-rgb-99); /* Original tone: White (255, 255, 255) */
--bs-black-rgb: var(--color-01-rgb-01); /* Original tone: Black (0, 0, 0) */
--bs-body-color-rgb: var(--color-01-rgb-10); /* Original tone: Dark (#212529 / 33, 37, 41) */
--bs-body-bg-rgb: var(--color-01-rgb-99); /* Original tone: White (#fff / 255, 255, 255) */
--bs-body-color: var(--color-01-10); /* Original tone: Dark (#212529) */
--bs-body-bg: var(--color-01-99); /* Original tone: White (#fff) */
--bs-border-color: var(--color-01-85); /* Original tone: Gray (#dee2e6) */
--bs-link-color: var(--color-01-65); /* Original tone: Blue (#0d6efd) */
--bs-link-hover-color: var(--color-01-60); /* Original tone: Darker Blue (#0a58ca) */
--bs-code-color: var(--color-01-55); /* Original tone: Pink (#d63384) */
--bs-highlight-bg: var(--color-01-93); /* Original tone: Light Yellow (#fff3cd) */
--bs-list-group-bg: var(--color-01-40);
--bs-emphasis-color: var(--color-01-01); /* Gemappt von #000 */
--bs-emphasis-color-rgb: var(--color-01-rgb-01); /* Gemappt von 0, 0, 0 */
--bs-secondary-color: rgba(var(--color-01-rgb-10), 0.75); /* Gemappt von rgba(33, 37, 41, 0.75) */
--bs-secondary-color-rgb: var(--color-01-rgb-10); /* Gemappt von 33, 37, 41 */
--bs-secondary-bg: var(--color-01-90); /* Gemappt von #e9ecef */
--bs-secondary-bg-rgb: var(--color-01-rgb-90); /* Gemappt von 233, 236, 239 */
--bs-tertiary-color: rgba(var(--color-01-rgb-10), 0.5); /* Gemappt von rgba(33, 37, 41, 0.5) */
--bs-tertiary-color-rgb: var(--color-01-rgb-10); /* Gemappt von 33, 37, 41 */
--bs-tertiary-bg: var(--color-01-95); /* Gemappt von #f8f9fa */
--bs-tertiary-bg-rgb: var(--color-01-rgb-95); /* Gemappt von 248, 249, 250 */
--bs-link-color-rgb: var(--color-01-rgb-65); /* Gemappt von 13, 110, 253 */
--bs-link-hover-color-rgb: var(--color-01-rgb-60); /* Gemappt von 10, 88, 202 */
--bs-highlight-color: var(--color-01-10); /* Gemappt von #212529 */
--bs-border-color-translucent: rgba(var(--color-01-rgb-01), 0.175); /* Gemappt von rgba(0, 0, 0, 0.175) */
--bs-focus-ring-color: rgba(var(--color-01-rgb-65), 0.25); /* Gemappt von rgba(13, 110, 253, 0.25) */
--bs-table-color: var(--bs-emphasis-color);
--bs-table-bg: var(--color-01-99); /* White (#fff) */
--bs-table-border-color: var(--color-01-99); /* White (#fff) */
--bs-table-striped-bg: var(--color-01-85); /* Light Gray (entspricht ca. #dee2e6) */
--bs-table-hover-color: var(--color-01-01); /* Black (#000) */
--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075);
}
/* Global Defaults (Colors Only) */
body, html[native-dark-active] {
background-color: var(--color-01-93);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-93), var(--color-01-91), var(--color-01-95), var(--color-01-93));
background-attachment: fixed;
color: var(--color-01-40);
font-family: {{design.font.type}};
}
{# All links (applies to all anchor elements regardless of state) #}
a {
color: var(--color-01-50);
}
{# Unvisited links (applies only to links that have not been visited) #}
a:link {
color: var(--color-01-55);
}
{# Visited links (applies only to links that have been visited) #}
a:visited {
color: var(--color-01-45);
}
{# Hover state (applies when the mouse pointer is over the link) #}
a:hover {
color: var(--color-01-60);
}
{# Active state (applies during the time the link is being activated, e.g., on click) #}
a:active {
color: var(--color-01-65);
}
/** Set default buttons transparent **/
html[native-dark-active] button, button{
background-color: var(--color-01-87);
}
button:hover, .btn:hover {
filter: brightness(0.9);
}
/* {# Invalid state: when the input value fails validation criteria. Use danger color for error indication. #} */
input:invalid,
textarea:invalid,
select:invalid {
background-color: var(--color-01-01);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-01), var(--color-01-10));
/* Use Bootstrap danger color for error messages */
color: var(--bs-danger);
border-color: var(--color-01-20);
}
/* {# Valid state: when the input value meets all validation criteria. Use success color for confirmation. #} */
input:valid,
textarea:valid,
select:valid {
background-color: var(--color-01-80);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-80), var(--color-01-90));
/* Use Bootstrap success color for confirmation messages */
color: var(--bs-success);
border-color: var(--color-01-70);
}
/* {# Required field: applied to elements that must be filled out by the user. Use warning color for emphasis. #} */
input:required,
textarea:required,
select:required {
background-color: var(--color-01-50);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-50), var(--color-01-60));
/* Use Bootstrap warning color to indicate a required field */
color: var(--bs-warning);
border-color: var(--color-01-70);
}
/* {# Optional field: applied to elements that are not mandatory. Use info color to denote additional information. #} */
input:optional,
textarea:optional,
select:optional {
background-color: var(--color-01-60);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-60), var(--color-01-70));
/* Use Bootstrap info color to indicate optional information */
color: var(--bs-info);
border-color: var(--color-01-70);
}
/* {# Read-only state: when an element is not editable by the user. #} */
input:read-only,
textarea:read-only,
select:read-only {
background-color: var(--color-01-80);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-90), var(--color-01-70));
color: var(--color-01-20);
border-color: var(--color-01-50);
}
/* {# Read-write state: when an element is editable by the user. #} */
input:read-write,
textarea:read-write,
select:read-write {
background-color: var(--color-01-70);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-80));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
/* {# In-range: for inputs with a defined range, when the value is within the allowed limits. #} */
input:in-range,
textarea:in-range,
select:in-range {
background-color: var(--color-01-70);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-85));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
/* {# Out-of-range: for inputs with a defined range, when the value falls outside the allowed limits. #} */
input:out-of-range,
textarea:out-of-range,
select:out-of-range {
background-color: var(--color-01-10);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-10), var(--color-01-30));
color: var(--color-01-10);
border-color: var(--color-01-50);
}
/* {# Placeholder-shown: when the input field is displaying its placeholder text. #} */
input:placeholder-shown,
textarea:placeholder-shown,
select:placeholder-shown {
background-color: var(--color-01-82);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-82), var(--color-01-90));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
/* {# Focus state: when the element is focused by the user. #} */
input:focus,
textarea:focus,
select:focus {
background-color: var(--color-01-75);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-85));
color: var(--color-01-40);
border-color: var(--color-01-50);
}
/* {# Hover state: when the mouse pointer is over the element. #} */
input:hover,
textarea:hover,
select:hover {
background-color: var(--color-01-78);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-78), var(--color-01-88));
color: var(--color-01-40);
border-color: var(--color-01-65);
}
/* {# Active state: when the element is being activated (e.g., clicked). #} */
input:active,
textarea:active,
select:active {
background-color: var(--color-01-68);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-68), var(--color-01-78));
color: var(--color-01-40);
border-color: var(--color-01-60);
}
/* {# Checked state: specifically for radio buttons and checkboxes when selected. #} */
input:checked {
background-color: var(--color-01-90);
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-90), var(--color-01-99));
color: var(--color-01-40);
border-color: var(--color-01-70);
}
option {
background-color: var(--color-01-82);
color: var(--color-01-07);
}
/* Tables (Borders and Header Colors) */
th, td {
border-color: var(--color-01-70);
}
thead {
background-color: var(--color-01-80);
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
color: var(--color-01-40);
}
/* Headings (Text Color) */
h1, h2, h3, h4, h5, h6, p{
color: var(--color-01-10);
}

View File

@@ -0,0 +1,8 @@
{% set __css_tpl_dir = [playbook_dir, 'roles', 'sys-front-inj-css', 'templates', 'css'] | path_join %}
{% for css_file in ['default.css','bootstrap.css'] %}
<link rel="stylesheet" href="{{ [ cdn_urls.shared.css, css_file, lookup('local_mtime_qs', [__css_tpl_dir, css_file ~ '.j2'] | path_join)] | url_join }}">
{% endfor %}
{% if app_style_present | bool %}
<link rel="stylesheet" href="{{ [ cdn_urls.role.release.css, 'style.css', lookup('local_mtime_qs', app_style_src)] | url_join }}">
{% endif %}

View File

@@ -0,0 +1,8 @@
# Constants
CSS_FILES: ['default.css','bootstrap.css']
CSS_BASE_COLOR: "{{ design.css.colors.base }}"
CSS_COUNT: 7
CSS_SHADES: 100
# Variables
css_app_dst: "{{ [cdn.role.release.css, 'style.css'] | path_join }}"