From 7e68dc4c61e8e062e6de54db490f403f0858e546 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 00:59:39 +0200 Subject: [PATCH 01/14] fix(web-app-bluesky): read the PDS admin password and clear the restore timer - Administration.md: the createAccount example reads PDS_ADMIN_PASSWORD from the PDS container before calling curl and unsets it afterwards. The operator shell had no value, so curl sent empty Basic Auth credentials. - _appview.js: restorePreferences clears its deadline timer once the race settles, so a Playwright worker no longer stays alive until the 45 s timer fires. Addresses the CodeRabbit review on #917. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-bluesky/Administration.md | 2 ++ roles/web-app-bluesky/files/playwright/_appview.js | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/roles/web-app-bluesky/Administration.md b/roles/web-app-bluesky/Administration.md index a610f909dd..e3bffc970d 100644 --- a/roles/web-app-bluesky/Administration.md +++ b/roles/web-app-bluesky/Administration.md @@ -3,6 +3,7 @@ ## Create a user via POST ```bash +PDS_ADMIN_PASSWORD="$(compose exec -T pds printenv PDS_ADMIN_PASSWORD)" curl -X POST https://your-pds-domain/xrpc/com.atproto.server.createAccount \ --user "admin:${PDS_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" \ @@ -12,6 +13,7 @@ curl -X POST https://your-pds-domain/xrpc/com.atproto.server.createAccount \ "password": "securepassword123", "inviteCode": "optional-invite-code" }' +unset PDS_ADMIN_PASSWORD ``` ## Use pdsadmin diff --git a/roles/web-app-bluesky/files/playwright/_appview.js b/roles/web-app-bluesky/files/playwright/_appview.js index d5e89d07e9..590297b66c 100644 --- a/roles/web-app-bluesky/files/playwright/_appview.js +++ b/roles/web-app-bluesky/files/playwright/_appview.js @@ -56,10 +56,13 @@ async function restorePreferences(request, pdsBaseUrl, accessJwt, preferences) { }).then((response) => { if (response.status() !== 200) throw new Error(`putPreferences answered ${response.status()}`); }); + let timer; const deadline = new Promise((_, reject) => { - setTimeout(() => reject(new Error("putPreferences timed out")), resolveTimeout(45_000)); + timer = setTimeout(() => reject(new Error("putPreferences timed out")), resolveTimeout(45_000)); }); - await Promise.race([restore, deadline]).catch((err) => console.warn(`bluesky: preferences not restored: ${err.message}`)); + await Promise.race([restore, deadline]) + .catch((err) => console.warn(`bluesky: preferences not restored: ${err.message}`)) + .finally(() => clearTimeout(timer)); } function collectAppViewResponses(page) { From 3c53fde46a9b2181a424b1ef7e74cf4840c68e12 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 15:23:03 +0200 Subject: [PATCH 02/14] docs(web-app-bluesky): keep the PDS admin password out of curl's argv The createAccount example expanded the password into `--user`, where any local user can read it from the process list while the request runs. The password now goes from `printenv` in the pds container through `sed` into `curl --config -` on stdin, so it is never a shell variable or a command-line argument. Addresses the CodeRabbit review on #961. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-bluesky/Administration.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/roles/web-app-bluesky/Administration.md b/roles/web-app-bluesky/Administration.md index e3bffc970d..42b196cc73 100644 --- a/roles/web-app-bluesky/Administration.md +++ b/roles/web-app-bluesky/Administration.md @@ -3,17 +3,16 @@ ## Create a user via POST ```bash -PDS_ADMIN_PASSWORD="$(compose exec -T pds printenv PDS_ADMIN_PASSWORD)" -curl -X POST https://your-pds-domain/xrpc/com.atproto.server.createAccount \ - --user "admin:${PDS_ADMIN_PASSWORD}" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "handle": "username", - "password": "securepassword123", - "inviteCode": "optional-invite-code" - }' -unset PDS_ADMIN_PASSWORD +compose exec -T pds printenv PDS_ADMIN_PASSWORD \ + | sed 's/.*/user = "admin:&"/' \ + | curl --config - -X POST https://your-pds-domain/xrpc/com.atproto.server.createAccount \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "handle": "username", + "password": "securepassword123", + "inviteCode": "optional-invite-code" + }' ``` ## Use pdsadmin From a59eb5d34701cbd2b87800ef8d17db5d784d680b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 22:56:20 +0200 Subject: [PATCH 03/14] docs(web-app-bluesky): escape the PDS admin password for curl's config curl reads backslash escapes inside a quoted config value, so a password with `"` or `\` reached the PDS altered (`a"b` as `admin:a`, `a\b` as `admin:ab`). The sed step now escapes both characters before it builds the `user` line. Addresses the CodeRabbit review on #961. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-bluesky/Administration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-bluesky/Administration.md b/roles/web-app-bluesky/Administration.md index 42b196cc73..82fdc48a2f 100644 --- a/roles/web-app-bluesky/Administration.md +++ b/roles/web-app-bluesky/Administration.md @@ -4,7 +4,7 @@ ```bash compose exec -T pds printenv PDS_ADMIN_PASSWORD \ - | sed 's/.*/user = "admin:&"/' \ + | sed 's/[\\"]/\\&/g; s/.*/user = "admin:&"/' \ | curl --config - -X POST https://your-pds-domain/xrpc/com.atproto.server.createAccount \ -H "Content-Type: application/json" \ -d '{ From 5359b99ed4dc28b0163c63ff6b7fee0581d845a3 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 23:33:58 +0200 Subject: [PATCH 04/14] style(tests): reflow the onlyoffice deadline assertions the way autoformat writes them Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web-app-nextcloud/test_onlyoffice_check_deadline.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py b/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py index 5ca872e41f..990e1b697e 100644 --- a/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py +++ b/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py @@ -15,13 +15,18 @@ class TestOnlyofficeCheckDeadline(unittest.TestCase): def test_the_deadline_is_set_before_the_check(self) -> None: deadline, check = self.tasks self.assertIn("nextcloud_onlyoffice_check_deadline", deadline["set_fact"]) - self.assertIn("lookup('timeout'", deadline["set_fact"]["nextcloud_onlyoffice_check_deadline"]) + self.assertIn( + "lookup('timeout'", + deadline["set_fact"]["nextcloud_onlyoffice_check_deadline"], + ) self.assertEqual(check["register"], "nextcloud_onlyoffice_check") def test_the_retries_stop_at_the_deadline(self) -> None: until = self.tasks[1]["until"] self.assertIn("nextcloud_onlyoffice_check.rc == 0", until) - self.assertIn("now(utc=true).timestamp() > nextcloud_onlyoffice_check_deadline", until) + self.assertIn( + "now(utc=true).timestamp() > nextcloud_onlyoffice_check_deadline", until + ) if __name__ == "__main__": From 1c35cdef91a4c82af2ec8de7da8872755d418b97 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 01:35:21 +0200 Subject: [PATCH 05/14] style: apply the autoformat output main drifted from `make quality-high` on main (fea6f31d9f) rewrote two files in its autoformat step: - roles/sys-service/handlers/refresh_systemctl_service.yml: the handler's `when` was wrapped by hand; the formatter wraps it at line width. Both forms parse to the same YAML. - tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py: two over-long assertIn calls that ruff format splits. A second autoformat run leaves the tree unchanged, so main is stable under the formatter again. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/sys-service/handlers/refresh_systemctl_service.yml | 5 ++--- .../web-app-nextcloud/test_onlyoffice_check_deadline.py | 9 +++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/roles/sys-service/handlers/refresh_systemctl_service.yml b/roles/sys-service/handlers/refresh_systemctl_service.yml index 6c8fb43bca..111fcf6bdf 100644 --- a/roles/sys-service/handlers/refresh_systemctl_service.yml +++ b/roles/sys-service/handlers/refresh_systemctl_service.yml @@ -53,6 +53,5 @@ Unable to start service {{ system_service_name }}. See shared service diagnostics above. when: - not (system_service_suppress_flush | bool) - - (systemd_set_state.failed | default(false) | bool) or (system_service_state != omit and ( - (system_service_result.stdout | default('') | trim) not in ['success', ''] or - (system_service_exec_status.stdout | default('') | trim) not in ['0', ''])) + - (systemd_set_state.failed | default(false) | bool) or (system_service_state != omit and ( (system_service_result.stdout | default('') | trim) not in + ['success', ''] or (system_service_exec_status.stdout | default('') | trim) not in ['0', ''])) diff --git a/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py b/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py index 5ca872e41f..990e1b697e 100644 --- a/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py +++ b/tests/unit/python/roles/web-app-nextcloud/test_onlyoffice_check_deadline.py @@ -15,13 +15,18 @@ class TestOnlyofficeCheckDeadline(unittest.TestCase): def test_the_deadline_is_set_before_the_check(self) -> None: deadline, check = self.tasks self.assertIn("nextcloud_onlyoffice_check_deadline", deadline["set_fact"]) - self.assertIn("lookup('timeout'", deadline["set_fact"]["nextcloud_onlyoffice_check_deadline"]) + self.assertIn( + "lookup('timeout'", + deadline["set_fact"]["nextcloud_onlyoffice_check_deadline"], + ) self.assertEqual(check["register"], "nextcloud_onlyoffice_check") def test_the_retries_stop_at_the_deadline(self) -> None: until = self.tasks[1]["until"] self.assertIn("nextcloud_onlyoffice_check.rc == 0", until) - self.assertIn("now(utc=true).timestamp() > nextcloud_onlyoffice_check_deadline", until) + self.assertIn( + "now(utc=true).timestamp() > nextcloud_onlyoffice_check_deadline", until + ) if __name__ == "__main__": From ddecf8a94ab93acc2702835eae1e9f00f50a2f3f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:23:06 +0200 Subject: [PATCH 06/14] fix(web-app-magento): draw the last two named colors from tokens and guard the stylesheet against literals Two rules of the Luma child theme still used the CSS keyword darkgrey; both now take var(--design-border-strong). A new design test reads the served role stylesheet and fails on any hex value, color function or color keyword outside a var(--design-*) reference, so a literal cannot return unnoticed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/playwright/test-design.js | 29 +++++++++++++++++++ roles/web-app-magento/templates/style.css.j2 | 4 +-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/roles/web-app-magento/files/playwright/test-design.js b/roles/web-app-magento/files/playwright/test-design.js index 66f5684e8e..633da5d2dc 100644 --- a/roles/web-app-magento/files/playwright/test-design.js +++ b/roles/web-app-magento/files/playwright/test-design.js @@ -28,6 +28,9 @@ const SIGN_IN_BUTTON = `${SIGN_IN_FORM} button.action.login`; const HEADER_PANEL = ".page-header .panel.wrapper"; const INJECTED_SHEET = /\/_shared\/css\/|\/roles\/web-app-magento\//; const ROLE_SHEET = "link[href*='/roles/web-app-magento/'][href*='style.css']"; +const COLOR_PROPERTY = + /^(?:[a-z-]*-)?color$|^(?:background(?:-image)?|fill|stroke|box-shadow|text-shadow|border(?:-(?:top|right|bottom|left))?|outline|column-rule)$/; +const NO_COLOR = /^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i; const CACHE_GRID = "#cache_grid_table"; const TREE_SELECTION = ".jstree-default .jstree-clicked"; @@ -197,6 +200,32 @@ test("design: every image the role stylesheet references is served", async ({ pa } }); +test("design: the role stylesheet sets every color through a token", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await openShop(page, "/", ".page-footer"); + const sheet = await page.locator(ROLE_SHEET).first().evaluate((link) => link.href); + const css = await (await apiGetOnion(page.request, sheet)).text(); + const offenders = await page.evaluate( + ([source, property, keyword]) => { + const colorProperty = new RegExp(property); + const noColor = new RegExp(keyword, "i"); + const found = []; + for (const [, name, value] of source.matchAll(/(?:^|[{;])\s*([a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (!colorProperty.test(name) || value.includes("var(--design-") || value.includes("url(")) continue; + const literal = + /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i.test(value) || + value + .split(/[\s,()]+/) + .some((word) => /^[a-z]+$/i.test(word) && !noColor.test(word) && CSS.supports("color", word)); + if (literal) found.push(`${name}: ${value.trim()}`); + } + return found; + }, + [css, COLOR_PROPERTY.source, NO_COLOR.source], + ); + expect(offenders, "every color of the role stylesheet comes from a --design-* token").toEqual([]); +}); + test("design: focus stops inside the storefront header panel draw in the frame text color", async ({ page }) => { skipUnlessServiceEnabled("design"); await page.setViewportSize(DESKTOP); diff --git a/roles/web-app-magento/templates/style.css.j2 b/roles/web-app-magento/templates/style.css.j2 index 7983c4b572..a529c936e9 100644 --- a/roles/web-app-magento/templates/style.css.j2 +++ b/roles/web-app-magento/templates/style.css.j2 @@ -5510,7 +5510,7 @@ border-bottom-color: var(--design-text); } & .pagebuilder-stage-wrapper.stage-full-screen .pagebuilder-panel-wrapper .pagebuilder-panel::-webkit-scrollbar-thumb { - background-color: darkgrey; + background-color: var(--design-border-strong); } & .pagebuilder-stage-wrapper.stage-full-screen .pagebuilder-panel-wrapper .pagebuilder-panel .panel-controls { background-color: var(--design-text); @@ -5551,7 +5551,7 @@ border-bottom-color: var(--design-text); } & .pagebuilder-wysiwyg-snapshot .pagebuilder-panel-wrapper.transition-out .pagebuilder-panel::-webkit-scrollbar-thumb { - background-color: darkgrey; + background-color: var(--design-border-strong); } & .pagebuilder-wysiwyg-snapshot .pagebuilder-panel-wrapper.transition-out .pagebuilder-panel .panel-controls { background-color: var(--design-text); From 28192d04126a04b1fc58004f6cd7b2188ae92f98 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:25:08 +0200 Subject: [PATCH 07/14] fix(web-app-magento): index on save so catalog changes reach the storefront without a cron After setup:install every indexer runs by schedule, and no container runs Magento's cron, so a product created after the install never reached the category listing or the cart ("Product that you are trying to add is not available"). The setup now switches all indexers to realtime and, only when that switch changed a mode, rebuilds the backlog once. The resolved TODO entry is removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-magento/TODO.md | 1 - roles/web-app-magento/tasks/01_setup.yml | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/roles/web-app-magento/TODO.md b/roles/web-app-magento/TODO.md index f5676b602a..7868713708 100644 --- a/roles/web-app-magento/TODO.md +++ b/roles/web-app-magento/TODO.md @@ -5,6 +5,5 @@ - `meta/csp.yml` grants `unsafe-eval` for `script-src`: Knockout compiles every `data-bind` attribute with `new Function`, and without the grant the Knockout forms of the admin stay empty ("Unable to parse bindings", measured on the code form at `tfa/google/auth`). The grant covers the storefront too, which runs the same Knockout components. Open for the role's owner: keep it, or serve the admin under a policy of its own. - The admin dashboard requests a launch script from the host `assets.adobedtm.com` (the admin usage tracking of `Magento_AdminAnalytics`, whose notification dialog opens on the first dashboard visit) and the content security policy blocks it in `script-src-elem`, one console error per dashboard visit. Options: switch the tracking off with `admin/usage/enabled`, or allow the host. - The persona test "administrator: app โ†’ universal logout" in `files/playwright/playwright.spec.js` does not enter the admin while `two_factor.enabled` is true: the shared native sign-in stops at Magento's two-factor page and the flow logs out through the link of that page. With the flag off it enters the admin and signs out through the account menu. `files/playwright/test-admin-sign-in.js` asserts the sign-in in both states of the flag. -- Indexers: after the install every indexer runs by schedule (`bin/magento indexer:status` shows `Update On: Schedule` for all of them, with a backlog) and no container runs Magento's cron, so a product or category created after the install never reaches the storefront. The three products the design gallery seeds over REST stayed out of the category listing, and `POST /rest/default/V1/carts/mine/items` answered 400 "Product that you are trying to add is not available." until `bin/magento indexer:reindex` was run by hand. Options: a service that runs `bin/magento cron:run` every minute, or `bin/magento indexer:set-mode realtime` in `tasks/01_setup.yml`. The gallery test in `files/playwright/test-design.js` needs one of them; the other design tests seed nothing. - Search engine: `bin/magento indexer:reindex catalogsearch_fulltext` failed with `index_create_block_exception`, "cluster create-index blocked (api)". OpenSearch had set `cluster.blocks.create_index` itself because the disk of the host was 92 % full (140 GB free of 1.7 TB), which is above its percentage watermark; category pages and the search then list nothing. On the test stack the block was lifted by hand with the transient cluster setting `cluster.routing.allocation.disk.threshold_enabled: false`. Options: that setting or absolute watermarks in the environment of the `search` service. - Storefront links carry `/index.php/` (the logo links to `/index.php/`): `web/seo/use_rewrites` is not set. Options: `--use-rewrites=1` at `setup:install`, or `bin/magento config:set web/seo/use_rewrites 1`. diff --git a/roles/web-app-magento/tasks/01_setup.yml b/roles/web-app-magento/tasks/01_setup.yml index ffa05cedc6..d1b9ef4364 100644 --- a/roles/web-app-magento/tasks/01_setup.yml +++ b/roles/web-app-magento/tasks/01_setup.yml @@ -84,6 +84,21 @@ register: magento_db_password_realign changed_when: "'You saved the new configuration' in magento_db_password_realign.stdout" +- name: "๐Ÿ—‚๏ธ Index on save, so catalog changes reach the storefront without a cron" + ansible.builtin.shell: >- + container exec --user {{ MAGENTO_USER }} {{ MAGENTO_PHP_CONTAINER_ADDRESS }} + bash -lc "cd {{ MAGENTO_ROOT }} && bin/magento indexer:set-mode realtime" + register: magento_indexer_mode + changed_when: "'was changed from' in magento_indexer_mode.stdout" + +- name: "๐Ÿ—‚๏ธ Rebuild the indexes the schedule left behind" + ansible.builtin.shell: >- + container exec --user {{ MAGENTO_USER }} {{ MAGENTO_PHP_CONTAINER_ADDRESS }} + bash -lc "cd {{ MAGENTO_ROOT }} && bin/magento indexer:reindex" + register: magento_indexer_reindex + changed_when: "'rebuilt successfully' in magento_indexer_reindex.stdout" + when: magento_indexer_mode is changed + - name: "๐Ÿ“ค Stage Magento's remote-storage configurator into the volume" ansible.builtin.copy: src: php/remote-storage.php From a18e1d9be6fc74580491b7907e75a8f35216f87c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:32:05 +0200 Subject: [PATCH 08/14] fix(web-app-shopware): draw every color of the role stylesheet from tokens, also inside its SVG images The role stylesheet still carried seven literal colors: copies of Bootstrap's white carousel arrows and Shopware's base64 admin signet, the checkerboard behind an empty property preview, a gold user-type label and a gold contrast button. The copies are removed so the vendor defaults apply, and the contrast button, its icon and its loader take the warning tokens. A new design test fetches the served role stylesheet, decodes every data:image/svg+xml image and fails on any hex value, color function or color keyword outside a var(--design-*) reference. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/playwright/test-design.js | 44 ++++++++++++++++++- roles/web-app-shopware/templates/style.css.j2 | 21 ++------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/roles/web-app-shopware/files/playwright/test-design.js b/roles/web-app-shopware/files/playwright/test-design.js index 41bd4eaf1d..96e0bc19dd 100644 --- a/roles/web-app-shopware/files/playwright/test-design.js +++ b/roles/web-app-shopware/files/playwright/test-design.js @@ -11,7 +11,7 @@ const { galleryEnabled, tokenValue, } = require("./design"); -const { apiFetchOnion, decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl } = require("./personas"); +const { apiFetchOnion, apiGetOnion, decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl } = require("./personas"); const { skipUnlessServiceEnabled } = require("./service-gating"); const { resolveTimeout } = require("./timeouts"); const { signIn, dismissFirstRunWizard } = require("./test-admin-native"); @@ -46,6 +46,9 @@ const THEME_FIELDS = [ ["sw-color-danger", "--design-danger"], ]; const INJECTED_SHEET = /\/_shared\/css\/|\/style\.css(\?|$)/; +const ROLE_SHEET = "link[href*='/roles/web-app-shopware/'][href*='style.css']"; +const COLOR_PROPERTY = + /^(?:--[\w-]+|(?:[a-z-]*-)?color|background(?:-image)?|fill|stroke|box-shadow|text-shadow|border(?:-(?:top|right|bottom|left))?|outline|column-rule)$/; async function adminApi(request, token, method, path, data) { const response = await apiFetchOnion(request, `${base}${path}`, { @@ -534,6 +537,45 @@ test("design: the storefront shows the configured logo, favicon and shop name", expect(await page.title(), "the page title carries the shop name of the design").toContain(designTitle); }); +test("design: the role stylesheet sets every color through a token, also inside its SVG images", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await openStorefront(page, "/account/login", "form.login-form"); + const sheet = await page.locator(ROLE_SHEET).first().evaluate((link) => link.href); + const css = await (await apiGetOnion(page.request, sheet)).text(); + const offenders = await page.evaluate( + ([source, property]) => { + const colorProperty = new RegExp(property); + const keyword = (word) => + /^[a-z]+$/i.test(word) && + !/^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i.test(word) && + CSS.supports("color", word); + const literal = (value) => + /#[0-9a-f]{3,8}\b/i.test(value) || + /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[\d.]/i.test(value) || + value.split(/[\s,()]+/).some(keyword); + const found = []; + const images = + /url\(\s*(?:"(data:image\/svg\+xml[^"]*)"|'(data:image\/svg\+xml[^']*)'|(data:image\/svg\+xml[^)]*))\s*\)/gi; + for (const match of source.matchAll(images)) { + const uri = match[1] || match[2] || match[3]; + const svg = uri.includes(";base64,") + ? atob(uri.slice(uri.indexOf(";base64,") + 8)) + : decodeURIComponent(uri.slice(uri.indexOf(",") + 1)); + for (const [, attribute, value] of svg.matchAll(/\b(fill|stroke|stop-color|flood-color)\s*[=:]\s*["']?([^"';\s>)]+)/gi)) { + if (literal(value)) found.push(`svg ${attribute}=${value}`); + } + } + const declarations = source.replace(/url\((?:[^()"']|"[^"]*"|'[^']*')*\)/gi, "url()"); + for (const [, name, value] of declarations.matchAll(/(?:^|[{;])\s*(--[\w-]+|[a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (colorProperty.test(name) && literal(value)) found.push(`${name}: ${value.trim()}`); + } + return found; + }, + [css, COLOR_PROPERTY.source], + ); + expect(offenders, "every color of the role stylesheet, also inside its SVG images, comes from a --design-* token").toEqual([]); +}); + test("design: the administration maps its tokens and keeps the menu on the frame", async ({ page, request }) => { skipUnlessServiceEnabled("design"); test.setTimeout(resolveTimeout(240_000)); diff --git a/roles/web-app-shopware/templates/style.css.j2 b/roles/web-app-shopware/templates/style.css.j2 index 35a784ba52..84029039bb 100644 --- a/roles/web-app-shopware/templates/style.css.j2 +++ b/roles/web-app-shopware/templates/style.css.j2 @@ -762,12 +762,6 @@ body { .carousel-control-next:focus { color: var(--design-surface-1); } -.carousel-control-prev-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e"); -} -.carousel-control-next-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e"); -} .carousel-caption { color: var(--bs-carousel-caption-color); } @@ -1625,7 +1619,6 @@ html .sw-text-editor-table .sw-text-editor-table__col { } .filter-property-select-preview { border-color: var(--design-border-strong); - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 90 90'%3E%3Cpath fill='%23cdd5db' d='M0 0h30v30H0zM30 30h30v30H30zM60 0h30v30H60zM60 60h30v30H60zM0 60h30v30H0z'/%3E%3C/svg%3E"); } .filter-property-select-preview-checkmark { background-color: var(--design-primary); @@ -2709,14 +2702,14 @@ html .sw-alert--system .sw-alert__message { color: var(--design-on-primary); } html .sw-button.sw-button--contrast { - background: gold; - color: var(--design-text-muted); + background: var(--design-warning); + color: var(--design-on-warning); } html .sw-button.sw-button--contrast .mt-icon { - color: var(--design-text-muted); + color: var(--design-on-warning); } html .sw-button.sw-button--contrast .sw-loader .sw-loader-element div { - border-color: var(--design-border) transparent transparent transparent; + border-color: var(--design-on-warning) transparent transparent transparent; } html .sw-button.sw-button--contrast:hover { background-color: var(--design-warning-hover); @@ -2759,9 +2752,6 @@ html .sw-admin-menu.is--expanded .sw-admin-menu__navigation-link.router-link-act html .sw-admin-menu .sw-admin-menu__headline { color: var(--design-on-frame); } -html .sw-admin-menu .sw-admin-menu__header-logo { - background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMzRweCIgaGVpZ2h0PSIzNHB4IiB2aWV3Qm94PSIwIDAgMzQgMzQiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDQ2LjIgKDQ0NDk2KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5Mb2dvPC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+CiAgICAgICAgPHBhdGggZD0iTTI2LjkyNjc3MiwzMC4wNzA0NTYyIEMyNi45ODc1MjA3LDMwLjExNjE3MTEgMjcuMDIzNzI0NSwzMC4xODg4ODU1IDI3LjAyMzQxNzcsMzAuMjY1MjgxNiBDMjcuMDIzMTEwOSwzMC4zNDEzNzA5IDI2Ljk4NjI5MzUsMzAuNDEzNzc4NCAyNi45MjUyMzc5LDMwLjQ1OTE4NjUgQzI0LjAwMTAxNjEsMzIuNjIzNDM1OSAyMC41MjkxMzU1LDMzLjc2NzIyOTcgMTYuODg1MTMzNiwzMy43NjcyMjk3IEMxMi4zNzQzODg3LDMzLjc2NzIyOTcgOC4xMzQyNTE3NywzMi4wMTEzNDY3IDQuOTQ1MjUxNSwyOC44MjI5NiBDMS43NTYyNTEyMywyNS42MzQ1NzM0IDYuMTM2MjMyOTZlLTA1LDIxLjM5NDQzNjQgNi4xMzYyMzI5NmUtMDUsMTYuODgzOTk4NCBDNi4xMzYyMzI5NmUtMDUsMTIuMzc0MTc0IDEuNzU2MjUxMjMsOC4xMzQzNDM4MSA0Ljk0NTI1MTUsNC45NDUzNDM1NCBDOC4xMzQyNTE3NywxLjc1NjM0MzI4IDEyLjM3NDY5NTUsMC4wMDAxNTM0MDU4MjQgMTYuODg1MTMzNiwwLjAwMDE1MzQwNTgyNCBDMjEuMDU4Mzg1NiwwLjAwMDE1MzQwNTgyNCAyNS4wNjQxMTg1LDEuNTM1NDM4ODkgMjguMTY0NDUwMiw0LjMyMzEyOTUyIEMyOC4yNDQyMjEyLDQuMzk0NjE2NjQgMjguMjY3MjMyMSw0LjUwODQ0Mzc2IDI4LjIyMjEzMDgsNC42MDUzOTYyNCBDMjguMTc4MjU2Nyw0LjcwMDIwMTA0IDI4LjA3NjA4ODQsNC43NTc4ODE2MyAyNy45Njk5MzE2LDQuNzQzNzY4MjkgQzI2Ljc2NjkyMzEsNC41Nzg3MDM2MiAyNS40NjgxODk0LDQuNDk0OTQ0MDQgMjQuMTA5MzIwNiw0LjQ5NDk0NDA0IEMxOS4xOTE3NDM1LDQuNDk0OTQ0MDQgMTUuMTk4ODk2OCw1LjU5MTQ4ODg3IDEyLjU2MjQ2NDMsNy42NjY3NjI4NiBDMTAuMjExMzY2Niw5LjUxNzE0MzkgOC45Njg3Nzk0NSwxMi4wNzc0ODcxIDguOTY4Nzc5NDUsMTUuMDcxMDQ4NCBDOC45Njg3Nzk0NSwxOC40MDg1NDU1IDEwLjI0NjAzNjMsMjEuMDQ3MTI1NiAxMi44NzM1NzEzLDIzLjEzODM1MzggQzE1LjE5Mjc2MDUsMjQuOTgzODI1OSAxOC4yODE3NDAyLDI2LjE4MTkyNTQgMjEuMDA3MTQ4MSwyNy4yMzg1ODQ3IEMyMy4yOTY4ODM0LDI4LjEyNjgwNDQgMjUuNDU5NTk4NywyOC45NjUzMjA2IDI2LjkyNjc3MiwzMC4wNzA0NTYyIFogTTMzLjY5NjQxNzYsMTUuMzIzODYxMSBDMzMuNzQzMzU5OCwxNS44NjEwODgzIDMzLjc2Njk4NDMsMTYuMzg2MDQzMSAzMy43NjY5ODQzLDE2Ljg4MzY5MTYgQzMzLjc2Njk4NDMsMTkuMDAzMTQ2NCAzMy4zNzgyNTM5LDIxLjA3MzgxODIgMzIuNjExNTMxNiwyMy4wMzgzMzMyIEMzMi41ODYwNjYyLDIzLjEwMzM3NzMgMzIuNTMzNjAxNSwyMy4xNTQzMDggMzIuNDY3OTQzOCwyMy4xNzgyMzkzIEMzMi40NDE1NTgsMjMuMTg3NzUwNSAzMi40MTM5NDQ5LDIzLjE5MjM1MjcgMzIuMzg1NzE4MiwyMy4xOTIzNTI3IEMzMi4zNDQ2MDU1LDIzLjE5MjM1MjcgMzIuMzAzNzk5NSwyMy4xODE5MjExIDMyLjI2NzkwMjYsMjMuMTYxOTc4MyBDMzAuMjYwNDM0LDIyLjA0ODI1MiAyOC4wNzM0ODA1LDIxLjIzMjc0NjcgMjUuOTU4NjI3OCwyMC40NDM5MzM5IEMyMS44NTQ0MDg0LDE4LjkxMzU1NzQgMTguMzEwNzMzOSwxNy41OTE4MTI4IDE4LjMxMDczMzksMTQuNjkxODI5MiBDMTguMzEwNzMzOSwxMy42NTY2NDY3IDE4Ljc0MzY0NTEsMTIuNzc1MTc2OCAxOS41NjMxMzksMTIuMTQyMjI0NCBDMjAuNTA5MzQ2MiwxMS40MTEzOTkgMjEuOTI1ODk1NSwxMS4wNDA3NzA1IDIzLjc3MzgyMjEsMTEuMDQwNzcwNSBDMjcuMjM2NDk4NCwxMS4wNDA3NzA1IDMwLjA4NjE2NDksMTIuMjMzMzQ3NCAzMy42MDk1ODk5LDE1LjE1Nzg3NiBDMzMuNjU5MjkzNCwxNS4xOTg5ODg4IDMzLjY5MDg5NSwxNS4yNTk3Mzc1IDMzLjY5NjQxNzYsMTUuMzIzODYxMSBaIiBpZD0icGF0aC0xIj48L3BhdGg+CiAgICA8L2RlZnM+CiAgICA8ZyBpZD0iU3ltYm9scyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9Ik5hdi1Mb2dvL0xhcmdlIj4KICAgICAgICAgICAgPGcgaWQ9IkxvZ28iPgogICAgICAgICAgICAgICAgPGcgaWQ9IkZpbGwtMyI+CiAgICAgICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgICAgIDxnIGlkPSJMb2dvL1NpZ25ldCI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8bWFzayBpZD0ibWFzay0yIiBmaWxsPSJ3aGl0ZSI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHVzZSB4bGluazpocmVmPSIjcGF0aC0xIj48L3VzZT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvbWFzaz4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx1c2UgaWQ9Ik1hc2siIGZpbGw9IiNGRkZGRkYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgeGxpbms6aHJlZj0iI3BhdGgtMSI+PC91c2U+CiAgICAgICAgICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==) no-repeat; -} html .sw-admin-menu .sw-admin-menu__headline-action, html .sw-admin-menu .sw-admin-menu__headline-context-menu { color: var(--design-on-frame); @@ -2804,9 +2794,6 @@ html .sw-admin-menu .sw-admin-menu__user-actions-toggle.is--active { html .sw-admin-menu .sw-admin-menu__user-actions-toggle .sw-admin-menu__navigation-list-item:hover { background-color: var(--design-frame-hover); } -html .sw-admin-menu .sw-admin-menu__user-type { - color: gold; -} html .sw-admin-menu .sw-admin-menu__user-actions .sw-admin-menu__navigation-list-item { background-color: var(--design-frame-active); } From ac11d4c39da1688420f3c7c9b14fcb448410394f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:36:24 +0200 Subject: [PATCH 09/14] fix(web-app-mattermost): fall back to palette tokens instead of Mattermost's fixed RGB triples Fifty fallbacks of Mattermost's theme variables in the role stylesheet carried the upstream RGB triples (61, 60, 64 / 210, 75, 78 / 6, 167, 125 / 255, 188, 66), so a page without the theme variables showed Mattermost's grey, red, green and amber instead of the palette. They now fall back to the matching --design-*-rgb token. Two .Help__section p rules set color to a bare triple, an invalid color that the browser dropped; they now take var(--center-channel-color, var(--design-text)). A new design test reads the served role stylesheet and fails on hex values, color functions, color keywords and bare-triple var() fallbacks. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/playwright/test-design.js | 32 ++++++ .../web-app-mattermost/templates/style.css.j2 | 100 +++++++++--------- 2 files changed, 82 insertions(+), 50 deletions(-) diff --git a/roles/web-app-mattermost/files/playwright/test-design.js b/roles/web-app-mattermost/files/playwright/test-design.js index 45df3d1328..cce8939350 100644 --- a/roles/web-app-mattermost/files/playwright/test-design.js +++ b/roles/web-app-mattermost/files/playwright/test-design.js @@ -49,6 +49,9 @@ const FIRST_RUN = [ ["onboarding_task_list", "onboarding_task_list_show", "false"], ["onboarding_task_list", "onboarding_task_list_open", "false"], ]; +const ROLE_SHEET = "link[href*='/roles/web-app-mattermost/'][href*='style.css']"; +const COLOR_PROPERTY = + /^(?:--[\w-]+|(?:[a-z-]*-)?color|background(?:-image)?|fill|stroke|box-shadow|text-shadow|border(?:-(?:top|right|bottom|left))?|outline|column-rule)$/; function shown(selector) { return async (page) => { @@ -307,6 +310,35 @@ exports.register = function (shared) { await assertDesignTokens(page, "Mattermost"); }); + test("design: the role stylesheet sets every color through a token", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await openSignIn(page); + const sheet = await page.locator(ROLE_SHEET).first().evaluate((link) => link.href); + const css = await (await apiGetOnion(page.request, sheet)).text(); + const offenders = await page.evaluate( + ([source, property]) => { + const colorProperty = new RegExp(property); + const keyword = (word) => + /^[a-z]+$/i.test(word) && + !/^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i.test(word) && + CSS.supports("color", word); + const found = []; + for (const [, name, value] of source.matchAll(/(?:^|[{;])\s*(--[\w-]+|[a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (!colorProperty.test(name) || value.includes("url(")) continue; + const literal = + /#[0-9a-f]{3,8}\b/i.test(value) || + /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[\d.]/i.test(value) || + /var\(--[\w-]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\)/.test(value) || + value.split(/[\s,()]+/).some(keyword); + if (literal) found.push(`${name}: ${value.trim()}`); + } + return found; + }, + [css, COLOR_PROPERTY.source], + ); + expect(offenders, "every color of the role stylesheet comes from a --design-* token").toEqual([]); + }); + test("design: the sign-in page takes surface, primary action and text from the palette", async ({ page }) => { skipUnlessServiceEnabled("design"); for (const mode of MODES) { diff --git a/roles/web-app-mattermost/templates/style.css.j2 b/roles/web-app-mattermost/templates/style.css.j2 index 44c8b9c9db..e696ce6a22 100644 --- a/roles/web-app-mattermost/templates/style.css.j2 +++ b/roles/web-app-mattermost/templates/style.css.j2 @@ -7336,15 +7336,15 @@ html:not([data-design-app-theme="custom"]) .table-editor__row-remove { color: rgba(var(--center-channel-color-rgb), 0.56); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--allow { - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.16); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.16); color: var(--online-indicator, var(--design-success)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--allow-saved { - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.12); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.12); color: var(--online-indicator, var(--design-success)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--deny { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.16); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.16); color: var(--error-text); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--pending { @@ -7356,7 +7356,7 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--not-ap color: rgba(var(--center-channel-color-rgb), 0.72); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--mixed { - background: rgba(var(--away-indicator-rgb, 255, 188, 66), 0.18); + background: rgba(var(--away-indicator-rgb, var(--design-warning-rgb)), 0.18); color: var(--away-indicator, var(--design-warning)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChipIcon { @@ -7410,11 +7410,11 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicies color: rgba(var(--center-channel-color-rgb), 0.72); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicyOutcome--allow { - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.16); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.16); color: var(--online-indicator, var(--design-success)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicyOutcome--deny { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.16); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.16); color: var(--error-text, var(--design-danger)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicyBadge { @@ -7454,15 +7454,15 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode { } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode--true { border-left-color: var(--online-indicator, var(--design-success)); - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.06); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.06); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode--false { border-left-color: var(--error-text, var(--design-danger)); - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.06); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.06); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode--error { border-left-color: var(--away-indicator, var(--design-warning)); - background: rgba(var(--away-indicator-rgb, 255, 188, 66), 0.08); + background: rgba(var(--away-indicator-rgb, var(--design-warning-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceIcon--true { color: var(--online-indicator, var(--design-success)); @@ -7576,11 +7576,11 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__empty { color: rgba(var(--center-channel-color-rgb), 0.56); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__empty--error { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.04); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.04); color: var(--error-text, var(--design-danger)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__simulateError { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.08); color: var(--error-text, var(--design-danger)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__table th, @@ -7759,24 +7759,24 @@ html:not([data-design-app-theme="custom"]) .Help { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__header { - border-bottom: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-bottom: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__header h1 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__content>p { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.8); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.8); } html:not([data-design-app-theme="custom"]) .Help__section h2, html:not([data-design-app-theme="custom"]) .Help__section h3 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section p { - color: var(--center-channel-color-rgb, 61, 60, 64); + color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section ul li, html:not([data-design-app-theme="custom"]) .Help__section ol li { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.9); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.9); } html:not([data-design-app-theme="custom"]) .Help__section a, html:not([data-design-app-theme="custom"]) .Help__table td a { @@ -7784,32 +7784,32 @@ html:not([data-design-app-theme="custom"]) .Help__table td a { } html:not([data-design-app-theme="custom"]) .Help__table th, html:not([data-design-app-theme="custom"]) .Help__table td { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__code-block, html:not([data-design-app-theme="custom"]) .Help__code-preview { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.04); } html:not([data-design-app-theme="custom"]) .Help__code-block code { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__code-line-number { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.4); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.4); } html:not([data-design-app-theme="custom"]) .Help__code-keyword { color: var(--design-info); } html:not([data-design-app-theme="custom"]) .Help__inline-code { - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__hr, html:not([data-design-app-theme="custom"]) .Help__learn-more { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__blockquote { - border-left: 4px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.2); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + border-left: 4px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.2); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__badge { background-color: var(--design-text); @@ -7825,8 +7825,8 @@ html:not([data-design-app-theme="custom"]) .Help__learn-more ul li a { color: var(--button-bg, var(--design-link)); } html:not([data-design-app-theme="custom"]) .Help__example-box { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.12); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.02); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.12); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.02); } html:not([data-design-app-theme="custom"]) .Help__example-content p { color: var(--center-channel-color, var(--design-text)); @@ -7839,25 +7839,25 @@ html:not([data-design-app-theme="custom"]) .Help__example-name { } html:not([data-design-app-theme="custom"]) .Help__example-time, html:not([data-design-app-theme="custom"]) .Help__commands-header { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .Help__commands-example { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); background-color: var(--design-surface-2); box-shadow: var(--design-shadow-2); } html:not([data-design-app-theme="custom"]) .Help__commands-list { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__command-icon { background-color: rgba(var(--design-text-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__command-name { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__command-desc { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .userSettingDesktopHeader .userSettingDesktopHeaderInfo svg.circular-border { background: rgba(var(--button-bg-rgb), 0.12); @@ -8311,24 +8311,24 @@ html:not([data-design-app-theme="custom"]) .Help { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__header { - border-bottom: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-bottom: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__header h1 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__content>p { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.8); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.8); } html:not([data-design-app-theme="custom"]) .Help__section h2, html:not([data-design-app-theme="custom"]) .Help__section h3 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section p { - color: var(--center-channel-color-rgb, 61, 60, 64); + color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section ul li, html:not([data-design-app-theme="custom"]) .Help__section ol li { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.9); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.9); } html:not([data-design-app-theme="custom"]) .Help__section a, html:not([data-design-app-theme="custom"]) .Help__table td a { @@ -8336,32 +8336,32 @@ html:not([data-design-app-theme="custom"]) .Help__table td a { } html:not([data-design-app-theme="custom"]) .Help__table th, html:not([data-design-app-theme="custom"]) .Help__table td { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__code-block, html:not([data-design-app-theme="custom"]) .Help__code-preview { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.04); } html:not([data-design-app-theme="custom"]) .Help__code-block code { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__code-line-number { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.4); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.4); } html:not([data-design-app-theme="custom"]) .Help__code-keyword { color: var(--design-info); } html:not([data-design-app-theme="custom"]) .Help__inline-code { - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__hr, html:not([data-design-app-theme="custom"]) .Help__learn-more { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__blockquote { - border-left: 4px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.2); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + border-left: 4px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.2); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__badge { background-color: var(--design-text); @@ -8377,8 +8377,8 @@ html:not([data-design-app-theme="custom"]) .Help__learn-more ul li a { color: var(--button-bg, var(--design-link)); } html:not([data-design-app-theme="custom"]) .Help__example-box { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.12); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.02); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.12); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.02); } html:not([data-design-app-theme="custom"]) .Help__example-content p { color: var(--center-channel-color, var(--design-text)); @@ -8391,25 +8391,25 @@ html:not([data-design-app-theme="custom"]) .Help__example-name { } html:not([data-design-app-theme="custom"]) .Help__example-time, html:not([data-design-app-theme="custom"]) .Help__commands-header { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .Help__commands-example { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); background-color: var(--design-surface-2); box-shadow: var(--design-shadow-2); } html:not([data-design-app-theme="custom"]) .Help__commands-list { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__command-icon { background-color: rgba(var(--design-text-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__command-name { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__command-desc { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .DesktopAuthToken__main { color: var(--sidebar-header-bg); @@ -8643,7 +8643,7 @@ html:not([data-design-app-theme="custom"]) .ChannelSettingsModal__permissionsPol color: var(--button-bg); } html:not([data-design-app-theme="custom"]) .ChannelSettingsModal__permissionsPolicyError { - background-color: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08); + background-color: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.08); color: var(--error-text); } html:not([data-design-app-theme="custom"]) .ChannelSettingsModal__infoTabTitle { From 7bcd3ce7dace4902ad5cbf2b01c2583588e45397 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:41:36 +0200 Subject: [PATCH 10/14] fix(web-app-fediwall): carry the design title through the wall configuration and drop the list page's own colors design.js rewrote document.title from outside with a MutationObserver, so Fediwall's own title setting never knew the design title. wall-config.json now joins the wall title and the design title (empty and duplicate parts dropped), and Fediwall sets the page title and the appearance dialog from it; the observer is gone. The wall list page keeps only its layout and declares color-scheme, so the injected layer supplies the palette and the page without design uses the browser's system colors; this also removes the 4.10:1 hover contrast recorded in TODO.md. The spec checks the served configuration, the page title and the dialog field, gets a separate icons test, and fails on any color literal in the list page's own style blocks. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-fediwall/TODO.md | 1 - .../files/playwright/test-design.js | 94 +++++++++++-------- roles/web-app-fediwall/templates/design.js.j2 | 14 --- .../web-app-fediwall/templates/index.html.j2 | 11 +-- .../templates/wall-config.json.j2 | 2 +- roles/web-app-fediwall/vars/main.yml | 2 + 6 files changed, 62 insertions(+), 62 deletions(-) diff --git a/roles/web-app-fediwall/TODO.md b/roles/web-app-fediwall/TODO.md index e3a003fd9c..5ccc706b21 100644 --- a/roles/web-app-fediwall/TODO.md +++ b/roles/web-app-fediwall/TODO.md @@ -2,4 +2,3 @@ - Implement multi-wall runtime upload behind OIDC (archived req 016). - The wall list page (more than one wall) links `./favicon.png` at the site root in `templates/index.html.j2`, and `https:///favicon.png` answers 404 because `tasks/01_manager_ops.yml` stages the icons per wall slug only. Options: stage the three icons at the root too, or link the icon of the first wall. -- The wall list page without the `design` service shows a hovered wall link in `#1a73e8` on `#f0f4ff`, contrast 4.10:1 where text needs 4.5:1. Cause: the color literals in `templates/index.html.j2`. Options: darken the link literal, or lighten the hover background. diff --git a/roles/web-app-fediwall/files/playwright/test-design.js b/roles/web-app-fediwall/files/playwright/test-design.js index 32ace848f4..9da9974dd4 100644 --- a/roles/web-app-fediwall/files/playwright/test-design.js +++ b/roles/web-app-fediwall/files/playwright/test-design.js @@ -9,16 +9,12 @@ const { galleryEnabled, tokenValue, } = require("./design"); -const { decodeDotenvQuotedValue, gotoOnion } = require("./personas"); +const { apiGetOnion, decodeDotenvQuotedValue, gotoOnion } = require("./personas"); const { skipUnlessServiceEnabled } = require("./service-gating"); const { resolveTimeout } = require("./timeouts"); -const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || ""); -const faviconIcoUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL || ""); -const faviconPngUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_PNG_URL || ""); -const faviconSvgUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_SVG_URL || ""); - const MODES = ["light", "dark"]; +const INJECTED_SNIPPETS = /[\s\S]*?/g; const SOURCE = "social.example"; const QUIET_SOURCE = "quiet.example"; const BROKEN_SOURCE = "down.example"; @@ -347,43 +343,49 @@ exports.register = function (shared) { await page.emulateMedia({ colorScheme: null }); }); - test("design: the wall shows the generated icons and the configured title", async ({ page }) => { + test("design: the wall takes the configured title through its own configuration", async ({ page, request }) => { skipUnlessServiceEnabled("design"); - test.skip(!faviconIcoUrl && !title, "logo and title replacement are disabled for this role"); + const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || ""); + test.skip(!title, "title replacement is disabled for this role"); + const response = await apiGetOnion(request, `${wall}wall-config.json`); + expect(response.ok(), "the wall configuration must be served").toBe(true); + const configured = (await response.json()).title; + expect(configured.split(" - "), "the wall configuration carries the design title").toContain(title); await openWall(page); - if (title) { - await expect - .poll(async () => (await page.title()).split(title).length - 1, { - message: "the title of the wall must carry the configured title exactly once", - }) - .toBe(1); - } - if (faviconIcoUrl) { - const icons = [ - ["image/png", faviconPngUrl], - ["image/svg+xml", faviconSvgUrl], - ["image/vnd.microsoft.icon", faviconIcoUrl], - ]; - for (const [type, url] of icons) { - await expect(page.locator(`link[rel~='icon'][type='${type}']`)).toHaveAttribute("href", url); - expect( - await page.evaluate( - (src) => - new Promise((resolve) => { - const image = new Image(); - image.onload = () => resolve(image.naturalWidth > 0); - image.onerror = () => resolve(false); - image.src = src; - }), - url, - ), - `the page must be allowed to load ${url}`, - ).toBe(true); - } + await expect(page).toHaveTitle(configured); + await openDialog(page, "appearance"); + await expect(page.locator(TITLE_FIELD)).toHaveValue(configured); + }); + + test("design: the wall shows the generated icons", async ({ page }) => { + skipUnlessServiceEnabled("design"); + const faviconIcoUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL || ""); + test.skip(!faviconIcoUrl, "logo replacement is disabled for this role"); + const icons = [ + ["image/png", decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_PNG_URL || "")], + ["image/svg+xml", decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_SVG_URL || "")], + ["image/vnd.microsoft.icon", faviconIcoUrl], + ]; + await openWall(page); + for (const [type, url] of icons) { + await expect(page.locator(`link[rel~='icon'][type='${type}']`)).toHaveAttribute("href", url); + expect( + await page.evaluate( + (src) => + new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image.naturalWidth > 0); + image.onerror = () => resolve(false); + image.src = src; + }), + url, + ), + `the page must be allowed to load ${url}`, + ).toBe(true); } }); - test("design: the wall list takes its colors from the palette", async ({ page }) => { + test("design: the wall list takes its colors from the palette", async ({ page, request }) => { skipUnlessServiceEnabled("design"); test.skip(shared.env.wallSlugs.length < 2, "a single wall is redirected to instead of listed"); await gotoOnion(page, `${shared.env.appBaseUrl}/`); @@ -398,6 +400,22 @@ exports.register = function (shared) { } await page.emulateMedia({ colorScheme: null }); await assertReadable(page, ["h1", WALL_LINK], "fediwall wall list"); + const raw = (await (await apiGetOnion(request, `${shared.env.appBaseUrl}/`)).text()).replace(INJECTED_SNIPPETS, ""); + const styles = [...raw.matchAll(/]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]).join("\n"); + const literals = await page.evaluate((css) => { + const keyword = (word) => + /^[a-z]+$/i.test(word) && + !/^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i.test(word) && + CSS.supports("color", word); + const found = []; + for (const [, name, value] of css.matchAll(/(?:^|[{;])\s*([a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?)\(/i.test(value) || value.split(/[\s,()]+/).some(keyword)) { + found.push(`${name}: ${value.trim()}`); + } + } + return found; + }, styles); + expect(literals, "the wall list page itself sets no color; the palette arrives through the injected layer").toEqual([]); }); function galleryViews() { diff --git a/roles/web-app-fediwall/templates/design.js.j2 b/roles/web-app-fediwall/templates/design.js.j2 index 94556fb493..3eb04b4ca2 100644 --- a/roles/web-app-fediwall/templates/design.js.j2 +++ b/roles/web-app-fediwall/templates/design.js.j2 @@ -14,20 +14,6 @@ new MutationObserver(mirror).observe(root, { attributes: true, attributeFilter: ["data-bs-theme"] }); prefersDark.addEventListener("change", mirror); mirror(); -{% if design.title is string %} - var title = {{ design.title | to_json }}; - function brandTitle() { - var current = document.title; - if (current.indexOf(title) === -1) { - document.title = current ? current + " - " + title : title; - } - } - var titleElement = document.querySelector("title"); - if (titleElement) { - new MutationObserver(brandTitle).observe(titleElement, { childList: true, characterData: true, subtree: true }); - brandTitle(); - } -{% endif %} {% if design.logo is string %} var icons = { "image/png": {{ design.urls.favicon.png | to_json }}, diff --git a/roles/web-app-fediwall/templates/index.html.j2 b/roles/web-app-fediwall/templates/index.html.j2 index bafd3c6673..4b74a76bd9 100644 --- a/roles/web-app-fediwall/templates/index.html.j2 +++ b/roles/web-app-fediwall/templates/index.html.j2 @@ -14,18 +14,13 @@ {% else %} Fediwalls on {{ DOMAIN_PRIMARY }} + diff --git a/roles/web-app-fediwall/templates/wall-config.json.j2 b/roles/web-app-fediwall/templates/wall-config.json.j2 index 04fd09b83f..c5ed77d6dc 100644 --- a/roles/web-app-fediwall/templates/wall-config.json.j2 +++ b/roles/web-app-fediwall/templates/wall-config.json.j2 @@ -23,7 +23,7 @@ "limit": {{ wall.config.limit | to_json }}, "interval": {{ wall.config.interval | to_json }}, - "title": {{ wall.config.title | to_json }}, + "title": {{ ([wall.config.title, FEDIWALL_DESIGN_TITLE] | select | unique | join(' - ')) | to_json }}, "theme": {{ wall.config.theme | to_json }}, "showInfobar": {{ wall.config.showInfobar | to_json }}, "showText": {{ wall.config.showText | to_json }}, diff --git a/roles/web-app-fediwall/vars/main.yml b/roles/web-app-fediwall/vars/main.yml index 124bc081eb..27ef661f78 100644 --- a/roles/web-app-fediwall/vars/main.yml +++ b/roles/web-app-fediwall/vars/main.yml @@ -15,6 +15,8 @@ FEDIWALL_UPSTREAM_VERSION: "{{ lookup('config', application_id, 'services.' ~ en FEDIWALL_UPSTREAM_TARBALL_URL: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.upstream.tarball_url') }}" FEDIWALL_WALLS: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.walls') }}" +FEDIWALL_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}" +FEDIWALL_DESIGN_TITLE: "{{ lookup('design', application_id).title if FEDIWALL_DESIGN_ENABLED | bool and lookup('design', application_id).title is string else '' }}" FEDIWALL_BUILD_DIR_REL: "build" FEDIWALL_BUILD_DIR_ABS: "{{ [ lookup('container', application_id, 'directories.instance'), FEDIWALL_BUILD_DIR_REL ] | path_join }}" From 130789d9b95dcddd5d7e4bad1e1225cf7a9bf100 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:45:04 +0200 Subject: [PATCH 11/14] fix(litellm): give the admin UI the corporate logo through LiteLLM's own ui_theme_config The web-app-litellm stylesheet swapped the navbar image with content: url(), so LiteLLM's own logo setting stayed empty and its UI theme page showed nothing. svc-ai-litellm now renders litellm_settings.ui_theme_config.logo_url from LITELLM_UI_LOGO_URL, the design lockup of web-app-litellm, only when that role is deployed with design and a logo; the stylesheet override is removed. The design spec asserts the served setting, the navbar src, the absence of a CSS swap, the loaded wide lockup and the value on the UI theme page; the config render test covers both the logo and the no-logo case. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/svc-ai-litellm/templates/config.yaml.j2 | 4 ++++ roles/svc-ai-litellm/vars/main.yml | 2 ++ .../files/playwright/test-design.js | 17 ++++++++++++++++- roles/web-app-litellm/templates/style.css.j2 | 3 --- .../templates/test_config_model_list.py | 17 ++++++++++++++++- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/roles/svc-ai-litellm/templates/config.yaml.j2 b/roles/svc-ai-litellm/templates/config.yaml.j2 index c4bed5dcce..8e3928222f 100644 --- a/roles/svc-ai-litellm/templates/config.yaml.j2 +++ b/roles/svc-ai-litellm/templates/config.yaml.j2 @@ -33,6 +33,10 @@ model_list:{{ ' []' if not has_backend else '' }} litellm_settings: drop_params: true +{% if LITELLM_UI_LOGO_URL | length > 0 %} + ui_theme_config: + logo_url: {{ LITELLM_UI_LOGO_URL | to_json }} +{% endif %} general_settings: master_key: os.environ/LITELLM_MASTER_KEY diff --git a/roles/svc-ai-litellm/vars/main.yml b/roles/svc-ai-litellm/vars/main.yml index a2cbbf9cef..c5e2e716d0 100644 --- a/roles/svc-ai-litellm/vars/main.yml +++ b/roles/svc-ai-litellm/vars/main.yml @@ -29,3 +29,5 @@ LITELLM_UI_ROLE: "web-app-litellm" LITELLM_UI_SSO: "{{ (LITELLM_UI_ROLE in group_names) and (lookup('sso', LITELLM_UI_ROLE, 'is_oidc_native') | bool) }}" LITELLM_UI_BASE_URL: "{{ lookup('tls', LITELLM_UI_ROLE, 'url.base') }}" LITELLM_UI_ADMIN: "{{ lookup('users', 'administrator').username }}" +LITELLM_UI_LOGO_URL: "{{ lookup('design', LITELLM_UI_ROLE).urls.logo.svg if ((LITELLM_UI_ROLE in group_names) and (lookup('config', LITELLM_UI_ROLE, 'services.design.enabled') + | bool) and (lookup('design', LITELLM_UI_ROLE).logo is string)) else '' }}" diff --git a/roles/web-app-litellm/files/playwright/test-design.js b/roles/web-app-litellm/files/playwright/test-design.js index dc089e3128..2c03f8a4e2 100644 --- a/roles/web-app-litellm/files/playwright/test-design.js +++ b/roles/web-app-litellm/files/playwright/test-design.js @@ -375,13 +375,28 @@ exports.register = function (shared) { await shared.signIn(page); if (title) await expect(page).toHaveTitle(title); if (logoUrl) { - await expect(page.locator(shared.SHELL)).toHaveCSS("content", `url("${logoUrl}")`); + const settings = await (await apiGetOnion(page.request, `${base}/get/ui_theme_settings`)).json(); + expect(settings.values.logo_url, "LiteLLM's own logo_url setting must carry the corporate lockup").toBe(logoUrl); + const brand = page.locator(shared.SHELL); + await expect(brand, "the navbar must take its logo from the setting").toHaveAttribute("src", logoUrl); + await expect(brand, "no stylesheet may swap the navbar logo").toHaveCSS("content", "normal"); + await expect + .poll(() => brand.evaluate((image) => image.complete && image.naturalWidth > 0), { message: "the lockup must load" }) + .toBe(true); + const box = await brand.boundingBox(); + expect(box.width, "the wide lockup must carry the title next to the symbol").toBeGreaterThan(2 * box.height); for (const icon of await page.locator("link[rel~='icon']").all()) { await expect(icon).toHaveAttribute("href", faviconUrl); } for (const url of [logoUrl, faviconUrl]) { expect((await apiGetOnion(page.request, url)).ok(), `${url} is published on the CDN`).toBe(true); } + await gotoOnion(page, `${base}/ui/?page=ui-theme`); + await ready(page); + await expect( + page.locator("input[placeholder='https://example.com/logo.png']"), + "the UI theme page must show the configured logo URL", + ).toHaveValue(logoUrl); } }); diff --git a/roles/web-app-litellm/templates/style.css.j2 b/roles/web-app-litellm/templates/style.css.j2 index f5065564e7..70fccbd7e6 100644 --- a/roles/web-app-litellm/templates/style.css.j2 +++ b/roles/web-app-litellm/templates/style.css.j2 @@ -544,9 +544,6 @@ button[id^="headlessui-listbox-button"] { color: var(--design-text); } {% if design.logo is string %} -img[alt="LiteLLM Brand"] { - content: url("{{ design.urls.logo.svg }}"); -} {{ sign_in }} .logo { height: 3rem; font-size: 0; diff --git a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py index 3f21f05fe5..b5a3eee1b1 100644 --- a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py +++ b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py @@ -9,6 +9,7 @@ template is rendered here rather than read. from __future__ import annotations +import json import unittest import yaml @@ -64,19 +65,21 @@ def _remote(keys): ] -def render(*, ollama=(), lmstudio=(), keys=None): +def render(*, ollama=(), lmstudio=(), keys=None, logo_url=""): """The rendered config as a parsed mapping. Args: ollama: preload entries svc-ai-ollama declares; empty means not deployed. lmstudio: preload entries svc-ai-lmstudio declares; empty means not deployed. keys: env-name -> provider key for the remote backends. + logo_url: LITELLM_UI_LOGO_URL; empty while the admin UI shows no corporate logo. """ env = Environment( undefined=StrictUndefined, autoescape=select_autoescape(default_for_string=False), ) env.filters["bool"] = _ansible_bool + env.filters["to_json"] = json.dumps rendered = env.from_string(read_text(str(TEMPLATE))).render( LITELLM_OLLAMA_BACKEND=str(bool(ollama)), LITELLM_LMSTUDIO_BACKEND=str(bool(lmstudio)), @@ -86,6 +89,7 @@ def render(*, ollama=(), lmstudio=(), keys=None): LITELLM_MAX_OUTPUT_TOKENS=512, LITELLM_UPSTREAM_TIMEOUT=60, LITELLM_REMOTE_BACKENDS=_remote(keys or {}), + LITELLM_UI_LOGO_URL=logo_url, ) return yaml.safe_load( rendered @@ -140,6 +144,17 @@ class TestBothBackends(unittest.TestCase): ) +class TestUiTheme(unittest.TestCase): + def test_a_logo_url_reaches_the_ui_theme_config(self) -> None: + url = "https://cdn.example/roles/web-app-litellm/latest/img/design/logo.svg" + settings = render(logo_url=url)["litellm_settings"] + self.assertEqual(settings["ui_theme_config"], {"logo_url": url}) + self.assertTrue(settings["drop_params"]) + + def test_without_a_logo_url_the_ui_theme_config_is_absent(self) -> None: + self.assertNotIn("ui_theme_config", render()["litellm_settings"]) + + class TestNoBackend(unittest.TestCase): def test_the_model_list_is_empty(self) -> None: self.assertEqual(render()["model_list"], []) From 64ce35635ae240510371950a74a02a4bbb8f34b8 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:46:52 +0200 Subject: [PATCH 12/14] fix(web-app-matomo): draw the administration's row dividers and primary table actions from tokens and assert Matomo's own theme setting Row dividers of the administration tables kept Matomo's own surface tone instead of --design-border, and the first action of a table action bar (Create report) kept its own text color. Two theme.less rules scoped to the InfinitoDesign theme map them onto --design-border and --design-on-primary. The spec now switches the theme mode through Matomo's personal settings form against the opposite browser scheme, checks that the palette follows and that the choice is stored with the account, resets the mode to auto through the API, and asserts menu, tables, cards and primary actions of the users and email report pages in both modes; the color-scheme test drops its DOM-only theme flips. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-matomo/files/design/theme.less | 9 ++ .../files/playwright/test-design.js | 128 +++++++++++++++++- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/roles/web-app-matomo/files/design/theme.less b/roles/web-app-matomo/files/design/theme.less index 58b8c56fcf..3c07cc04a9 100644 --- a/roles/web-app-matomo/files/design/theme.less +++ b/roles/web-app-matomo/files/design/theme.less @@ -197,3 +197,12 @@ div.dataTableVizAllColumns tr.subDataTable > td > span.label::before { #loginPage .hints { color: var(--design-text-muted); } + +[data-theme-mode]:root table.entityTable tbody tr td { + border-bottom-color: var(--design-border) !important; +} + +[data-theme-mode]:root .tableActionBar button:first-child:not(:disabled):not([disabled]), +[data-theme-mode]:root .tableActionBar a:first-child { + color: var(--design-on-primary); +} diff --git a/roles/web-app-matomo/files/playwright/test-design.js b/roles/web-app-matomo/files/playwright/test-design.js index d8494fcd8b..d61b15a9f3 100644 --- a/roles/web-app-matomo/files/playwright/test-design.js +++ b/roles/web-app-matomo/files/playwright/test-design.js @@ -7,11 +7,12 @@ const { assertToken, captureDesignGallery, galleryEnabled, + tokenValue, } = require("./design"); const { apiFetchOnion, apiGetOnion, gotoOnion } = require("./personas"); const { skipUnlessServiceEnabled } = require("./service-gating"); const { resolveTimeout } = require("./timeouts"); -const { appBaseUrl, loginAsAdmin, matomoApiToken } = require("./_shared"); +const { adminUsername, appBaseUrl, loginAsAdmin, matomoApiToken } = require("./_shared"); const THEME = "InfinitoDesign"; const MODES = ["light", "dark"]; @@ -23,6 +24,14 @@ const BUSY = ".widgetLoading:visible, .loadingPiwik:visible, .sparklineLoadingSk const USER = "#login_form_login"; const SUBMIT = "#login_form_submit"; const SHOWCASE_VISITS = 20; +const SETTINGS_FORM = "#userSettingsTable"; +const SETTINGS_SAVE = `${SETTINGS_FORM} .matomo-save-button input.btn`; +const USERS_TABLE = "#manageUsersTable"; +const USER_CELL = `${USERS_TABLE} tbody td#userLogin`; +const ADD_USER = ".usersManager .btn.add-new-user"; +const ADMIN_MENU = "#secondNavBar.Menu--admin"; +const ADMIN_ENTRY = `${ADMIN_MENU} .navbar > li > ul > li.active > a.item`; +const ADD_REPORT = ".tableActionBar #add-report"; function base() { return appBaseUrl.replace(/\/$/, ""); @@ -138,10 +147,119 @@ test("design: the login page and the administrator follow the browser color sche await signIn(page); await gotoOnion(page, homeUrl()); await expect(page.locator("html"), "the administrator must follow the browser").toHaveAttribute("data-theme-mode", "auto"); - await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark")); - await expect(page.locator("html"), "a dark Matomo theme mode must reach the design tokens").toHaveAttribute("data-design-theme", "dark"); - await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "auto")); - await expect(page.locator("html")).not.toHaveAttribute("data-design-theme", /.+/); +}); + +/** + * Args: + * page: Playwright page on Matomo's personal settings; picks a theme mode through the form and saves it. + * mode: "light", "dark" or "auto". + */ +async function pickThemeMode(page, mode) { + await shown(page, SETTINGS_FORM); + await page + .locator(`${SETTINGS_FORM} label`) + .filter({ has: page.locator(`input[name="themeMode"][value="${mode}"]`) }) + .click(); + await page.locator(SETTINGS_SAVE).click(); + await expect(page.locator("html"), `saving the personal settings applies the ${mode} theme mode`).toHaveAttribute( + "data-theme-mode", + mode, + { timeout: resolveTimeout(15_000) }, + ); +} + +test("design: Matomo's own theme setting drives the palette", async ({ page, request }) => { + skipUnlessServiceEnabled("design"); + await page.setViewportSize(DESKTOP); + const html = page.locator("html"); + const surface = () => tokenValue(page, "--design-surface-1", "background-color"); + await signIn(page); + try { + for (const [browser, picked] of [ + ["light", "dark"], + ["dark", "light"], + ]) { + await page.emulateMedia({ colorScheme: browser }); + await gotoOnion(page, adminUrl("UsersManager", "userSettings")); + await shown(page, SETTINGS_FORM); + await expect(html, "the administrator starts on the provisioned auto mode").toHaveAttribute("data-theme-mode", "auto"); + const followed = await surface(); + await pickThemeMode(page, picked); + await expect(html).toHaveAttribute("data-design-theme", picked); + await expect + .poll(surface, { message: `a ${picked} Matomo theme in a ${browser} browser must switch the tokens` }) + .not.toBe(followed); + await assertToken(page, SETTINGS_SAVE, "background-color", "--design-primary", `save button, ${picked} theme`); + await assertToken(page, SETTINGS_SAVE, "color", "--design-on-primary", `save button, ${picked} theme`); + await gotoOnion(page, homeUrl()); + await expect(html, "the theme mode is stored with the account").toHaveAttribute("data-theme-mode", picked); + await expect(html).toHaveAttribute("data-design-theme", picked); + await gotoOnion(page, adminUrl("UsersManager", "userSettings")); + await pickThemeMode(page, "auto"); + await expect(html).not.toHaveAttribute("data-design-theme", /.+/); + } + } finally { + await page.emulateMedia({ colorScheme: null }); + await apiFetchOnion(request, `${base()}/index.php`, { + method: "POST", + form: { + module: "API", + method: "UsersManager.setUserPreference", + format: "json", + userLogin: adminUsername, + preferenceName: "themeMode", + preferenceValue: "auto", + token_auth: matomoApiToken, + }, + }); + } +}); + +test("design: the administration takes menu, tables, cards and primary actions from the palette", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await page.setViewportSize(DESKTOP); + await signIn(page); + await gotoOnion(page, adminUrl("UsersManager", "index")); + await shown(page, USER_CELL); + await assertDesignTokens(page, "matomo administration"); + for (const mode of MODES) { + await page.emulateMedia({ colorScheme: mode }); + await assertToken(page, "body", "background-color", "--design-surface-1", `admin page ${mode}`); + await assertToken(page, ADMIN_MENU, "background-color", "--design-surface-2", `admin menu ${mode}`); + await assertToken(page, ADMIN_ENTRY, "color", "--design-link", `active admin entry ${mode}`); + await assertToken(page, ADMIN_ENTRY, "background-color", "--design-surface-hover", `active admin entry ${mode}`); + await assertToken(page, `${USERS_TABLE} thead th`, "background-color", "--design-surface-1", `user table head ${mode}`); + await assertToken(page, `${USERS_TABLE} thead th`, "color", "--design-text", `user table head ${mode}`); + await assertToken(page, USER_CELL, "background-color", "--design-surface-2", `user row ${mode}`); + await assertToken(page, USER_CELL, "color", "--design-text", `user row ${mode}`); + await assertToken(page, USER_CELL, "border-bottom-color", "--design-border", `user row divider ${mode}`); + await assertToken(page, ADD_USER, "background-color", "--design-primary", `add user ${mode}`); + await assertToken(page, ADD_USER, "color", "--design-on-primary", `add user ${mode}`); + } + await page.emulateMedia({ colorScheme: null }); + await assertLightAndDark(page, USER_CELL, "matomo users"); + await assertReadable( + page, + [ + `${USERS_TABLE} thead th.first`, + USER_CELL, + ADD_USER, + ADMIN_ENTRY, + { selector: `${ADMIN_MENU} .navbar > li.active > a.item`, optional: true }, + ], + "matomo users", + ); + + await gotoOnion(page, adminUrl("ScheduledReports", "index")); + await shown(page, ADD_REPORT); + for (const mode of MODES) { + await page.emulateMedia({ colorScheme: mode }); + await assertToken(page, "#entityEditContainer", "background-color", "--design-surface-2", `report card ${mode}`); + await assertToken(page, ADD_REPORT, "background-color", "--design-primary", `create report ${mode}`); + await assertToken(page, ADD_REPORT, "color", "--design-on-primary", `create report ${mode}`); + } + await page.emulateMedia({ colorScheme: null }); + await assertReadable(page, [ADD_REPORT, "#entityEditContainer .card-title"], "matomo email reports"); }); test("design: reports take surfaces, dividers and text from the palette", async ({ page, request }) => { From cb473e45dc95777f6b80716e2a0cd4162c7ac33f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 10:48:50 +0200 Subject: [PATCH 13/14] docs(requirements): tick requirement 037 for fediwall, litellm, magento, matomo, mattermost, prometheus and shopware Each role's fix is committed and proven by a closing role sync without failures and a green spec; every role except prometheus also has a negative control that failed before its fix. Prometheus needed no change: a fresh complete run closed the stale-run reason. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/requirements/037-corporate-design-overhaul.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/requirements/037-corporate-design-overhaul.md b/docs/requirements/037-corporate-design-overhaul.md index 252c0db3a2..5170e76e7a 100644 --- a/docs/requirements/037-corporate-design-overhaul.md +++ b/docs/requirements/037-corporate-design-overhaul.md @@ -64,7 +64,7 @@ Two base commits changed many roles after their last run: `6c2de017f8` split ven - [x] web-app-discourse - [x] web-app-erpnext - [x] web-app-espocrm -- [ ] web-app-fediwall: literal colors in `templates/index.html.j2:18-27`; title not set through the wall config's native title. +- [x] web-app-fediwall - [ ] web-app-fider: the logo does not reach Fider while the object store is enabled. - [ ] web-app-flowise: `env.j2`, `playwright.env.j2` and `tasks/utils/mcp.yml` changed after the last green run. - [x] web-app-friendica @@ -83,13 +83,13 @@ Two base commits changed many roles after their last run: `6c2de017f8` split ven - [x] web-app-kix - [ ] web-app-lam: title neither set nor asserted; spec and `playwright.env.j2` rewritten after the last run. - [x] web-app-listmonk -- [ ] web-app-litellm: logo swapped by CSS `content` instead of LiteLLM's `logo_url` setting. +- [x] web-app-litellm - [x] web-app-littlejs -- [ ] web-app-magento: literal `darkgrey` at `style.css.j2:5513` and `:5554`; `style.css.j2` changed by `6c2de017f8` after the last run. +- [x] web-app-magento - [x] web-app-mastodon -- [ ] web-app-matomo: Matomo's own `themeMode` preference is not exercised (the spec flips the DOM); the administration is not asserted. +- [x] web-app-matomo - [x] web-app-matrix -- [ ] web-app-mattermost: about 50 literal RGB fallbacks inside `var()` in `style.css.j2`; `style.css.j2` changed by `6c2de017f8` after the last run. +- [x] web-app-mattermost - [x] web-app-mediawiki - [x] web-app-mini-qr - [x] web-app-mobilizon @@ -111,11 +111,11 @@ Two base commits changed many roles after their last run: `6c2de017f8` split ven - [x] web-app-pixelfed - [ ] web-app-postmarks: the server HTML and the Atom feeds keep the title `Postmarks`, because Postmarks reads `site_name` only from `account.json`, which switches on fediverse mode; `design.js` sets the browser title (upstream option in `roles/web-app-postmarks/TODO.md`). - [x] web-app-pretix -- [ ] web-app-prometheus: merges of `main` changed spec, services, volumes and tasks after the last run; literal colors in the logout button of `javascript.js.j2`. +- [x] web-app-prometheus - [x] web-app-roulette-wheel - [x] web-app-seaweedfs - [x] web-app-semaphore -- [ ] web-app-shopware: literal colors (`gold`, SVG fills) in `style.css.j2`; `style.css.j2` changed by `6c2de017f8` after the last run. +- [x] web-app-shopware - [x] web-app-snipe-it - [x] web-app-socialhome - [x] web-app-sphinx From 67f6c8d7e847d61c7a9b0cf986b46600fa2e1e65 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 16 Oct 2026 11:11:47 +0200 Subject: [PATCH 14/14] fix(cache): keep the applications carrier valid across token store writes Since 778c18fb23 the carrier key holds the tokens file's mtime and size. sys-token-store changed that stat on every inclusion (00_core.yml touched the file) and on every persisted token, and nothing re-parked a carrier afterwards. Every later task's fresh worker then rendered the applications SPOT cold, 30 to 80 s each. On main fea6f31d9f (run 38812964308) seven heavy jobs logged 194 to 287 such tasks and ran out of the 20400 s budget; the same jobs before 778c18fb23 logged 65 to 83, all of them real waits. - 00_core.yml touches the store with modification_time and access_time preserved, so it only creates a missing file. In swarm the controller sees the store over NFS with attribute caching, and the touch alone made the second pass go cold without any write. - write.yml re-parks the carrier when the persist or the controller mirror changed the store. Both run once, so every host that ran the include gets the same verdict. - tasks/utils/setup/role.yml re-parks after every role on every host, so hosts that did not run the writing role recover too. Where the key still matches this serves the carried payload; measured 0.1 s per role in a local compose deploy. - The tokens file enters the key as the SHA-256 of its content, memoised per inode, mtime and size, so a touch no longer changes the key. A stat that still matches serves the memo (1.67 us against 1.41 us for the old stat signature), and a stale stat keeps key and YAML cache on the same old content instead of pairing new content with an old key. - applications_carrier opens the tokens file before it computes the key, so on the NFS-mounted controller the parked key reflects the revalidated attributes rather than a cached stat that changes a minute later. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/plugins/lookup/applications.md | 8 ++- plugins/action/applications_carrier.py | 9 ++- roles/sys-token-store/tasks/00_core.yml | 2 + roles/sys-token-store/tasks/write.yml | 6 ++ tasks/utils/setup/role.yml | 3 + .../action/test_applications_carrier.py | 42 ++++++++++++++ tests/unit/python/utils/cache/test_base.py | 43 ++++++++++++-- utils/cache/README.md | 2 +- utils/cache/base.py | 57 ++++++++++++++----- utils/cache/carrier.py | 14 ++++- 10 files changed, 161 insertions(+), 25 deletions(-) diff --git a/docs/contributing/artefact/files/plugins/lookup/applications.md b/docs/contributing/artefact/files/plugins/lookup/applications.md index c051176c84..fdca9bd70f 100644 --- a/docs/contributing/artefact/files/plugins/lookup/applications.md +++ b/docs/contributing/artefact/files/plugins/lookup/applications.md @@ -18,7 +18,7 @@ The `applications` lookup is the ONLY supported runtime entry point for merged a | `lookup('applications')` | Full merged mapping keyed by `application_id`. | | `lookup('applications', '')` | Merged entry for that application. Raises when missing. | | `lookup('applications', '', default_value)` | Merged entry, or `default_value` when the entry is missing. | -| `lookup('applications', carrier=True)` | `{"key": [...], "applications": {...}}`: the full mapping plus the cache key it is valid under. Reserved for the constructor's carrier task (see below). | +| `lookup('applications', carrier=True)` | `{"key": [...], "applications": {...}}`: the full mapping plus the cache key it is valid under. Reserved for the `applications_carrier` action (see below). | ## Data Sources ๐Ÿ“š @@ -33,9 +33,11 @@ No other source is consulted. Ansible forks a fresh worker process for every task. The in-process cache inside `get_merged_applications` therefore never survives from one task to the next, and every task that touches the lookup would render the full tree again (about 16 to 23 seconds per task on a CI runner, measured on the workspace jobs). -The constructor stage renders once through the [applications_carrier](../../../../../../plugins/action/applications_carrier.py) action and parks the result together with its cache key in the host fact `_INFINITO_APPLICATIONS_RENDERED` (the action calls `lookup('applications', carrier=True)`). Every later worker inherits that fact at fork time; `get_merged_applications` serves it whenever the key still matches and renders only when it does not (a changed `applications`, `users`, `DOMAIN_PRIMARY` or `SYSTEM_EMAIL_DOMAIN`). +The constructor stage renders once through the [applications_carrier](../../../../../../plugins/action/applications_carrier.py) action and parks the result together with its cache key in the host fact `_INFINITO_APPLICATIONS_RENDERED` (the action calls `lookup('applications', carrier=True)`). Every later worker inherits that fact at fork time; `get_merged_applications` serves it whenever the key still matches and renders only when it does not (a changed `applications`, `users`, `DOMAIN_PRIMARY`, `SYSTEM_EMAIL_DOMAIN` or `group_names`, or a changed path or content of the tokens file). -- The task sits in `tasks/stages/01_constructor.yml` after the `networks`, `design` and `service_provider` merges, after the `add_host` placements and after the token store, so the payload renders with the same `group_names`, `groups` and user records a role would see. Moving it earlier bakes stale values into the play. +- The stage-wide task sits in `tasks/stages/01_constructor.yml` after the `networks`, `design` and `service_provider` merges, after the `add_host` placements and after the token store, so the payload renders with the same `group_names`, `groups` and user records a role would see. Moving it earlier bakes stale values into the play. +- The carrier is parked again after every role in `tasks/utils/setup/role.yml` and after every persisted token in `roles/sys-token-store/tasks/write.yml`. A re-park whose key still matches serves the carried payload without rendering. +- The tokens file enters the key as the SHA-256 of its content, memoised per inode, mtime and size, so a touch keeps the key. The carrier action opens the file before it computes the key, so an NFS client keys on revalidated attributes. - The action masks its own result: the fact is the whole rendered tree with every application's credentials, so it never reaches a log regardless of `MASK_CREDENTIALS_IN_LOGS`. - User records inside the payload are the token store's state at render time. Readers of tokens captured later in the play MUST go through `lookup('users', ...)` or `lookup('config', , 'users....')`, both of which read the users cache fresh. - The carrier is private to the lookup. Consumers MUST NOT read `_INFINITO_APPLICATIONS_RENDERED`; they keep going through `lookup('applications')`, which is what makes the carrier transparent. diff --git a/plugins/action/applications_carrier.py b/plugins/action/applications_carrier.py index ce05759532..93c479ff3d 100644 --- a/plugins/action/applications_carrier.py +++ b/plugins/action/applications_carrier.py @@ -14,13 +14,17 @@ instead of rendering:: for the merge block, which it drops again at the block's end, one after the ``add_host`` tasks for the swarm bootstrap roles, parked only when ``DEPLOYMENT_MODE`` is ``swarm``, and the stage-wide one after the token store. ``merged_applications_cache_key`` reads ``group_names`` and the -tokens file's path, mtime and size, so a carrier whose groups or tokens no +tokens file's path and content digest, so a carrier whose groups or tokens no longer match is rejected and the reader renders afresh:: - name: ๐ŸงŠ Drop the merge-block carrier so later stages re-render applications_carrier: clear: true +``tasks/utils/setup/role.yml`` parks one after every role and +``roles/sys-token-store/tasks/write.yml`` one after every persisted token; +where the key still matches they serve the carried payload without rendering. + The result is masked by the action itself: the fact is the whole rendered tree, about 0.4 MB with every application's credentials, and printing it at ``-v`` is neither credential debugging nor readable. @@ -39,7 +43,7 @@ from ansible.module_utils.parsing.convert_bool import boolean from ansible.plugins.action import ActionBase from ansible.plugins.loader import lookup_loader -from utils.cache.carrier import APPLICATIONS_RENDERED_FACT +from utils.cache.carrier import APPLICATIONS_RENDERED_FACT, revalidate_tokens_file class ActionModule(ActionBase): @@ -64,6 +68,7 @@ class ActionModule(ActionBase): ) return result + revalidate_tokens_file(task_vars) lookup = lookup_loader.get( "applications", loader=self._loader, templar=self._templar ) diff --git a/roles/sys-token-store/tasks/00_core.yml b/roles/sys-token-store/tasks/00_core.yml index 7f77bcb4c0..1d27b4468f 100644 --- a/roles/sys-token-store/tasks/00_core.yml +++ b/roles/sys-token-store/tasks/00_core.yml @@ -14,6 +14,8 @@ ansible.builtin.file: path: "{{ FILE_TOKENS }}" state: touch + modification_time: preserve + access_time: preserve owner: "{{ SYS_TOKEN_STORE_OWNER }}" group: "{{ SYS_TOKEN_STORE_GROUP }}" mode: "{{ SYS_TOKEN_STORE_FILE_MODE }}" diff --git a/roles/sys-token-store/tasks/write.yml b/roles/sys-token-store/tasks/write.yml index 51c6f5190f..6ee10926a5 100644 --- a/roles/sys-token-store/tasks/write.yml +++ b/roles/sys-token-store/tasks/write.yml @@ -51,6 +51,7 @@ run_once: true no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" become: true + register: sys_token_store_persisted - name: "๐Ÿชž Mirror the store onto the controller, where every users lookup reads" ansible.builtin.copy: @@ -63,3 +64,8 @@ delegate_to: localhost run_once: true no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + register: sys_token_store_mirrored + +- name: ๐ŸงŠ Re-render the applications SPOT the new token invalidated + applications_carrier: {} + when: sys_token_store_persisted is changed or sys_token_store_mirrored is changed diff --git a/tasks/utils/setup/role.yml b/tasks/utils/setup/role.yml index b618b6195d..34b31c5fb2 100644 --- a/tasks/utils/setup/role.yml +++ b/tasks/utils/setup/role.yml @@ -6,3 +6,6 @@ - name: "๐Ÿšฟ flush handlers after {{ role_entry.app }}" ansible.builtin.meta: flush_handlers + +- name: "๐ŸงŠ Carry the applications SPOT past {{ role_entry.app }}" + applications_carrier: {} diff --git a/tests/unit/python/plugins/action/test_applications_carrier.py b/tests/unit/python/plugins/action/test_applications_carrier.py index 841b2eea44..9f69c1d1d5 100644 --- a/tests/unit/python/plugins/action/test_applications_carrier.py +++ b/tests/unit/python/plugins/action/test_applications_carrier.py @@ -56,6 +56,48 @@ class TestApplicationsCarrierActionPlugin(unittest.TestCase): self.assertEqual(variables, {"applications": {}}) self.assertTrue(kwargs["carrier"]) + def test_revalidates_the_tokens_file_before_rendering(self): + order = [] + lookup = _FakeLookup({"key": ["roles", []], "applications": {}}) + lookup.run = lambda *args, **kwargs: order.append("render") or [{}] + action = _make_action(_FakeTask()) + with ( + patch( + "plugins.action.applications_carrier.ActionBase.run", + autospec=True, + return_value={}, + ), + patch( + "plugins.action.applications_carrier.lookup_loader.get", + return_value=lookup, + ), + patch( + "plugins.action.applications_carrier.revalidate_tokens_file", + side_effect=lambda variables: order.append(("revalidate", variables)), + ), + ): + action.run(task_vars={"FILE_TOKENS": "/x/tokens.yml"}) + + self.assertEqual( + order, [("revalidate", {"FILE_TOKENS": "/x/tokens.yml"}), "render"] + ) + + def test_clear_does_not_revalidate(self): + action = _make_action(_FakeTask(args={"clear": True})) + with ( + patch( + "plugins.action.applications_carrier.ActionBase.run", + autospec=True, + return_value={}, + ), + patch( + "plugins.action.applications_carrier.revalidate_tokens_file" + ) as revalidate, + ): + action.run(task_vars={}) + + revalidate.assert_not_called() + def test_rejects_arguments(self): action = _make_action(_FakeTask(args={"foo": 1})) with ( diff --git a/tests/unit/python/utils/cache/test_base.py b/tests/unit/python/utils/cache/test_base.py index 044cd168e9..657fbbd12d 100644 --- a/tests/unit/python/utils/cache/test_base.py +++ b/tests/unit/python/utils/cache/test_base.py @@ -10,9 +10,11 @@ files; this file pins the contract of base itself. from __future__ import annotations +import os import tempfile import textwrap import unittest +import unittest.mock as mock from pathlib import Path from utils.cache import _reset_cache_for_tests, base @@ -153,11 +155,13 @@ class TestStableVariablesSignature(unittest.TestCase): class TestTokensFileSignature(unittest.TestCase): - def test_missing_file_returns_zeroed_signature(self): + def setUp(self): + base._reset() + + def test_missing_file_returns_an_empty_digest(self): with tempfile.TemporaryDirectory() as tmp: - sig = base._tokens_file_signature(Path(tmp) / "absent.yml") - self.assertEqual(sig[1], 0) - self.assertEqual(sig[2], 0) + path = Path(tmp) / "absent.yml" + self.assertEqual(base._tokens_file_signature(path), (str(path), "")) def test_signature_changes_when_file_changes(self): with tempfile.TemporaryDirectory() as tmp: @@ -168,6 +172,37 @@ class TestTokensFileSignature(unittest.TestCase): sig_after = base._tokens_file_signature(path) self.assertNotEqual(sig_before, sig_after) + def test_a_touch_keeps_the_signature(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "tokens.yml" + path.write_text("a: 1\n", encoding="utf-8") + sig_before = base._tokens_file_signature(path) + later = path.stat().st_mtime_ns + 5_000_000_000 + os.utime(path, ns=(later, later)) + self.assertEqual(base._tokens_file_signature(path), sig_before) + + def test_an_unchanged_stat_serves_the_digest_without_reading(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "tokens.yml" + path.write_text("a: 1\n", encoding="utf-8") + sig_before = base._tokens_file_signature(path) + with mock.patch.object(Path, "open", side_effect=AssertionError): + self.assertEqual(base._tokens_file_signature(path), sig_before) + + +class TestRevalidateTokensFile(unittest.TestCase): + def test_opens_the_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "tokens.yml" + path.write_text("a: 1\n", encoding="utf-8") + with mock.patch.object(Path, "open", wraps=path.open) as opened: + base._revalidate_tokens_file(path) + opened.assert_called_once_with("rb") + + def test_a_missing_file_is_ignored(self): + with tempfile.TemporaryDirectory() as tmp: + base._revalidate_tokens_file(Path(tmp) / "absent.yml") + class TestRenderWithTemplar(unittest.TestCase): """`_render_with_templar` is the single ansible-coupled symbol in diff --git a/utils/cache/README.md b/utils/cache/README.md index a4d0080e34..7e42126045 100644 --- a/utils/cache/README.md +++ b/utils/cache/README.md @@ -26,7 +26,7 @@ The layers are orthogonal: `yaml.py` caches "this file's parsed root", the `appl Both layers are process-wide in-process caches. They intentionally do NOT track on-disk file mtimes; CLI tools are short-lived and the assumption "the on-disk file does not change while my process runs" holds. The single exception is `utils.cache.yaml.dump_yaml`, which writes through and evicts the cached entry for the path it just wrote so a tool that edits a file mid-run sees the new content on the next read. -Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` starts empty in every worker. `get_merged_applications` therefore also accepts the play-scoped carrier fact `_INFINITO_APPLICATIONS_RENDERED` (payload plus cache key, parked once by the constructor stage) and serves it when the key matches. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) for the contract. +Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` starts empty in every worker. `get_merged_applications` therefore also accepts the play-scoped carrier fact `_INFINITO_APPLICATIONS_RENDERED` (payload plus cache key, parked by the constructor stage and again after every role and every persisted token) and serves it when the key matches. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) for the contract. ## Test-Only Helpers ๐Ÿงช diff --git a/utils/cache/base.py b/utils/cache/base.py index f9c2d23276..27510d8941 100644 --- a/utils/cache/base.py +++ b/utils/cache/base.py @@ -16,10 +16,12 @@ only when actually invoked. from __future__ import annotations import copy +import hashlib +import os import threading from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from plugins.filter.merge.with_defaults import ( merge_with_defaults, # noqa: F401 re-exported @@ -28,8 +30,7 @@ from utils.paths import FILE_TOKENS from . import PROJECT_ROOT, ROLES_DIR # noqa: F401 -if TYPE_CHECKING: - import os +_TOKENS_DIGESTS: dict[tuple, str] = {} try: from ansible.parsing.vault import EncryptedString as _AnsibleEncryptedString @@ -125,18 +126,46 @@ def _stable_variables_signature(variables: Mapping[str, Any] | None) -> tuple: ) -def _tokens_file_signature(path: Path) -> tuple: - """Return a cheap stat-based signature for the tokens file. +def _stat_identity(st: os.stat_result) -> tuple: + return (st.st_ino, st.st_mtime_ns, st.st_size) - The merged-users cache must invalidate whenever sys-token-store persists a - new token โ€” otherwise downstream `lookup('users', ...)` returns stale tokens - within the same play. stat() is cheap and captures in-place writes. + +def _tokens_file_signature(path: Path) -> tuple: + """Return ``(path, sha256 of the content)`` for the tokens file. + + Args: + path: the tokens file. + + Returns: + ``(str(path), "")`` when the file cannot be read. """ try: - st = path.stat() - except (FileNotFoundError, OSError): - return (str(path), 0, 0) - return (str(path), st.st_mtime_ns, st.st_size) + identity = (str(path), *_stat_identity(path.stat())) + except OSError: + return (str(path), "") + digest = _TOKENS_DIGESTS.get(identity) + if digest is None: + try: + with path.open("rb") as handle: + identity = (str(path), *_stat_identity(os.fstat(handle.fileno()))) + digest = hashlib.sha256(handle.read()).hexdigest() + except OSError: + return (str(path), "") + _TOKENS_DIGESTS[identity] = digest + return (str(path), digest) + + +def _revalidate_tokens_file(path: Path) -> None: + """Open and close the tokens file so an NFS client refreshes its attributes. + + Args: + path: the tokens file. + """ + try: + with path.open("rb"): + pass + except OSError: + return def _deep_merge(base: Any, override: Any) -> Any: @@ -287,5 +316,5 @@ def _render_with_templar( def _reset() -> None: - """No-op kept so the `data._reset_cache_for_tests` facade can call every - domain module uniformly. This module owns no cache.""" + """Clear the tokens-file digest memo.""" + _TOKENS_DIGESTS.clear() diff --git a/utils/cache/carrier.py b/utils/cache/carrier.py index dfda350ae4..aafed31415 100644 --- a/utils/cache/carrier.py +++ b/utils/cache/carrier.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any from .base import ( _cache_key, _resolve_roles_dir, + _revalidate_tokens_file, _stable_variables_signature, _tokens_file_signature, ) @@ -42,7 +43,7 @@ def merged_applications_cache_key( Returns: ``(resolved roles dir, stable variables signature extended by the - tokens file's path, mtime and size)``. + tokens file's path and content digest)``. """ from .users import _resolve_tokens_file @@ -53,6 +54,17 @@ def merged_applications_cache_key( ) +def revalidate_tokens_file(variables: Mapping[str, Any] | None) -> None: + """Refresh an NFS client's attributes of the tokens file before a carrier is keyed. + + Args: + variables: The play's variable scope the tokens file resolves from. + """ + from .users import _resolve_tokens_file + + _revalidate_tokens_file(_resolve_tokens_file(variables)) + + def _carried_applications( variables: Mapping[str, Any], cache_key: tuple[str, tuple] ) -> dict[str, Any] | None: