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:
parent
a9c04b75a2
commit
a7027b42d5
3 changed files with 512 additions and 36 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
84
tests/test_ui_design_system.py
Normal file
84
tests/test_ui_design_system.py
Normal 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])
|
||||||
Loading…
Reference in a new issue