feat(web-app-openproject): finish the corporate design and lock the seeded admin

templates/style.css.j2 now covers the whole interface: the header is the
brand frame, page, text and primary action take the palette, focus
indicators use the frame text color in the header and the link color
elsewhere, and administration tiles and disabled fields leave upstream's
colors; it shows the generated logo (260x94 on frame chrome) and icon
slots meta/services.yml declares. templates/design.js.j2 mirrors a theme
picked in the interface settings, and with design on templates/env.j2
sets the application title and makes "sync with OS" the default theme,
so accounts without a stored theme follow the browser.

OpenProject seeds an upstream account admin/admin. templates/env.j2 sets
OPENPROJECT_SEED__ADMIN__USER__LOCKED and tasks/04_admin.yml runs
files/ruby/lock_seeded_admin.rb, which locks that account unless its
login is the configured administrator; files/playwright/
test-seeded-admin.js proves the published password no longer signs in
with sso off.

files/playwright/test-design.js checks tokens, the browser-following
default, header frame and palette, focus colors, login page,
administration tiles, a picked theme, logo and title, every image the
stylesheet references, and captures the gallery. README.md gains the
resource links; TODO.md records two credentials in container exec argv
and the tasks that report changed on every deploy.

Verified green on the compose stack (15 passed); only README.md and
TODO.md changed after that run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-13 13:10:12 +02:00
1 parent 0b428367fd
commit ccbf8f168d
12 files changed
+540 -7

No files matched your search

+5
View File
@@ -143,6 +143,11 @@ docker run --rm -it \
To inspect and modify live settings inside the container, open a shell and use the Rails console for full LDAP and SMTP configuration.
## Further Resources
- [OpenProject Official Website](https://www.openproject.org/)
- [Corporate design review: screenshots in light, dark, desktop and mobile](https://claude.ai/artifact/Gg9254eTLjegtqzMKvb8gD)
## Persona contract opt-outs
The `administrator` persona is **not** blocked: [tasks/04_admin.yml](./tasks/04_admin.yml) seeds a native OpenProject account whose password is the Keycloak administrator secret, so the journey runs in every variant. The `biber` persona is blocked only in the `sso: false` variants — OpenProject is seeded with the administrator alone, and `biber` is auto-provisioned exclusively through the oauth2-proxy trusted-header bridge onto the LDAP auth source, which those variants remove. Both flags are declared explicitly in [templates/playwright.env.j2](./templates/playwright.env.j2).
+3 -1
View File
@@ -1,4 +1,6 @@
# Todo
- Finish corporate CSS implementation
- Implement RBAC via LDAP
- `tasks/04_admin.yml:16` passes the administrator password as `-e OPENPROJECT_ADMIN_PASSWORD=<value>` in the `argv` of `container exec`, so a `-vv` deploy log prints it and `expand_argument_vars` rewrites a `$NAME` sequence inside it; forward it as `-e OPENPROJECT_ADMIN_PASSWORD` plus the task's `environment:`. The same task reports `changed` on every run; give it a `changed_when` on an output line the script prints only on a real change.
- `tasks/02_settings.yml:58` renders `OPENPROJECT_RAILS_SETTINGS` including `smtp_password` into the shell command line as `-e OPENPROJECT_RAILS_SETTINGS=<json>`; forward it as `-e OPENPROJECT_RAILS_SETTINGS` plus `environment:`, or feed the JSON on stdin.
- "Run database migrations" and "Set settings in OpenProject" report `changed` on every deploy, next to the admin task above (measured 2026-10-11 in two consecutive closing role syncs), so a deploy never shows the role idempotent. Options: a `changed_when` on output the migration and settings scripts print only when they changed something.
@@ -1,3 +1,5 @@
require("./test-baseline");
require("./test-oidc-login");
require("./test-oidc-security");
require("./test-design");
require("./test-seeded-admin");
@@ -0,0 +1,307 @@
const { test, expect } = require("@playwright/test");
const {
assertDesignTokens,
assertLightAndDark,
assertReadable,
assertToken,
captureDesignGallery,
galleryEnabled,
tokenValue,
} = require("./design");
const { apiGetOnion, decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl, performKeycloakLoginForm } = require("./personas");
const { skipUnlessServiceEnabled } = require("./service-gating");
const { resolveTimeout } = require("./timeouts");
test.use({ ignoreHTTPSErrors: true });
const base = normalizeBaseUrl(process.env.OPENPROJECT_BASE_URL || process.env.APP_BASE_URL || "").replace(/\/$/, "");
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
const ssoEnabled = (process.env.SSO_SERVICE_ENABLED || "").toLowerCase() === "true";
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || "");
const logoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGO_URL || "");
const iconUrl = decodeDotenvQuotedValue(process.env.DESIGN_ICON_URL || "");
const MODES = ["light", "dark"];
const HEADER = "header.op-app-header";
const LOGO = `${HEADER} .op-logo > .op-logo--link`;
const CONTENT = "#content";
const LOGIN_FORM = "#login-form";
const LOGIN_USER = "#login-form input#username";
const LOGIN_PASSWORD = "#login-form input#password";
const LOGIN_SUBMIT = "#login-form [type='submit']";
const PRIMARY = "#content .Button--primary:visible";
const THEME_SELECT = "#content form select#pref_theme";
let session = null;
function shown(selector) {
return async (page) => {
await expect(page.locator(selector).first()).toBeVisible({ timeout: resolveTimeout(10_000) });
await expect(page.locator(".op-loading-indicator:visible, .loading-indicator--background:visible")).toHaveCount(0, { timeout: resolveTimeout(10_000) });
};
}
/**
* Args:
* page: Playwright page that ends signed in as the platform administrator, through oauth2-proxy and Keycloak when sso is on and through OpenProject's own form otherwise. Later calls reuse the first session.
*/
async function signIn(page) {
if (session) {
await page.context().addCookies(session);
} else if (ssoEnabled) {
await gotoOnion(page, `${base}/`);
await expect.poll(() => page.url(), { timeout: resolveTimeout(60_000) }).toContain(`${oidcIssuerUrl}/protocol/openid-connect/auth`);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect.poll(() => page.url(), { timeout: resolveTimeout(90_000) }).toContain(base);
} else {
await gotoOnion(page, `${base}/login`);
await expect(page.locator(LOGIN_USER)).toBeEditable({ timeout: resolveTimeout(10_000) });
await page.locator(LOGIN_USER).fill(adminUsername);
await expect(page.locator(LOGIN_PASSWORD)).toBeEditable({ timeout: resolveTimeout(10_000) });
await page.locator(LOGIN_PASSWORD).fill(adminPassword);
await page.locator(LOGIN_SUBMIT).first().click();
await expect(page.locator(LOGIN_FORM)).toHaveCount(0, { timeout: resolveTimeout(30_000) });
}
await gotoOnion(page, `${base}/my/page`);
await shown(HEADER)(page);
if (!session) session = await page.context().cookies();
}
/**
* Args:
* page: signed-in Playwright page.
* theme: one of the values of OpenProject's interface theme select (light, dark, sync_with_os).
*/
async function pickTheme(page, theme) {
await gotoOnion(page, `${base}/my/interface`);
await expect(page.locator(THEME_SELECT)).toBeEditable({ timeout: resolveTimeout(10_000) });
await page.locator(THEME_SELECT).selectOption(theme);
await page.locator("#content form").filter({ has: page.locator("select#pref_theme") }).locator("[type='submit']").click();
await expect(page.locator(THEME_SELECT)).toHaveValue(theme, { timeout: resolveTimeout(10_000) });
}
test("design: corporate tokens apply in light and dark mode", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
await assertDesignTokens(page, "openproject");
});
test("design: accounts without a stored theme follow the browser instead of the light default", async ({ page }) => {
skipUnlessServiceEnabled("design");
if (!ssoEnabled) {
await gotoOnion(page, `${base}/login`);
await expect(page.locator("body"), "the instance default theme is sync with OS").toHaveAttribute("data-auto-theme-switcher-theme-value", "sync_with_os");
}
await signIn(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await gotoOnion(page, `${base}/my/page`);
await expect(page.locator("body")).toHaveAttribute("data-color-mode", mode);
}
await page.emulateMedia({ colorScheme: null });
});
test("design: header is the frame, page and text take the palette, primary action reads on-primary", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await gotoOnion(page, `${base}/projects`);
await shown(`${CONTENT} table`)(page);
await assertToken(page, HEADER, "background-color", "--design-frame", `header ${mode}`);
await assertToken(page, "body", "background-color", "--design-surface-1", `page ${mode}`);
await assertToken(page, "body", "color", "--design-text", `text ${mode}`);
await assertToken(page, PRIMARY, "background-color", "--design-primary", `primary ${mode}`);
await assertToken(page, PRIMARY, "color", "--design-on-primary", `primary text ${mode}`);
await assertToken(page, "#content table tbody tr", "border-bottom-color", "--design-border", `table row ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
await assertLightAndDark(page, CONTENT, "openproject projects");
await assertReadable(page, [`${CONTENT} h1, ${CONTENT} h2`, { selector: "#main-menu a", optional: true }], "openproject projects");
});
test("design: focus indicators take the frame text color in the header and the link color outside it", async ({ page }) => {
skipUnlessServiceEnabled("design");
await page.setViewportSize({ width: 1440, height: 900 });
await signIn(page);
await gotoOnion(page, `${base}/my/page`);
await shown(HEADER)(page);
const onFrame = await tokenValue(page, "--design-on-frame", "outline-color");
const link = await tokenValue(page, "--design-link", "outline-color");
const inHeader = () => page.evaluate((selector) => Boolean(document.activeElement.closest(selector)), HEADER);
for (let presses = 0; presses < 20 && !(await inHeader()); presses += 1) await page.keyboard.press("Tab");
let stops = 0;
for (; stops < 12 && (await inHeader()); stops += 1) {
const drawn = await page.evaluate(() => {
const style = getComputedStyle(document.activeElement);
return { color: style.outlineColor, style: style.outlineStyle, field: document.activeElement.matches("input, textarea, select") };
});
if (!drawn.field) {
expect(drawn.style, `header focus stop ${stops}: an outline must be drawn`).not.toBe("none");
expect(drawn.color, `header focus stop ${stops}: the outline takes --design-on-frame`).toBe(onFrame);
}
await page.keyboard.press("Tab");
}
expect(stops, "the header holds focus stops").toBeGreaterThan(0);
const toggler = page.locator("button.main-menu--navigation-toggler").first();
await toggler.focus();
await expect(toggler, "the sidebar toggler draws its outline in --design-link").toHaveCSS("outline-color", link);
});
test("design: the login page takes the palette", async ({ page }) => {
skipUnlessServiceEnabled("design");
test.skip(ssoEnabled, "with sso oauth2-proxy sends every request to the identity provider");
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await gotoOnion(page, `${base}/login`);
await shown(LOGIN_FORM)(page);
await assertToken(page, "body", "background-color", "--design-surface-1", `login ${mode}`);
await assertToken(page, LOGIN_FORM, "background-color", "--design-surface-2", `login card ${mode}`);
await assertToken(page, LOGIN_SUBMIT, "background-color", "--design-primary", `login submit ${mode}`);
await assertToken(page, LOGIN_SUBMIT, "color", "--design-on-primary", `login submit ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
await assertReadable(page, [LOGIN_SUBMIT, `${LOGIN_FORM} label`], "openproject login");
});
test("design: administration tiles and disabled fields take the palette instead of upstream literals", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await gotoOnion(page, `${base}/admin`);
await shown(".menu-blocks--container .menu-block")(page);
await assertToken(page, ".menu-blocks--container .menu-block", "background-color", "--design-surface-2", `admin tile ${mode}`);
await gotoOnion(page, `${base}/my/account`);
await shown(`${CONTENT} form`)(page);
await assertToken(page, `${CONTENT} form input.FormControl-input:is([readonly], [disabled])`, "background-color", "--design-surface-3", `read-only field ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
});
test("design: a theme picked in the interface settings is respected and mirrored", async ({ page }) => {
skipUnlessServiceEnabled("design");
await page.emulateMedia({ colorScheme: "light" });
await signIn(page);
const html = page.locator("html");
const surface = () => tokenValue(page, "--design-surface-1", "background-color");
try {
await pickTheme(page, "sync_with_os");
await gotoOnion(page, `${base}/my/page`);
await expect(html, "sync with OS hands the mode to the browser").not.toHaveAttribute("data-design-theme");
const lightSurface = await surface();
await pickTheme(page, "dark");
await gotoOnion(page, `${base}/my/page`);
await expect(page.locator("body")).toHaveAttribute("data-color-mode", "dark");
await expect(html).toHaveAttribute("data-design-theme", "dark");
expect(await surface(), "a dark pick switches the tokens while the browser prefers light").not.toBe(lightSurface);
await assertToken(page, HEADER, "background-color", "--design-frame", "header with a dark pick");
} finally {
await pickTheme(page, "sync_with_os");
}
});
test("design: header shows the generated logo and the tab the configured title", async ({ page }) => {
skipUnlessServiceEnabled("design");
test.skip(!logoUrl, "logo replacement is disabled for this role");
await page.setViewportSize({ width: 1440, height: 900 });
await signIn(page);
await shown(LOGO)(page);
await expect(page.locator(LOGO)).toHaveCSS("background-image", `url("${logoUrl}")`);
const box = await page.locator(LOGO).boundingBox();
expect(box.width, "the header shows the wide lockup").toBeGreaterThan(box.height * 2);
if (title) await expect(page).toHaveTitle(new RegExp(`${title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`));
await page.setViewportSize({ width: 390, height: 844 });
await gotoOnion(page, `${base}/my/page`);
await expect(page.locator(`${HEADER} a.op-logo--icon`)).toHaveCSS("background-image", `url("${iconUrl}")`);
});
test("design: every image the role stylesheet references is served", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
const sheet = await page.locator("link[href*='/roles/web-app-openproject/'][href*='style.css']").first().getAttribute("href");
const css = await (await apiGetOnion(page.request, sheet)).text();
const targets = [...new Set([...css.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)].map((match) => match[1]))]
.filter((target) => !target.startsWith("data:"))
.map((target) => new URL(target, sheet).href);
expect(targets.length, "the role stylesheet references images").toBeGreaterThan(0);
for (const target of targets) {
const served = await apiGetOnion(page.request, target);
expect(`${served.status()} ${served.headers()["content-type"]}`, `${target} must be served as an image`).toMatch(/^200 image\//);
}
});
test("design: gallery of login, projects, work packages, my account and administration", async ({ page }) => {
skipUnlessServiceEnabled("design");
test.skip(!galleryEnabled(), "INFINITO_PLAYWRIGHT_KEEP is not true");
test.setTimeout(resolveTimeout(1_500_000));
const failures = [];
if (!ssoEnabled) {
const guest = await page.context().browser().newContext({ ignoreHTTPSErrors: true });
try {
await captureDesignGallery(await guest.newPage(), guestViews());
} catch (error) {
failures.push(error.message);
} finally {
await guest.close();
}
}
await signIn(page);
await captureDesignGallery(page, signedInViews()).catch((error) => failures.push(error.message));
expect(failures, failures.join("\n")).toEqual([]);
});
function guestViews() {
return [
{ name: "login", url: `${base}/login`, prepare: shown(LOGIN_FORM) },
{
name: "login-focus",
url: `${base}/login`,
prepare: async (page) => {
await shown(LOGIN_USER)(page);
await page.locator(LOGIN_USER).focus();
},
},
{ name: "lost-password", url: `${base}/account/lost_password`, prepare: shown(`${CONTENT} form`) },
{ name: "register", url: `${base}/account/register`, prepare: shown(`${CONTENT} form`) },
];
}
function signedInViews() {
const view = (name, path, selector) => ({ name, url: `${base}${path}`, prepare: shown(selector) });
return [
view("home", "/", CONTENT),
view("my-page", "/my/page", CONTENT),
view("projects", "/projects", `${CONTENT} table`),
view("project-overview", "/projects/demo-project", CONTENT),
view("work-packages", "/projects/demo-project/work_packages", CONTENT),
view("work-package-new", "/projects/demo-project/work_packages/new", CONTENT),
view("gantt", "/projects/demo-project/gantt", CONTENT),
view("boards", "/projects/demo-project/boards", CONTENT),
view("backlogs", "/projects/your-scrum-project/backlogs", CONTENT),
view("wiki", "/projects/demo-project/wiki", CONTENT),
view("notifications", "/notifications", CONTENT),
view("my-account", "/my/account", `${CONTENT} form`),
view("my-interface", "/my/interface", THEME_SELECT),
view("admin", "/admin", CONTENT),
view("admin-users", "/users", `${CONTENT} table`),
view("admin-general", "/admin/settings/general", `${CONTENT} form`),
view("admin-design", "/admin/design", CONTENT),
view("admin-types", "/types", `${CONTENT} table`),
view("project-settings", "/projects/demo-project/settings/general", `${CONTENT} form`),
{
name: "help-menu",
url: `${base}/my/page`,
prepare: async (page) => {
await shown(HEADER)(page);
await page.locator("#op-app-header--help-menu-button").click();
const menu = page.locator("anchored-position:popover-open [role='menu']").first();
await expect(menu).toBeVisible({ timeout: resolveTimeout(10_000) });
await expect.poll(() => menu.evaluate((element) => element.getAnimations({ subtree: true }).length), { timeout: resolveTimeout(5_000) }).toBe(0);
},
},
];
}
@@ -0,0 +1,30 @@
const { test, expect } = require("@playwright/test");
const { decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl } = require("./personas");
const { resolveTimeout } = require("./timeouts");
test.use({ ignoreHTTPSErrors: true });
const base = normalizeBaseUrl(process.env.OPENPROJECT_BASE_URL || process.env.APP_BASE_URL || "").replace(/\/$/, "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const ssoEnabled = (process.env.SSO_SERVICE_ENABLED || "").toLowerCase() === "true";
const SEEDED_LOGIN = "admin";
const SEEDED_PUBLISHED_PASSWORD = "admin";
test("seeded admin: the upstream account with the published password cannot sign in", async ({ page }) => {
test.skip(ssoEnabled, "with sso oauth2-proxy sends every request to the identity provider");
test.skip(adminUsername.toLowerCase() === SEEDED_LOGIN, "the platform administrator owns the seeded login and its password is converged");
await page.context().clearCookies();
await gotoOnion(page, `${base}/login`);
const user = page.locator("#login-form input#username");
const password = page.locator("#login-form input#password");
await expect(user).toBeEditable({ timeout: resolveTimeout(10_000) });
await user.fill(SEEDED_LOGIN);
await expect(password).toBeEditable({ timeout: resolveTimeout(10_000) });
await password.fill(SEEDED_PUBLISHED_PASSWORD);
await page.locator("#login-form [type='submit']").first().click();
await page.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) });
await expect(page.locator("#login-form"), "the sign-in form stays: the seeded account is locked").toBeVisible({ timeout: resolveTimeout(10_000) });
expect(new URL(page.url()).pathname, "no password change form opens for the seeded account").not.toContain("change_password");
});
@@ -0,0 +1,10 @@
# nocheck: mirrored-unit-test - runs in the OpenProject console against User and Setting;
# the models only exist once OpenProject has booted
platform_login = ENV.fetch("OPENPROJECT_ADMIN_LOGIN")
User.where(login: "admin", mail: Setting.seed_admin_user_mail).find_each do |user|
next if user.login.casecmp?(platform_login) || user.locked?
user.locked!
puts "Locked seeded account #{user.login}"
end
@@ -112,6 +112,14 @@ design:
bond: 1
enabled: "{{ 'web-svc-design' in group_names }}"
shared: "{{ 'web-svc-design' in group_names }}"
slots:
logo:
width: 260
height: 94
frame: true
icon:
width: 66
height: 66
prometheus:
bond: 1
enabled: "{{ 'web-app-prometheus' in group_names }}"
@@ -26,6 +26,25 @@
delegate_to: "{{ OPENPROJECT_WEB_HOST_NODE }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: 🔒 Lock the upstream-seeded admin account
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- OPENPROJECT_ADMIN_LOGIN
- "{{ OPENPROJECT_WEB_LOCAL_CID }}" # nocheck: container-exec-resolver CID resolved at delegate_to runtime; container_address targets a manager-only subshell
- bash
- -lc
- "cd /app && bundle exec rails runner -"
stdin: "{{ lookup('file', 'ruby/lock_seeded_admin.rb') }}"
environment:
OPENPROJECT_ADMIN_LOGIN: "{{ OPENPROJECT_ADMINISTRATOR_USERNAME }}"
delegate_to: "{{ OPENPROJECT_WEB_HOST_NODE }}"
register: openproject_seeded_admin_lock
changed_when: "'Locked seeded account' in openproject_seeded_admin_lock.stdout"
- name: 🔧 Link admin to LDAP auth source so trusted-header bridge can resolve it
ansible.builtin.command:
argv:
@@ -0,0 +1,25 @@
(function () {
var root = document.documentElement;
var watched = null;
var observer = new MutationObserver(mirror);
function mirror() {
var body = document.body;
if (!body) return;
var mode = body.getAttribute("data-color-mode");
if (body.getAttribute("data-auto-theme-switcher-theme-value") === "sync_with_os" || (mode !== "light" && mode !== "dark")) {
root.removeAttribute("data-design-theme");
} else if (root.getAttribute("data-design-theme") !== mode) {
root.setAttribute("data-design-theme", mode);
}
}
function bind() {
if (!document.body || document.body === watched) return mirror();
observer.disconnect();
watched = document.body;
observer.observe(watched, { attributes: true, attributeFilter: ["data-color-mode", "data-auto-theme-switcher-theme-value"] });
mirror();
}
bind();
document.addEventListener("DOMContentLoaded", bind);
document.addEventListener("turbo:load", bind);
})();
@@ -8,6 +8,12 @@ SECRET_KEY_BASE={{ lookup('config', application_id, 'secrets.credentials.secret_
OPENPROJECT_HSTS={{ lookup('tls', application_id, 'enabled') | string | lower }}
RAILS_CACHE_STORE=memcache
OPENPROJECT_CACHE__MEMCACHE__SERVER=cache:11211
OPENPROJECT_SEED__ADMIN__USER__LOCKED=true
{% if lookup('config', application_id, 'services.design.enabled') | bool %}
OPENPROJECT_APP__TITLE={{ lookup('design', application_id).label | dotenv_quote }}
OPENPROJECT_USER__DEFAULT__THEME=sync_with_os
{% endif %}
{% if OPENPROJECT_HEADER_SSO_ENABLED | bool %}
OPENPROJECT_AUTH__SOURCE__SSO_HEADER={{ OPENPROJECT_SSO_HEADER }}
@@ -10,6 +10,14 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
DESIGN_ICON_URL={{ (lookup('design', application_id).urls.icon.svg if lookup('design', application_id).logo is string else '') | dotenv_quote }}
DESIGN_LOGO_URL={{ (lookup('design', application_id).urls.logo.svg if lookup('design', application_id).logo is string else '') | dotenv_quote }}
DESIGN_SERVICE_ENABLED={{ lookup('config', application_id, 'services.design.enabled') | string | lower }}
DESIGN_TITLE={{ lookup('design', application_id).label | dotenv_quote }}
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
@@ -1,9 +1,120 @@
header.op-app-header{
background-color: var(--design-primary-800);
background: linear-gradient(135deg, var(--design-primary-800), var(--design-primary-800), var(--design-primary-800), var(--design-primary-700));
color: var(--design-primary-800);
{% set design = lookup('design', application_id) %}
{% set themed = "body:is([data-color-mode='light'][data-light-theme='light'], [data-color-mode='dark'][data-dark-theme='dark'])" %}
{{ themed }} {
--body-background: var(--design-surface-1);
--bgColor-default: var(--design-surface-1);
--main-menu-bg-color: var(--design-surface-1);
--box-list-item-bg-color: var(--design-surface-1);
--bgColor-muted: var(--design-surface-2);
--overlay-bgColor: var(--design-surface-2);
--button--background-color: var(--design-surface-2);
--button-default-bgColor-rest: var(--design-surface-2);
--control-bgColor-rest: var(--design-surface-2);
--bgColor-neutral-muted: var(--design-surface-2);
--display-gray-bgColor-muted: var(--design-surface-2);
--bgColor-inset: var(--design-surface-3);
--gray-light: var(--design-surface-3);
--bgColor-disabled: var(--design-surface-3);
--control-bgColor-disabled: var(--design-surface-3);
--button--background-hover-color: var(--design-surface-hover);
--button-default-bgColor-hover: var(--design-surface-hover);
--control-transparent-bgColor-hover: var(--design-surface-hover);
--button-default-bgColor-active: var(--design-surface-active);
--control-transparent-bgColor-active: var(--design-surface-active);
--control-transparent-bgColor-selected: var(--design-surface-active);
--drop-down-selected-bg-color: var(--design-surface-active);
--display-blue-bgColor-muted: var(--design-surface-active);
--body-font-color: var(--design-text);
--fgColor-default: var(--design-text);
--main-menu-font-color: var(--design-text);
--main-menu-selected-font-color: var(--design-text);
--button--font-color: var(--design-text);
--button-default-fgColor-rest: var(--design-text);
--fgColor-muted: var(--design-text-muted);
--accent-color: var(--design-link);
--accent-color--dark-mode: var(--design-link);
--accent-color--major1: var(--design-link);
--content-icon-color: var(--design-link);
--list-item-hover--color: var(--design-link);
--control-checked-color: var(--design-primary);
--control-checked-color--major1: var(--design-primary-hover);
--control-checked-fgColor-rest: var(--design-on-primary);
--primary-button-color: var(--design-primary);
--primary-button-color--dark-mode: var(--design-primary);
--button--primary-background-color: var(--design-primary);
--button--primary-border-color: var(--design-primary);
--primary-button-color--major1: var(--design-primary-hover);
--button--primary-background-hover-color: var(--design-primary-hover);
--button--primary-background-active-color: var(--design-primary-active);
--font-color-on-primary: var(--design-on-primary);
--button--primary-font-color: var(--design-on-primary);
--borderColor-default: var(--design-border);
--borderColor-muted: var(--design-border);
--borderColor-neutral-muted: var(--design-border);
--borderColor-attention-muted: var(--design-warning-border);
--borderColor-danger-muted: var(--design-danger-border);
--borderColor-success-muted: var(--design-success-border);
--ck-color-base-border: var(--design-border);
--ck-color-toolbar-border: var(--design-border);
--main-menu-border-color: var(--design-border);
--button--active-border-color: var(--design-border-strong);
--button-default-borderColor-rest: var(--design-border-strong);
--control-borderColor-rest: var(--design-border-strong);
--borderColor-accent-muted: var(--design-border-strong);
--borderColor-accent-emphasis: var(--design-link);
--header-bg-color: var(--design-frame);
--header-border-bottom-color: var(--design-frame);
--header-item-font-color: var(--design-on-frame);
--header-item-font-hover-color: var(--design-on-frame);
--main-menu-bg-selected-background: var(--design-primary);
--content-form-error-color: var(--design-danger);
--fgColor-danger: var(--design-danger);
--bgColor-danger-muted: var(--design-danger-subtle);
--fgColor-success: var(--design-success);
--bgColor-success-muted: var(--design-success-subtle);
--fgColor-attention: var(--design-warning);
--bgColor-attention-muted: var(--design-warning-subtle);
--enterprise-upsell-color: var(--design-warning);
--enterprise-upsell-text-color: var(--design-warning);
--focus-outlineColor: var(--design-link);
--focus-outline: 2px solid var(--design-link);
}
div#wrapper button, div#wrapper input, button.top-menu-search-button, div.menu-sidebar a{
background-color: transparent;
{{ themed }} .op-app-header {
--design-focus: var(--design-on-frame);
--focus-outlineColor: var(--design-on-frame);
--focus-outline: 2px solid var(--design-on-frame);
--control-transparent-bgColor-hover: var(--design-frame-hover);
--control-transparent-bgColor-active: var(--design-frame-active);
}
{{ themed }} .op-app-header :is([role='menu'], .Overlay) {
--design-focus: var(--design-link);
--focus-outlineColor: var(--design-link);
--focus-outline: 2px solid var(--design-link);
--control-transparent-bgColor-hover: var(--design-surface-hover);
--control-transparent-bgColor-active: var(--design-surface-active);
}
{{ themed }} :is(.op-submenu--item-action_active, .op-submenu--item-action_active-child) {
--main-menu-bg-selected-background: var(--design-surface-active);
}
{{ themed }} .menu-blocks--container .menu-block {
background: var(--design-surface-2);
}
{{ themed }} .menu-blocks--container .menu-block:hover {
background: var(--design-surface-hover);
outline-color: var(--design-border-strong);
}
{% if design.logo is string %}
{{ themed }} .op-app-header .op-logo--link {
background-image: url("{{ design.urls.logo.svg }}");
}
{{ themed }} .op-app-header :is(.op-logo--icon, .op-logo--icon_white) {
background-image: url("{{ design.urls.icon.svg }}");
}
{% endif %}