mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
web-svc-css becomes web-svc-design and owns the configuration that lived in group_vars/all/13_design.yml. Consumers switch their css service key to design, and sys-front-inj-css becomes sys-front-inj-design. javascript stays an independent key; a role's optional design.js.j2 is injected together with the design and CSP-hashed like the functional script. An in-repo OKLCH lookup replaces colorscheme_generator. From design.colors.base alone it derives surfaces, text, borders, the primary and status fills with their on-colors, hover and active states and a dedicated dark palette; a unit test proves WCAG AA, and AAA for body text, across six base colors in both modes. Semantic --design-* tokens replace --color-NN-SS in every role stylesheet. The shared element styles sit in the cascade layer infinito-design, so an app's own design system always wins and takes the palette through a variable mapping in its role stylesheet or through its own theming mechanism. design.logo and design.title resolve globally from web-svc-design or per role from its design entry, and false or 0 disables the replacement. The design_assets action renders the logo lockup per slot as SVG, PNG and ICO, and the legacy title keys move into the design entries. Three roles carry the design end to end. The dashboard takes logo, favicon and title from the generator and replaces the hard-coded port-ui colors. Gitea maps its theme variables onto the tokens and mounts the generated logo and a branded start page. Nextcloud receives primary color, logo, name and slogan through occ theming and ships no stylesheet. make design-sync re-renders the static design files of a deployed app, make design-gallery captures before and after screenshots in light, dark, desktop and mobile, and make design-queue orders the roles that are due for a design pass. The i8-designer skill and docs/agents/action/design.md describe that loop. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
95 lines
3.5 KiB
Python
95 lines
3.5 KiB
Python
import io
|
|
import re
|
|
import unittest
|
|
from typing import ClassVar
|
|
|
|
from PIL import Image
|
|
|
|
from utils.design.logo import (
|
|
LAYOUT_LOGO,
|
|
LAYOUT_LOGO_STACKED,
|
|
LAYOUT_LOGO_TITLE,
|
|
LAYOUT_LOGO_TITLE_DOMAIN,
|
|
LAYOUT_TITLE,
|
|
choose_layout,
|
|
render_assets,
|
|
)
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
LOGO = str(PROJECT_ROOT / "assets" / "img" / "logo.png")
|
|
TEXT_RE = re.compile(r"<text ([^>]*)>([^<]*)</text>")
|
|
|
|
|
|
class TestChooseLayout(unittest.TestCase):
|
|
def test_slot_format_picks_the_lockup(self) -> None:
|
|
cases = {
|
|
(512, 512, False): LAYOUT_LOGO,
|
|
(320, 64, False): LAYOUT_LOGO_TITLE,
|
|
(720, 64, False): LAYOUT_LOGO_TITLE_DOMAIN,
|
|
(960, 160, False): LAYOUT_LOGO_STACKED,
|
|
(320, 64, True): LAYOUT_TITLE,
|
|
}
|
|
for (width, height, text_only), expected in cases.items():
|
|
with self.subTest(width=width, height=height, text_only=text_only):
|
|
self.assertEqual(
|
|
choose_layout(width, height, text_only, True), expected
|
|
)
|
|
|
|
def test_without_title_every_logo_slot_shows_the_logo_only(self) -> None:
|
|
self.assertEqual(choose_layout(960, 160, False, False), LAYOUT_LOGO)
|
|
|
|
|
|
class TestRenderAssets(unittest.TestCase):
|
|
SLOTS: ClassVar[dict] = {
|
|
"icon": {"width": 512, "height": 512, "text_only": False},
|
|
"header": {"width": 320, "height": 64, "text_only": False},
|
|
"banner": {"width": 960, "height": 160, "text_only": False},
|
|
"wordmark": {"width": 320, "height": 64, "text_only": True},
|
|
}
|
|
|
|
def test_every_slot_renders_png_and_svg_at_its_size(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", "#001f3f", "#ffffff", self.SLOTS
|
|
)
|
|
for name, spec in self.SLOTS.items():
|
|
with self.subTest(slot=name):
|
|
image = Image.open(io.BytesIO(assets[f"{name}.png"]))
|
|
self.assertEqual(image.size, (spec["width"], spec["height"]))
|
|
self.assertEqual(image.mode, "RGBA")
|
|
self.assertEqual(image.getpixel((0, 0))[3], 0)
|
|
svg = assets[f"{name}.svg"].decode("utf-8")
|
|
self.assertIn(f'width="{spec["width"]}" height="{spec["height"]}"', svg)
|
|
self.assertTrue(svg.endswith("</svg>"))
|
|
|
|
def test_text_carries_fill_and_outline(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", "#001f3f", "#ffffff", self.SLOTS
|
|
)
|
|
texts = TEXT_RE.findall(assets["banner.svg"].decode("utf-8"))
|
|
self.assertEqual(
|
|
[content for _, content in texts], ["Gitea", "git.example.org"]
|
|
)
|
|
for attrs, _ in texts:
|
|
self.assertIn('fill="#001f3f"', attrs)
|
|
self.assertIn('stroke="#ffffff"', attrs)
|
|
self.assertIn("textLength=", attrs)
|
|
|
|
def test_favicon_holds_every_size(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", "#001f3f", "#ffffff", self.SLOTS
|
|
)
|
|
icon = Image.open(io.BytesIO(assets["favicon.ico"]))
|
|
self.assertEqual(icon.info["sizes"], {(16, 16), (32, 32), (48, 48), (64, 64)})
|
|
|
|
def test_disabled_title_drops_text_only_slots_and_text(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, False, "git.example.org", "#001f3f", "#ffffff", self.SLOTS
|
|
)
|
|
self.assertNotIn("wordmark.png", assets)
|
|
self.assertNotIn(b"<text", assets["banner.svg"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|