mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
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:
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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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} }}"
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user