Files
2026-07-13 13:12:33 +08:00

231 lines
9.2 KiB
Python

import re
from pathlib import Path
CSS_DIR = Path("src/opensquilla/gateway/static/css")
COMPONENTS_CSS = Path("src/opensquilla/gateway/static/css/components.css")
VIEW_CSS_DIR = Path("src/opensquilla/gateway/static/css/views")
def _relative_luminance(hex_color: str) -> float:
channels = [int(hex_color[index : index + 2], 16) / 255 for index in (1, 3, 5)]
linear = [
channel / 12.92
if channel <= 0.03928
else ((channel + 0.055) / 1.055) ** 2.4
for channel in channels
]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _contrast_ratio(foreground: str, background: str) -> float:
lighter, darker = sorted(
(_relative_luminance(foreground), _relative_luminance(background)),
reverse=True,
)
return (lighter + 0.05) / (darker + 0.05)
def _mix_srgb(foreground: str, foreground_percent: int, background: str) -> str:
ratio = foreground_percent / 100
foreground_channels = [int(foreground[index : index + 2], 16) for index in (1, 3, 5)]
background_channels = [int(background[index : index + 2], 16) for index in (1, 3, 5)]
mixed = [
round(foreground_channel * ratio + background_channel * (1 - ratio))
for foreground_channel, background_channel in zip(
foreground_channels,
background_channels,
strict=True,
)
]
return "#" + "".join(f"{channel:02X}" for channel in mixed)
def test_non_onboarding_webui_css_does_not_use_negative_letter_spacing() -> None:
offenders: list[str] = []
for css_path in sorted(CSS_DIR.rglob("*.css")):
if css_path.name == "setup.css":
continue
for line_no, line in enumerate(css_path.read_text(encoding="utf-8").splitlines(), 1):
if (
("letter-spacing:" in line and "letter-spacing: -" in line)
or ("--track" in line and ": -" in line)
):
offenders.append(f"{css_path}:{line_no}:{line.strip()}")
assert offenders == []
def test_shared_mono_stat_values_have_room_for_glyphs() -> None:
css = COMPONENTS_CSS.read_text(encoding="utf-8")
rule = css[css.index(".stat-value.mono {") : css.index("}", css.index(".stat-value.mono {"))]
assert "line-height: 1.3" in rule
def test_header_search_inputs_keep_polished_hit_areas() -> None:
search_rules = {
"skills.css": ".sk-search-input {",
"sessions.css": ".sess-search-input {",
"cron.css": ".cron-search-input {",
"logs.css": ".lg-search-input {",
"config.css": ".cfg-search-input {",
}
for filename, selector in search_rules.items():
css = (VIEW_CSS_DIR / filename).read_text(encoding="utf-8")
rule = css[css.index(selector) : css.index("}", css.index(selector))]
match = re.search(r"min-height:\s*(\d+)px", rule)
assert match is not None, filename
assert int(match.group(1)) >= 36, filename
def test_text_entry_inputs_keep_polished_hit_areas() -> None:
component_css = COMPONENTS_CSS.read_text(encoding="utf-8")
component_rule = component_css[
component_css.index(".input {") : component_css.index("}", component_css.index(".input {"))
]
overview_css = (VIEW_CSS_DIR / "overview.css").read_text(encoding="utf-8")
overview_rule = overview_css[
overview_css.index(".ov-field__input {") : overview_css.index(
"}", overview_css.index(".ov-field__input {")
)
]
chat_css = (VIEW_CSS_DIR / "chat.css").read_text(encoding="utf-8")
chat_rule_start = chat_css.rindex(".chat-textarea {")
chat_rule = chat_css[chat_rule_start : chat_css.index("}", chat_rule_start)]
assert "min-height: 36px" in component_rule
assert "min-height: 40px" in overview_rule
assert "min-height: 40px" in chat_rule
def test_empty_state_copy_stays_inside_mobile_cards() -> None:
copy_rules = {
"components.css": ".state-text {",
"channels.css": ".ch-empty__msg {",
"sessions.css": ".sess-empty__msg {",
"cron.css": ".cron-empty__msg {",
}
for filename, selector in copy_rules.items():
css_path = COMPONENTS_CSS if filename == "components.css" else VIEW_CSS_DIR / filename
css = css_path.read_text(encoding="utf-8")
rule = css[css.index(selector) : css.index("}", css.index(selector))]
assert "width: 100%" in rule, filename
assert "max-width:" in rule, filename
assert "box-sizing: border-box" in rule, filename
assert "overflow-wrap: anywhere" in rule, filename
def test_empty_state_svg_art_boxes_drop_inline_baseline() -> None:
art_rules = {
"overview.css": (".ov-recent__empty-icon {", ".ov-recent__empty-icon svg {"),
"channels.css": (".ch-empty__art {", ".ch-empty__art svg {"),
"sessions.css": (".sess-empty__art {", ".sess-empty__art svg {"),
"cron.css": (".cron-empty__clock {", ".cron-empty__clock svg {"),
}
for filename, (box_selector, svg_selector) in art_rules.items():
css = (VIEW_CSS_DIR / filename).read_text(encoding="utf-8")
box_rule = css[css.index(box_selector) : css.index("}", css.index(box_selector))]
svg_rule = css[css.index(svg_selector) : css.index("}", css.index(svg_selector))]
assert "line-height: 1" in box_rule, filename
assert "display: block" in svg_rule, filename
def test_header_search_icons_drop_inline_svg_baseline() -> None:
css = (VIEW_CSS_DIR / "config.css").read_text(encoding="utf-8")
box_selector = ".cfg-search-icon {"
svg_selector = ".cfg-search-icon svg {"
box_rule = css[css.index(box_selector) : css.index("}", css.index(box_selector))]
svg_rule = css[css.index(svg_selector) : css.index("}", css.index(svg_selector))]
assert "line-height: 1" in box_rule
assert "display: block" in svg_rule
def test_light_theme_dim_text_meets_accessible_contrast() -> None:
css = (CSS_DIR / "base.css").read_text(encoding="utf-8")
light_start = css.index('[data-theme="light"] {')
light_rule = css[light_start : css.index("}", light_start)]
bg = re.search(r"--bg:\s*(#[0-9A-Fa-f]{6});", light_rule)
surface = re.search(r"--bg-surface:\s*(#[0-9A-Fa-f]{6});", light_rule)
dim = re.search(r"--text-dim:\s*(#[0-9A-Fa-f]{6});", light_rule)
assert bg is not None
assert surface is not None
assert dim is not None
assert _contrast_ratio(dim.group(1), bg.group(1)) >= 4.5
assert _contrast_ratio(dim.group(1), surface.group(1)) >= 4.5
def test_light_theme_status_tokens_meet_accessible_tinted_contrast() -> None:
css = (CSS_DIR / "base.css").read_text(encoding="utf-8")
light_start = css.index('[data-theme="light"] {')
light_rule = css[light_start : css.index("}", light_start)]
status_tokens = {
"ok": 10,
"warn": 14,
"danger": 14,
"info": 14,
}
elevated = re.search(r"--bg-elevated:\s*(#[0-9A-Fa-f]{6});", light_rule)
assert elevated is not None
for token, tint in status_tokens.items():
color = re.search(rf"--{token}:\s*(#[0-9A-Fa-f]{{6}});", light_rule)
assert color is not None
pill_background = _mix_srgb(color.group(1), tint, elevated.group(1))
bg = re.search(r"--bg:\s*(#[0-9A-Fa-f]{6});", light_rule)
assert bg is not None
body_background = _mix_srgb(color.group(1), tint, bg.group(1))
assert _contrast_ratio(color.group(1), pill_background) >= 4.5, token
assert _contrast_ratio(color.group(1), body_background) >= 4.5, token
danger = re.search(r"--danger:\s*(#[0-9A-Fa-f]{6});", light_rule)
bg = re.search(r"--bg:\s*(#[0-9A-Fa-f]{6});", light_rule)
assert danger is not None
assert bg is not None
log_line_background = _mix_srgb(danger.group(1), 4, bg.group(1))
error_label_background = _mix_srgb(danger.group(1), 14, log_line_background)
assert _contrast_ratio(danger.group(1), error_label_background) >= 4.5
def test_dark_theme_dim_and_status_tokens_meet_accessible_contrast() -> None:
css = (CSS_DIR / "base.css").read_text(encoding="utf-8")
dark_start = css.index('[data-theme="dark"], :root {')
dark_rule = css[dark_start : css.index("}", dark_start)]
bg = re.search(r"--bg:\s*(#[0-9A-Fa-f]{6});", dark_rule)
surface = re.search(r"--bg-surface:\s*(#[0-9A-Fa-f]{6});", dark_rule)
elevated = re.search(r"--bg-elevated:\s*(#[0-9A-Fa-f]{6});", dark_rule)
dim = re.search(r"--text-dim:\s*(#[0-9A-Fa-f]{6});", dark_rule)
assert bg is not None
assert surface is not None
assert elevated is not None
assert dim is not None
assert _contrast_ratio(dim.group(1), bg.group(1)) >= 4.5
assert _contrast_ratio(dim.group(1), surface.group(1)) >= 4.5
assert _contrast_ratio(dim.group(1), elevated.group(1)) >= 4.5
for token in ("danger", "info"):
color = re.search(rf"--{token}:\s*(#[0-9A-Fa-f]{{6}});", dark_rule)
assert color is not None
tinted_background = _mix_srgb(color.group(1), 14, elevated.group(1))
assert _contrast_ratio(color.group(1), tinted_background) >= 4.5, token
def test_primary_buttons_use_theme_contrast_token() -> None:
css = COMPONENTS_CSS.read_text(encoding="utf-8")
rule = css[css.index(".btn--primary {") : css.index("}", css.index(".btn--primary {"))]
assert "color: var(--accent-foreground)" in rule