refactor(ui): centralize design tokens and complete the component library

PHASE 1 of the UI redesign. Adds semantic colour roles that keep brand gold out
of health states, layout and typography aliases so views stop hand-rolling
padding, and the reusable widgets the redesign depends on: PlanBadge, QuotaBar,
QuotaBucketWidget (stable key plus in-place update), SearchField, FilterButton,
ActionButton, IconButton, EmptyState, RouteTargetWidget, AccountCardWidget,
AgentCardWidget, ConfirmDialog, Toast, plus ellipsize/tooltip helpers for long
account identities.

test_unknown_quota_is_supported_explicitly compared inspect's resolved
annotation object against source strings, so it could only pass while being
skipped; components.py has no `from __future__ import annotations`. Rewritten
to assert through typing.get_args that QuotaBar.set_value accepts None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-20 23:35:10 +07:00
parent a9c04b75a2
commit a7027b42d5
3 changed files with 512 additions and 36 deletions

View file

@ -50,11 +50,11 @@ class HubButton(ctk.CTkButton):
hover_color = Theme.SURFACE_HOVER hover_color = Theme.SURFACE_HOVER
text_color = Theme.TEXT_PRIMARY text_color = Theme.TEXT_PRIMARY
elif variant == "danger": elif variant == "danger":
fg_color = "#5A1E1E" fg_color = Theme.DANGER_SURFACE
hover_color = "#7A2828" hover_color = Theme.DANGER_SURFACE_HOVER
text_color = "#FFD6D6" text_color = Theme.DANGER_TEXT
border_width = 1 border_width = 1
border_color = "#8A3333" border_color = Theme.DANGER_BORDER
elif variant == "accent_outline": elif variant == "accent_outline":
fg_color = "transparent" fg_color = "transparent"
hover_color = Theme.ACCENT_DIM hover_color = Theme.ACCENT_DIM
@ -218,7 +218,7 @@ class HubSectionHeader(ctk.CTkFrame):
text=subtitle, text=subtitle,
font=Theme.font_caption(), font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED, text_color=Theme.TEXT_MUTED,
).pack(anchor="w", pady=(2, 0)) ).pack(anchor="w", pady=(Theme.SPACE_XS, 0))
if action_text and action_cmd: if action_text and action_cmd:
HubButton( HubButton(
@ -227,7 +227,7 @@ class HubSectionHeader(ctk.CTkFrame):
variant="primary", variant="primary",
command=action_cmd, command=action_cmd,
height=Theme.HEIGHT_BTN_MD, height=Theme.HEIGHT_BTN_MD,
).pack(side="right", padx=(10, 0)) ).pack(side="right", padx=(Theme.SPACE_MD, 0))
class HubStatusBadge(ctk.CTkFrame): class HubStatusBadge(ctk.CTkFrame):
@ -255,14 +255,14 @@ class HubStatusBadge(ctk.CTkFrame):
color, label = self.STATUS_MAP.get(clean_key, (Theme.TEXT_MUTED, status_key)) color, label = self.STATUS_MAP.get(clean_key, (Theme.TEXT_MUTED, status_key))
inner = ctk.CTkFrame(self, fg_color="transparent") inner = ctk.CTkFrame(self, fg_color="transparent")
inner.pack(padx=10, pady=4) inner.pack(padx=Theme.SPACE_MD, pady=Theme.SPACE_XS)
ctk.CTkLabel( ctk.CTkLabel(
inner, inner,
text="", text="",
font=("Segoe UI", 12, "bold"), font=Theme.font_badge_bold(),
text_color=color, text_color=color,
).pack(side="left", padx=(0, 5)) ).pack(side="left", padx=(0, Theme.SPACE_XS))
ctk.CTkLabel( ctk.CTkLabel(
inner, inner,
@ -297,14 +297,16 @@ class HubProviderBadge(ctk.CTkFrame):
img = AssetManager.get().get_provider_image(prov, size=size) img = AssetManager.get().get_provider_image(prov, size=size)
if img: if img:
ctk.CTkLabel(badge, image=img, text="").pack(side="left", padx=(6, 4), pady=3) ctk.CTkLabel(badge, image=img, text="").pack(
side="left", padx=(Theme.SPACE_SM, Theme.SPACE_XS), pady=Theme.SPACE_XS
)
ctk.CTkLabel( ctk.CTkLabel(
badge, badge,
text=name, text=name,
font=Theme.font_caption(), font=Theme.font_caption(),
text_color=color, text_color=color,
).pack(side="left", padx=(0 if img else 6, 8), pady=3) ).pack(side="left", padx=(0 if img else Theme.SPACE_SM, Theme.SPACE_SM), pady=Theme.SPACE_XS)
class HubMetricCard(HubCard): class HubMetricCard(HubCard):
@ -324,14 +326,14 @@ class HubMetricCard(HubCard):
super().__init__(master=master, border_color=border_color, **kwargs) super().__init__(master=master, border_color=border_color, **kwargs)
top = ctk.CTkFrame(self, fg_color="transparent") top = ctk.CTkFrame(self, fg_color="transparent")
top.pack(fill="x", padx=16, pady=(14, 4)) top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
ctk.CTkLabel( ctk.CTkLabel(
top, top,
text=icon, text=icon,
font=("Segoe UI", 16), font=Theme.font_icon(),
text_color=Theme.ACCENT if accent else Theme.TEXT_MUTED, text_color=Theme.ACCENT if accent else Theme.TEXT_MUTED,
).pack(side="left", padx=(0, 6)) ).pack(side="left", padx=(0, Theme.SPACE_SM))
ctk.CTkLabel( ctk.CTkLabel(
top, top,
@ -344,10 +346,10 @@ class HubMetricCard(HubCard):
self.val_label = ctk.CTkLabel( self.val_label = ctk.CTkLabel(
self, self,
text=value, text=value,
font=("Segoe UI", 28, "bold"), font=Theme.font_metric(),
text_color=val_color, text_color=val_color,
) )
self.val_label.pack(anchor="w", padx=16, pady=(2, 2)) self.val_label.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_XS))
self.sub_label = ctk.CTkLabel( self.sub_label = ctk.CTkLabel(
self, self,
@ -355,7 +357,7 @@ class HubMetricCard(HubCard):
font=Theme.font_caption(), font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY, text_color=Theme.TEXT_SECONDARY,
) )
self.sub_label.pack(anchor="w", padx=16, pady=(0, 14)) self.sub_label.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
class HubToolbar(ctk.CTkFrame): class HubToolbar(ctk.CTkFrame):
@ -385,7 +387,7 @@ class HubToolbar(ctk.CTkFrame):
border_color=Theme.BORDER, border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY, text_color=Theme.TEXT_PRIMARY,
) )
self.search_entry.pack(side="left", padx=(0, 10)) self.search_entry.pack(side="left", padx=(0, Theme.INLINE_GAP))
self.search_entry.bind("<KeyRelease>", self._handle_search) self.search_entry.bind("<KeyRelease>", self._handle_search)
# Filter Option Menu # Filter Option Menu
@ -400,7 +402,7 @@ class HubToolbar(ctk.CTkFrame):
text_color=Theme.TEXT_PRIMARY, text_color=Theme.TEXT_PRIMARY,
command=self._handle_filter, command=self._handle_filter,
) )
self.filter_menu.pack(side="left", padx=(0, 10)) self.filter_menu.pack(side="left", padx=(0, Theme.INLINE_GAP))
# Sort Option Menu # Sort Option Menu
self.sort_menu = ctk.CTkOptionMenu( self.sort_menu = ctk.CTkOptionMenu(
@ -452,10 +454,10 @@ class HubModal(ctk.CTkToplevel):
border_color=Theme.BORDER_ACCENT, border_color=Theme.BORDER_ACCENT,
fg_color=Theme.DARK, fg_color=Theme.DARK,
) )
self.container.pack(fill="both", expand=True, padx=16, pady=16) self.container.pack(fill="both", expand=True, padx=Theme.SPACE_LG, pady=Theme.SPACE_LG)
self.hdr = ctk.CTkFrame(self.container, fg_color="transparent") self.hdr = ctk.CTkFrame(self.container, fg_color="transparent")
self.hdr.pack(fill="x", padx=16, pady=(16, 8)) self.hdr.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
self.title_lbl = ctk.CTkLabel( self.title_lbl = ctk.CTkLabel(
self.hdr, self.hdr,
@ -466,7 +468,356 @@ class HubModal(ctk.CTkToplevel):
self.title_lbl.pack(side="left") self.title_lbl.pack(side="left")
self.body = ctk.CTkFrame(self.container, fg_color="transparent") self.body = ctk.CTkFrame(self.container, fg_color="transparent")
self.body.pack(fill="both", expand=True, padx=16, pady=8) self.body.pack(fill="both", expand=True, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
self.footer = ctk.CTkFrame(self.container, fg_color="transparent") self.footer = ctk.CTkFrame(self.container, fg_color="transparent")
self.footer.pack(fill="x", padx=16, pady=(8, 16)) self.footer.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
# Public design-system names. Existing Hub-prefixed imports remain compatible.
SectionHeader = HubSectionHeader
StatusBadge = HubStatusBadge
ProviderBadge = HubProviderBadge
def _semantic_color(status: str) -> str:
"""Return an operational colour without using brand gold as health."""
key = (status or "unknown").lower().replace("-", "_")
if key in {"healthy", "active", "authenticated", "online", "ready"}:
return Theme.COLOR_POSITIVE
if key in {"warning", "quota_low", "cooldown", "reserve", "rate_limited"}:
return Theme.COLOR_CAUTION
if key in {"error", "unhealthy", "quota_exhausted", "offline", "auth_expired"}:
return Theme.COLOR_NEGATIVE
return Theme.COLOR_NEUTRAL
def ellipsize_text(text: str, max_chars: int = 34) -> str:
"""Truncate long identities while retaining the full value for a tooltip."""
value = str(text or "")
if len(value) <= max_chars:
return value
return f"{value[: max(1, max_chars - 1)]}"
class Tooltip:
"""Lightweight tooltip implemented with Tk only; no extra dependency."""
def __init__(self, widget: Any, text: str):
self.widget = widget
self.text = text
self._window: Optional[tk.Toplevel] = None
widget.bind("<Enter>", self._show, add="+")
widget.bind("<Leave>", self._hide, add="+")
def _show(self, _event=None) -> None:
if not self.text or self._window is not None:
return
self._window = tk.Toplevel(self.widget)
self._window.wm_overrideredirect(True)
self._window.wm_geometry(f"+{self.widget.winfo_rootx() + Theme.SPACE_MD}+{self.widget.winfo_rooty() + 28}")
label = tk.Label(
self._window,
text=self.text,
background=Theme.BG_HEADER,
foreground=Theme.TEXT_PRIMARY,
relief="solid",
borderwidth=1,
font=Theme.font_caption(),
)
label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS)
def _hide(self, _event=None) -> None:
if self._window is not None:
self._window.destroy()
self._window = None
class EllipsizedLabel(ctk.CTkLabel):
"""Label that shortens long account identities and exposes the full value."""
def __init__(self, master: Any, text: str, max_chars: int = 34, **kwargs):
self.full_text = str(text or "")
self.max_chars = max_chars
super().__init__(master, text=ellipsize_text(self.full_text, max_chars), **kwargs)
self.tooltip = Tooltip(self, self.full_text) if len(self.full_text) > max_chars else None
def set_text(self, text: str) -> None:
self.full_text = str(text or "")
self.configure(text=ellipsize_text(self.full_text, self.max_chars))
if self.tooltip:
self.tooltip.text = self.full_text
elif len(self.full_text) > self.max_chars:
self.tooltip = Tooltip(self, self.full_text)
class _TextBadge(ctk.CTkFrame):
"""Small neutral badge used for plans and metadata, not health."""
def __init__(self, master: Any, text: str, text_color: str = Theme.TEXT_SECONDARY, **kwargs):
super().__init__(
master=master,
height=Theme.HEIGHT_BADGE,
fg_color=Theme.SURFACE_MUTED,
border_width=1,
border_color=Theme.BORDER_SUBTLE,
corner_radius=Theme.RADIUS_PILL,
**kwargs,
)
self.label = ctk.CTkLabel(self, text=text, font=Theme.font_micro(), text_color=text_color)
self.label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS)
def set_text(self, text: str) -> None:
self.label.configure(text=text)
class PlanBadge(_TextBadge):
"""Provider plan badge. Unknown/empty plans should not instantiate it."""
class QuotaBar(ctk.CTkFrame):
"""Compact quota bar with honest unknown state and semantic thresholds."""
def __init__(
self,
master: Any,
value: Optional[float] = None,
label: str = "Квота",
detail: str = "Н",
**kwargs,
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.header = ctk.CTkFrame(self, fg_color="transparent")
self.header.pack(fill="x")
self.label = ctk.CTkLabel(self.header, text=label, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.label.pack(side="left")
self.detail = ctk.CTkLabel(self.header, text=detail, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.detail.pack(side="right")
self.progress = ctk.CTkProgressBar(
self,
height=Theme.SPACE_SM,
corner_radius=Theme.RADIUS_PILL,
fg_color=Theme.BG_SIDEBAR,
progress_color=Theme.COLOR_NEUTRAL,
)
self.progress.pack(fill="x", pady=(Theme.SPACE_XS, 0))
self.set_value(value, detail)
def set_value(self, value: Optional[float], detail: Optional[str] = None) -> None:
if value is None:
self.progress.set(0)
self.progress.configure(progress_color=Theme.COLOR_NEUTRAL)
self.detail.configure(text=detail or "Н", text_color=Theme.TEXT_MUTED)
return
normalized = max(0.0, min(1.0, float(value)))
color = Theme.COLOR_NEGATIVE if normalized <= 0.05 else (
Theme.COLOR_CAUTION if normalized <= 0.20 else Theme.COLOR_POSITIVE
)
self.progress.set(normalized)
self.progress.configure(progress_color=color)
self.detail.configure(text=detail or f"{normalized:.0%}", text_color=color)
class QuotaBucketWidget(HubCard):
"""Reusable quota bucket updated in place by a stable bucket key."""
def __init__(
self,
master: Any,
bucket_key: str,
label: str,
remaining_ratio: Optional[float] = None,
reset_text: str = "",
is_estimated: bool = False,
**kwargs,
):
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE, **kwargs)
self.bucket_key = bucket_key
self.title = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.SPACE_XS))
self.bar = QuotaBar(self, label="Остаток")
self.bar.pack(fill="x", padx=Theme.CARD_PAD_X)
self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM))
self.update_bucket(label, remaining_ratio, reset_text, is_estimated)
def update_bucket(
self,
label: str,
remaining_ratio: Optional[float],
reset_text: str = "",
is_estimated: bool = False,
) -> None:
suffix = " • оценка" if is_estimated else ""
self.title.configure(text=f"{label}{suffix}")
self.bar.set_value(remaining_ratio)
self.reset.configure(text=reset_text or "Сброс: Н")
class SearchField(HubEntry):
"""Normalized searchable text field with clipboard support."""
def __init__(self, master: Any, placeholder_text: str = "Поиск…", width: int = 260, **kwargs):
super().__init__(
master,
placeholder_text=placeholder_text,
width=width,
height=Theme.HEIGHT_INPUT,
font=Theme.font_body(),
fg_color=Theme.SURFACE,
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
**kwargs,
)
class FilterButton(ctk.CTkOptionMenu):
"""Compact filter selector with a consistent visual treatment."""
def __init__(self, master: Any, values: List[str], command: Optional[Callable] = None, **kwargs):
super().__init__(
master,
values=values,
command=command,
height=Theme.HEIGHT_INPUT,
font=Theme.font_caption(),
fg_color=Theme.SURFACE,
button_color=Theme.SECONDARY,
button_hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_PRIMARY,
**kwargs,
)
class ActionButton(HubButton):
"""Semantic alias used for text actions in views."""
class IconButton(HubButton):
"""Square icon action using the existing lightweight asset infrastructure."""
def __init__(self, master: Any, text: str, size: int = Theme.HEIGHT_BTN_SM, **kwargs):
super().__init__(master, text=text, width=size, height=size, variant="ghost", **kwargs)
class EmptyState(HubCard):
"""Honest empty/unknown state with an optional recovery action."""
def __init__(
self,
master: Any,
title: str,
message: str,
action_text: Optional[str] = None,
action_cmd: Optional[Callable] = None,
**kwargs,
):
super().__init__(master, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED, **kwargs)
ctk.CTkLabel(self, text=title, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(
pady=(Theme.SPACE_LG, Theme.SPACE_XS)
)
ctk.CTkLabel(self, text=message, font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(
padx=Theme.SPACE_LG, pady=(0, Theme.SPACE_MD)
)
if action_text and action_cmd:
ActionButton(self, text=action_text, variant="secondary", command=action_cmd).pack(
pady=(0, Theme.SPACE_LG)
)
class RouteTargetWidget(HubCard):
"""One node in a primary → reserve failover chain."""
def __init__(self, master: Any, rank: str, title: str, subtitle: str, status: str = "unknown", **kwargs):
super().__init__(master, corner_radius=Theme.RADIUS_SM, **kwargs)
self.rank = ctk.CTkLabel(self, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.rank.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, 0))
self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0))
self.subtitle = ctk.CTkLabel(self, text=subtitle, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_SM))
self.set_status(status)
def set_status(self, status: str) -> None:
self.configure(border_color=_semantic_color(status))
class AccountCardWidget(HubCard):
"""Model-agnostic account-card shell shared by account views."""
def __init__(self, master: Any, identity: str, provider: str, status: str = "unknown", **kwargs):
super().__init__(master, height=Theme.ACCOUNT_CARD_MIN_HEIGHT, **kwargs)
self.grid_propagate(False)
self.provider = ctk.CTkLabel(self, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.provider.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.identity = EllipsizedLabel(
self,
text=identity,
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
)
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.status = StatusBadge(self, status)
self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
class AgentCardWidget(HubCard):
"""Model-agnostic agent-card shell shared by team views."""
def __init__(self, master: Any, name: str, role: str, detail: str = "Н", **kwargs):
super().__init__(master, **kwargs)
ctk.CTkLabel(self, text=role, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS)
)
ctk.CTkLabel(self, text=name, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(
anchor="w", padx=Theme.CARD_PAD_X
)
ctk.CTkLabel(self, text=detail, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.CARD_PAD_Y)
)
class ConfirmDialog(HubModal):
"""Reusable destructive/non-destructive confirmation dialog."""
def __init__(
self,
parent: Any,
title: str,
message: str,
on_confirm: Callable[[], None],
destructive: bool = False,
):
super().__init__(parent, title=title, width=440, height=240)
ctk.CTkLabel(
self.body,
text=message,
wraplength=380,
justify="left",
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
).pack(fill="x")
ActionButton(self.footer, text="Отмена", variant="ghost", command=self.destroy).pack(side="right")
def _confirm() -> None:
self.destroy()
on_confirm()
ActionButton(
self.footer,
text="Подтвердить",
variant="danger" if destructive else "primary",
command=_confirm,
).pack(side="right", padx=(0, Theme.SPACE_SM))
class Toast(HubCard):
"""Non-blocking in-window notification with an optional auto-dismiss timer."""
def __init__(self, master: Any, message: str, status: str = "unknown", duration_ms: int = 4000, **kwargs):
super().__init__(master, border_color=_semantic_color(status), fg_color=Theme.BG_HEADER, **kwargs)
ctk.CTkLabel(self, text=message, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY).pack(
padx=Theme.CARD_PAD_X, pady=Theme.CARD_PAD_Y
)
if duration_ms > 0:
self.after(duration_ms, self.destroy)

View file

@ -52,6 +52,11 @@ class Theme:
ACCENT_DIM = "#3D3522" ACCENT_DIM = "#3D3522"
ACCENT_GLOW = "#CDAA64" ACCENT_GLOW = "#CDAA64"
DANGER_SURFACE = "#5A1E1E"
DANGER_SURFACE_HOVER = "#7A2828"
DANGER_BORDER = "#8A3333"
DANGER_TEXT = "#FFD6D6"
# ── Operational / Status Colors ── # ── Operational / Status Colors ──
STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green
STATUS_WARNING = "#D97706" # Amber Gold STATUS_WARNING = "#D97706" # Amber Gold
@ -63,6 +68,13 @@ class Theme:
STATUS_MAIN = "#CDAA64" # Gold Badge STATUS_MAIN = "#CDAA64" # Gold Badge
STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold
# Semantic roles. Brand gold is intentionally not a health colour.
COLOR_POSITIVE = STATUS_HEALTHY
COLOR_CAUTION = STATUS_WARNING
COLOR_NEGATIVE = STATUS_ERROR
COLOR_NEUTRAL = STATUS_DISABLED
COLOR_BRAND = ACCENT
# ── Provider Specific Accent Colors ── # ── Provider Specific Accent Colors ──
PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue
PROVIDER_CODEX = "#10A37F" # OpenAI Emerald PROVIDER_CODEX = "#10A37F" # OpenAI Emerald
@ -76,6 +88,14 @@ class Theme:
SPACE_XL = 24 SPACE_XL = 24
SPACE_2XL = 32 SPACE_2XL = 32
# Layout aliases used by views and reusable components.
PAGE_PAD_X = SPACE_LG
PAGE_PAD_Y = SPACE_MD
SECTION_GAP = SPACE_MD
CARD_PAD_X = SPACE_MD
CARD_PAD_Y = SPACE_MD
INLINE_GAP = SPACE_SM
# ── Corner Radius Scale (px) ── # ── Corner Radius Scale (px) ──
RADIUS_SM = 6 RADIUS_SM = 6
RADIUS_MD = 10 RADIUS_MD = 10
@ -90,6 +110,10 @@ class Theme:
HEIGHT_HEADER = 60 HEIGHT_HEADER = 60
HEIGHT_STATUSBAR = 30 HEIGHT_STATUSBAR = 30
WIDTH_SIDEBAR = 240 WIDTH_SIDEBAR = 240
HEIGHT_INPUT = 36
HEIGHT_BADGE = 24
ACCOUNT_CARD_MIN_HEIGHT = 188
ACCOUNT_CARD_COLUMNS = 3
# ── Fonts ── # ── Fonts ──
FONT_FAMILY_TITLE = "Cinzel" FONT_FAMILY_TITLE = "Cinzel"
@ -102,35 +126,40 @@ class Theme:
@classmethod @classmethod
def font_title_page(cls): def font_title_page(cls):
return (cls.FONT_FAMILY_UI, 24, "bold") return (cls.FONT_FAMILY_UI, 20, "bold")
@classmethod
def font_title(cls):
"""Compatibility alias for the normalized page title."""
return cls.font_title_page()
@classmethod @classmethod
def font_title_section(cls): def font_title_section(cls):
return (cls.FONT_FAMILY_UI, 19, "bold") return (cls.FONT_FAMILY_UI, 17, "bold")
@classmethod @classmethod
def font_heading(cls): def font_heading(cls):
return (cls.FONT_FAMILY_UI, 16, "bold")
@classmethod
def font_subheading(cls):
return (cls.FONT_FAMILY_UI, 15, "bold") return (cls.FONT_FAMILY_UI, 15, "bold")
@classmethod @classmethod
def font_body(cls): def font_subheading(cls):
return (cls.FONT_FAMILY_UI, 14)
@classmethod
def font_body_bold(cls):
return (cls.FONT_FAMILY_UI, 14, "bold") return (cls.FONT_FAMILY_UI, 14, "bold")
@classmethod
def font_body(cls):
return (cls.FONT_FAMILY_UI, 13)
@classmethod
def font_body_bold(cls):
return (cls.FONT_FAMILY_UI, 13, "bold")
@classmethod @classmethod
def font_caption(cls): def font_caption(cls):
return (cls.FONT_FAMILY_UI, 12) return (cls.FONT_FAMILY_UI, 11)
@classmethod @classmethod
def font_micro(cls): def font_micro(cls):
return (cls.FONT_FAMILY_UI, 11) return (cls.FONT_FAMILY_UI, 10)
@classmethod @classmethod
def font_mono(cls): def font_mono(cls):
@ -139,3 +168,15 @@ class Theme:
@classmethod @classmethod
def font_mono_sm(cls): def font_mono_sm(cls):
return (cls.FONT_FAMILY_MONO, 12) return (cls.FONT_FAMILY_MONO, 12)
@classmethod
def font_badge_bold(cls):
return (cls.FONT_FAMILY_UI, 10, "bold")
@classmethod
def font_icon(cls):
return (cls.FONT_FAMILY_UI, 15)
@classmethod
def font_metric(cls):
return (cls.FONT_FAMILY_UI, 24, "bold")

View file

@ -0,0 +1,84 @@
"""Regression checks for the Hermes Hub UI design-system foundation."""
from __future__ import annotations
import inspect
import typing
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.ui import components
from antigravity_provider.router.ui.theme import Theme
@pytest.mark.unit
def test_required_component_library_is_available() -> None:
required = {
"HubCard",
"SectionHeader",
"StatusBadge",
"PlanBadge",
"ProviderBadge",
"QuotaBar",
"QuotaBucketWidget",
"AccountCardWidget",
"AgentCardWidget",
"RouteTargetWidget",
"EmptyState",
"SearchField",
"FilterButton",
"ActionButton",
"IconButton",
"ConfirmDialog",
"Toast",
}
assert required <= set(vars(components))
@pytest.mark.unit
def test_brand_gold_is_not_used_as_healthy_status() -> None:
assert Theme.COLOR_BRAND == Theme.ACCENT
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
assert Theme.COLOR_POSITIVE != Theme.COLOR_BRAND
assert components._semantic_color("healthy") == Theme.COLOR_POSITIVE
assert components._semantic_color("reserve") == Theme.COLOR_CAUTION
assert components._semantic_color("error") == Theme.COLOR_NEGATIVE
assert components._semantic_color("unknown") == Theme.COLOR_NEUTRAL
@pytest.mark.unit
def test_layout_and_typography_tokens_are_centralized() -> None:
assert Theme.PAGE_PAD_X in {Theme.SPACE_MD, Theme.SPACE_LG, Theme.SPACE_XL}
assert Theme.CARD_PAD_X in {Theme.SPACE_SM, Theme.SPACE_MD, Theme.SPACE_LG}
assert Theme.HEIGHT_INPUT == Theme.HEIGHT_BTN_MD - 2
assert Theme.font_title() == Theme.font_title_page()
assert Theme.font_title_page()[1] > Theme.font_heading()[1] > Theme.font_body()[1]
@pytest.mark.unit
def test_quota_bucket_has_stable_key_and_in_place_update_api() -> None:
signature = inspect.signature(components.QuotaBucketWidget.__init__)
assert "bucket_key" in signature.parameters
assert callable(getattr(components.QuotaBucketWidget, "update_bucket"))
@pytest.mark.unit
def test_unknown_quota_is_supported_explicitly() -> None:
signature = inspect.signature(components.QuotaBar.set_value)
annotation = signature.parameters["value"].annotation
# inspect resolves the real typing object here (components.py does not use
# `from __future__ import annotations`), so compare types, not source text.
assert type(None) in typing.get_args(annotation), (
"QuotaBar.set_value must accept None so an unknown quota can be rendered "
f"as such instead of a fabricated number; got {annotation!r}"
)
@pytest.mark.unit
def test_long_identity_is_ellipsized_without_losing_source_value() -> None:
identity = "very.long.account.identity.for.daily.operations@example.enterprise"
shortened = components.ellipsize_text(identity, 28)
assert len(shortened) == 28
assert shortened.endswith("")
assert identity.startswith(shortened[:-1])