fix(design): give lockup text on pages a contrasting color in both color schemes

A lockup slot on a page or panel drew its title in the light
--design-primary with a --design-on-primary outline in both color schemes.
With an orange base that is about 3:1 on the light surface, and the SVG
never changed in dark mode; the Sphinx, Jenkins and Jellyfin passes
reported it.

sys-front-inj-design now passes --design-link with a --design-surface-1
outline for light and their dark values for dark. render_assets() hands
the dark pair to every slot without frame: true, and render_svg() adds a
style rule under prefers-color-scheme: dark that overrides the fill and
stroke attributes. Slots on brand chrome keep the frame colors, and the PNG
keeps the light pair. The design_assets action requires the two new
arguments; its two callers, assets.yml and Keycloak's theme task, pass
them.

A unit test asserts the dark rule on page slots and its absence on frame
and logo-only slots; it fails when the dark pair is not handed on. In
headless Chromium an <img> of the SVG renders the title #825200 on #fff5ea
in light and #ffa500 on #140e07 in dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-12 17:48:21 +02:00
1 parent b82591ba9d
commit 63f3631567
6 files changed
+51 -6

No files matched your search

@@ -15,7 +15,7 @@ These choices are settled at requirement creation time and bound the implementat
5. **Clean, flat base style.** No gradients on form controls and tables, subtle elevation shadows, one radius scale, 4/8 px spacing, visible focus rings, form state shown by border and icon. Font is the `system-ui` stack; `design.font` stays configurable. The shared element styles sit in the cascade layer `infinito-design`, so an app's own design system always wins over them and keeps its contrast relations. Such an app takes the palette through its own configuration first: a theming configuration, then an in-house theme or custom CSS option. Its role stylesheet maps the app's variables only for what no in-house option reaches.
6. **Dark mode.** An app's native theme toggle wins. Without one, the design follows `prefers-color-scheme`. No theme toggle is injected.
7. **Logo and title override semantics.** `design.logo` defaults to `assets/img/logo.png`, `design.title` defaults to `true`, which resolves to the role title (README H1). Set in `web-svc-design`, a value applies to every role. Set in a role's own `design:` entry, it applies to that role only. `false` or `0` disables the replacement for that scope. The legacy keys `title`, `titel`, `sitename` and `site_name` are migrated, the hardcoded titles in `web-app-joomla` and `web-app-pretix` are removed, and `web-app-decidim` and `web-app-socialhome` read the resolved title instead of `defaults_*`.
8. **Logo generator.** An SVG template renders the lockup, Pillow rasterises PNG and ICO variants. The variant is computed from the target slot format: square slot shows the logo only, wide slot shows logo and title, very wide or two-line slot adds the domain, text-only slot shows the title only. The background stays transparent. The text uses palette colors with an outline so it stands out on light and dark surfaces. A slot declared with `frame: true` sits on brand-colored chrome and takes the frame text color with a frame-colored outline.
8. **Logo generator.** An SVG template renders the lockup, Pillow rasterises PNG and ICO variants. The variant is computed from the target slot format: square slot shows the logo only, wide slot shows logo and title, very wide or two-line slot adds the domain, text-only slot shows the title only. The background stays transparent. The text uses palette colors with an outline so it stands out on light and dark surfaces; the SVG of a slot on a page or panel switches to the dark palette colors under `prefers-color-scheme: dark`. A slot declared with `frame: true` sits on brand-colored chrome and takes the frame text color with a frame-colored outline.
9. **Branch and stack.** One branch `feature/designer` and one draft PR, opened once the base is approved. Exactly one compose stack runs at a time. Base changes land as base commits on the same branch. Role changes stay under `roles/<role>/` and are committed per role after approval.
10. **Verification.** Every change passes `make quality-high` and a minimal compose deploy against the running stack: `make compose-deploy apps=<role> variant=0 disable=<every disableable service except design>` (default `initialize` mode, no teardown, single pass, no `full_cycle`). CSS and branding iterations run without a redeploy through `make design-sync app=<role> variant=0` (re-renders shared CSS, role `style.css` and the generated logo assets). A changed `design.js`, task, template or `meta/` file runs through `make compose-role-sync role=<role> variant=0`, which re-runs only the app role against the running stack and renders the CSP hash in the vhost with it. A role pass runs `make test` and the lint targets of the file types it touched after its last edit, and closes with a role sync and the complete role spec; a pass that changed provisioning runs that role sync twice, and the second run reports no change for the tasks it added. `make quality-high` runs once per two or three role passes, at every merge of the main branch and before a role is committed. Each role is also asserted with one further base color, blue `#001f3f`, through `base=` on the sync targets. Swarm verification is out of scope.
11. **Review gallery.** Each role gets its own private Artifact with at least 20 views, each captured in light and dark mode and in a desktop (1440×900) and a mobile (390×844) viewport, so at least 80 screenshots of the designed state per role. The views favour detail-rich surfaces: settings, administration, forms, lists and detail pages, logged in where the app has accounts, with idempotently seeded showcase content where a list or detail page would otherwise be empty. `make design-gallery app=<role>` captures them in one spec run and copies them to `/tmp/design-gallery/<role>/`. A comparison with the app before the design is captured on request only: `before=true` adds every view with every injected snippet stripped from the document (the body filter wraps its injections in `<!--infinito-inj-->` markers). The operator approves or rejects by comment.
+6
View File
@@ -15,6 +15,8 @@ _REQUIRED = (
"domain",
"fill",
"stroke",
"dark_fill",
"dark_stroke",
"frame_fill",
"frame_stroke",
"slots",
@@ -33,6 +35,8 @@ class ActionModule(StackHostCopyAction):
domain role domain
fill text color of a slot on a page or panel
stroke text outline color of a slot on a page or panel
dark_fill dark-mode text color of a page or panel slot's SVG
dark_stroke dark-mode text outline color of a page or panel slot's SVG
frame_fill text color of a slot on brand-colored chrome
frame_stroke text outline color of a slot on brand-colored chrome
slots {name: {width, height, text_only, frame}}
@@ -59,6 +63,8 @@ class ActionModule(StackHostCopyAction):
args["domain"],
args["fill"],
args["stroke"],
args["dark_fill"],
args["dark_stroke"],
args["frame_fill"],
args["frame_stroke"],
args["slots"],
+4 -2
View File
@@ -11,8 +11,10 @@
logo: "{{ design_branding.logo }}"
title: "{{ design_branding.title }}"
domain: "{{ design_branding.domain }}"
fill: "{{ _palette.light['--design-primary'] }}"
stroke: "{{ _palette.light['--design-on-primary'] }}"
fill: "{{ _palette.light['--design-link'] }}"
stroke: "{{ _palette.light['--design-surface-1'] }}"
dark_fill: "{{ _palette.dark['--design-link'] }}"
dark_stroke: "{{ _palette.dark['--design-surface-1'] }}"
frame_fill: "{{ _palette.light['--design-on-frame'] }}"
frame_stroke: "{{ _palette.light['--design-frame'] }}"
slots: "{{ design_branding.slots }}"
@@ -24,6 +24,8 @@
domain: "{{ kc_design.domain }}"
fill: "{{ KEYCLOAK_DESIGN_ON_FRAME }}"
stroke: "{{ KEYCLOAK_DESIGN_ON_FRAME }}"
dark_fill: "{{ KEYCLOAK_DESIGN_ON_FRAME }}"
dark_stroke: "{{ KEYCLOAK_DESIGN_ON_FRAME }}"
frame_fill: "{{ KEYCLOAK_DESIGN_ON_FRAME }}"
frame_stroke: "{{ KEYCLOAK_DESIGN_ON_FRAME }}"
slots: "{{ {'brand': KEYCLOAK_DESIGN_BRAND} }}"
+22 -1
View File
@@ -48,7 +48,14 @@ class TestRenderAssets(unittest.TestCase):
"wordmark": {"width": 320, "height": 64, "text_only": True, "frame": False},
"topbar": {"width": 320, "height": 64, "text_only": False, "frame": True},
}
COLORS: ClassVar[tuple] = ("#001f3f", "#ffffff", "#fefefe", "#00152c")
COLORS: ClassVar[tuple] = (
"#001f3f",
"#ffffff",
"#7fb2ff",
"#0b1220",
"#fefefe",
"#00152c",
)
def test_every_slot_renders_png_and_svg_at_its_size(self) -> None:
assets = render_assets(
@@ -88,6 +95,20 @@ class TestRenderAssets(unittest.TestCase):
self.assertIn('stroke="#00152c"', attrs)
self.assertNotEqual(assets["topbar.png"], assets["header.png"])
def test_page_slots_switch_text_colors_in_dark_mode(self) -> None:
assets = render_assets(
LOGO, "Gitea", "git.example.org", *self.COLORS, self.SLOTS
)
dark_rule = (
"@media (prefers-color-scheme: dark){text{fill:#7fb2ff;stroke:#0b1220}}"
)
for name in ("header", "banner", "wordmark"):
with self.subTest(slot=name):
self.assertIn(dark_rule, assets[f"{name}.svg"].decode("utf-8"))
for name in ("topbar", "icon"):
with self.subTest(slot=name):
self.assertNotIn(b"<style", assets[f"{name}.svg"])
def test_favicon_holds_every_size(self) -> None:
assets = render_assets(
LOGO, "Gitea", "git.example.org", *self.COLORS, self.SLOTS
+16 -2
View File
@@ -171,6 +171,7 @@ def render_svg(
width: int,
height: int,
layout: str,
dark: tuple[str, str] | None = None,
) -> str:
"""Render one lockup as SVG with the logo embedded as PNG.
@@ -186,6 +187,8 @@ def render_svg(
width: Output width.
height: Output height.
layout: One of the ``LAYOUT_*`` constants.
dark: ``(fill, stroke)`` applied under ``prefers-color-scheme: dark``,
or ``None`` to keep one color pair in both modes.
Returns:
SVG document.
@@ -196,6 +199,10 @@ def render_svg(
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">'
]
if dark is not None and placed:
parts.append(
f"<style>@media (prefers-color-scheme: dark){{text{{fill:{dark[0]};stroke:{dark[1]}}}}}</style>"
)
if logo_img is not None:
scale = min(2.0, _SVG_RASTER_MAX / max(logo_img.width, logo_img.height))
crisp = _fit(
@@ -245,6 +252,8 @@ def render_assets(
domain: str,
fill: str,
stroke: str,
dark_fill: str,
dark_stroke: str,
frame_fill: str,
frame_stroke: str,
slots: dict[str, dict],
@@ -255,8 +264,12 @@ def render_assets(
logo_path: Logo file.
title: Title text, or ``False`` when titles are disabled.
domain: Role domain.
fill: Text color of a slot on a page or panel.
fill: Text color of a slot on a page or panel; the PNG keeps it in
both modes.
stroke: Text outline color of a slot on a page or panel.
dark_fill: Text color of a page or panel slot's SVG in dark mode.
dark_stroke: Text outline color of a page or panel slot's SVG in dark
mode.
frame_fill: Text color of a slot on brand-colored chrome.
frame_stroke: Text outline color of a slot on brand-colored chrome.
slots: ``{name: {"width", "height", "text_only", "frame"}}``.
@@ -282,6 +295,7 @@ def render_assets(
spec["height"],
layout,
)
dark = None if spec["frame"] else (dark_fill, dark_stroke)
assets[f"{name}.png"] = render_png(*args)
assets[f"{name}.svg"] = render_svg(*args).encode("utf-8")
assets[f"{name}.svg"] = render_svg(*args, dark).encode("utf-8")
return assets