| | """ |
| | ui_theme.py |
| | ----------- |
| | Miami University branded theme and styling utilities. |
| | |
| | Provides: |
| | - Gradio theme subclass (MiamiTheme) with Miami branding |
| | - Custom CSS string for elements beyond theme control |
| | - Matplotlib rcParams styled with Miami branding |
| | - ColorBrewer palette loading via palettable with graceful fallback |
| | - Color-swatch preview figure generation |
| | """ |
| |
|
| | from __future__ import annotations |
| |
|
| | import itertools |
| | from typing import Dict, List, Optional |
| |
|
| | import gradio as gr |
| | from gradio.themes.base import Base |
| | from gradio.themes.utils import colors, fonts, sizes |
| | import matplotlib.figure |
| | import matplotlib.pyplot as plt |
| |
|
| | |
| | |
| | |
| | MIAMI_RED: str = "#C41230" |
| | MIAMI_BLACK: str = "#000000" |
| | MIAMI_WHITE: str = "#FFFFFF" |
| |
|
| | |
| | _WHITE = "#FFFFFF" |
| | _BLACK = "#000000" |
| | _LIGHT_GRAY = "#F5F5F5" |
| | _BORDER_GRAY = "#E0E0E0" |
| | _DARK_TEXT = "#000000" |
| | _HOVER_RED = "#9E0E26" |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | _miami_red_palette = colors.Color( |
| | c50="#fff5f6", |
| | c100="#ffe0e4", |
| | c200="#ffc7ce", |
| | c300="#ffa3ad", |
| | c400="#ff6b7d", |
| | c500="#C41230", |
| | c600="#a30f27", |
| | c700="#850c1f", |
| | c800="#6b0a19", |
| | c900="#520714", |
| | c950="#3d0510", |
| | name="miami_red", |
| | ) |
| |
|
| |
|
| | class MiamiTheme(Base): |
| | """Gradio theme subclass with Miami University branding.""" |
| |
|
| | def __init__(self, **kwargs): |
| | super().__init__( |
| | primary_hue=_miami_red_palette, |
| | secondary_hue=colors.gray, |
| | neutral_hue=colors.gray, |
| | spacing_size=sizes.spacing_md, |
| | radius_size=sizes.radius_sm, |
| | text_size=sizes.text_md, |
| | font=( |
| | fonts.GoogleFont("Source Sans Pro"), |
| | fonts.Font("ui-sans-serif"), |
| | fonts.Font("system-ui"), |
| | fonts.Font("sans-serif"), |
| | ), |
| | font_mono=( |
| | fonts.Font("ui-monospace"), |
| | fonts.Font("SFMono-Regular"), |
| | fonts.Font("monospace"), |
| | ), |
| | **kwargs, |
| | ) |
| | super().set( |
| | |
| | button_primary_background_fill="*primary_500", |
| | button_primary_background_fill_hover="*primary_700", |
| | button_primary_text_color="white", |
| | button_primary_border_color="*primary_500", |
| | |
| | block_title_text_weight="600", |
| | block_title_text_color="*primary_500", |
| | |
| | body_text_color="*neutral_900", |
| | |
| | block_border_width="1px", |
| | block_border_color="*neutral_200", |
| | |
| | checkbox_background_color_selected="*primary_500", |
| | checkbox_border_color_selected="*primary_500", |
| | ) |
| |
|
| |
|
| | def get_miami_css() -> str: |
| | """Return custom CSS for elements that ``gr.themes.Base`` cannot control. |
| | |
| | This string is passed to ``gr.Blocks(css=...)`` alongside the |
| | :class:`MiamiTheme`. |
| | """ |
| | return f""" |
| | /* ---- Sidebar header accent ---- */ |
| | .sidebar > .panel {{ |
| | border-top: 4px solid {MIAMI_RED} !important; |
| | }} |
| | |
| | /* ---- Developer card ---- */ |
| | .dev-card {{ |
| | padding: 0; |
| | background: transparent; |
| | }} |
| | .dev-row {{ |
| | display: flex; |
| | gap: 0.5rem; |
| | align-items: flex-start; |
| | }} |
| | .dev-avatar {{ |
| | width: 28px; |
| | height: 28px; |
| | min-width: 28px; |
| | fill: {_BLACK}; |
| | }} |
| | .dev-name {{ |
| | font-weight: 600; |
| | color: {_BLACK}; |
| | font-size: 0.82rem; |
| | line-height: 1.3; |
| | }} |
| | .dev-role {{ |
| | font-size: 0.7rem; |
| | color: #6c757d; |
| | line-height: 1.3; |
| | }} |
| | .dev-links {{ |
| | display: flex; |
| | gap: 0.3rem; |
| | flex-wrap: wrap; |
| | margin-top: 0.35rem; |
| | }} |
| | .dev-link, |
| | .dev-link:visited, |
| | .dev-link:link {{ |
| | display: inline-flex; |
| | align-items: center; |
| | gap: 0.2rem; |
| | padding: 0.15rem 0.4rem; |
| | border: 1px solid {MIAMI_RED}; |
| | border-radius: 4px; |
| | font-size: 0.65rem; |
| | color: {MIAMI_RED} !important; |
| | text-decoration: none; |
| | background: {_WHITE}; |
| | line-height: 1.4; |
| | white-space: nowrap; |
| | }} |
| | .dev-link svg {{ |
| | width: 11px; |
| | height: 11px; |
| | fill: {MIAMI_RED}; |
| | }} |
| | .dev-link:hover {{ |
| | background-color: {MIAMI_RED}; |
| | color: {_WHITE} !important; |
| | }} |
| | .dev-link:hover svg {{ |
| | fill: {_WHITE}; |
| | }} |
| | |
| | /* ---- Metric-like stat cards ---- */ |
| | .stat-card {{ |
| | background-color: {_LIGHT_GRAY}; |
| | box-shadow: inset 4px 0 0 0 {MIAMI_RED}; |
| | border-radius: 6px; |
| | padding: 0.6rem 0.75rem 0.6rem 1rem; |
| | }} |
| | .stat-card .stat-label {{ |
| | color: {_BLACK}; |
| | font-size: 0.78rem; |
| | }} |
| | .stat-card .stat-value {{ |
| | color: {_BLACK}; |
| | font-weight: 700; |
| | font-size: 0.95rem; |
| | }} |
| | |
| | /* ---- Step cards on welcome screen ---- */ |
| | .step-card {{ |
| | background: {_LIGHT_GRAY}; |
| | border-radius: 8px; |
| | padding: 1rem; |
| | border-left: 4px solid {MIAMI_RED}; |
| | height: 100%; |
| | }} |
| | .step-card .step-number {{ |
| | font-size: 1.6rem; |
| | font-weight: 700; |
| | color: {MIAMI_RED}; |
| | }} |
| | .step-card .step-title {{ |
| | font-weight: 600; |
| | margin: 0.3rem 0 0.2rem; |
| | }} |
| | .step-card .step-desc {{ |
| | font-size: 0.82rem; |
| | color: #444; |
| | }} |
| | |
| | /* ---- App title in sidebar ---- */ |
| | .app-title {{ |
| | text-align: center; |
| | margin-bottom: 0.5rem; |
| | }} |
| | .app-title .title-text {{ |
| | font-size: 1.6rem; |
| | font-weight: 800; |
| | color: {MIAMI_RED}; |
| | }} |
| | .app-title .subtitle-text {{ |
| | font-size: 0.82rem; |
| | color: {_BLACK}; |
| | }} |
| | """ |
| |
|
| |
|
| | |
| | |
| | |
| | def get_miami_mpl_style() -> Dict[str, object]: |
| | """Return a dictionary of matplotlib rcParams for Miami branding. |
| | |
| | Usage:: |
| | |
| | import matplotlib as mpl |
| | mpl.rcParams.update(get_miami_mpl_style()) |
| | |
| | Or apply to a single figure:: |
| | |
| | with mpl.rc_context(get_miami_mpl_style()): |
| | fig, ax = plt.subplots() |
| | ... |
| | """ |
| | return { |
| | |
| | "figure.facecolor": _WHITE, |
| | "figure.edgecolor": _WHITE, |
| | "figure.figsize": (10, 5), |
| | "figure.dpi": 100, |
| | |
| | "axes.facecolor": _WHITE, |
| | "axes.edgecolor": _BLACK, |
| | "axes.labelcolor": _BLACK, |
| | "axes.titlecolor": MIAMI_RED, |
| | "axes.labelsize": 12, |
| | "axes.titlesize": 14, |
| | "axes.titleweight": "bold", |
| | "axes.prop_cycle": plt.cycler( |
| | color=[MIAMI_RED, _BLACK, "#4E79A7", "#F28E2B", "#76B7B2"] |
| | ), |
| | |
| | "axes.grid": True, |
| | "grid.color": _BORDER_GRAY, |
| | "grid.linestyle": "--", |
| | "grid.linewidth": 0.6, |
| | "grid.alpha": 0.7, |
| | |
| | "xtick.color": _BLACK, |
| | "ytick.color": _BLACK, |
| | "xtick.labelsize": 10, |
| | "ytick.labelsize": 10, |
| | |
| | "legend.fontsize": 10, |
| | "legend.frameon": True, |
| | "legend.framealpha": 0.9, |
| | "legend.edgecolor": _BORDER_GRAY, |
| | |
| | "font.size": 11, |
| | "font.family": "sans-serif", |
| | |
| | "savefig.dpi": 150, |
| | "savefig.bbox": "tight", |
| | } |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | |
| | _PALETTE_MAP: Dict[str, str] = { |
| | "Set1": "colorbrewer.qualitative.Set1", |
| | "Set2": "colorbrewer.qualitative.Set2", |
| | "Set3": "colorbrewer.qualitative.Set3", |
| | "Dark2": "colorbrewer.qualitative.Dark2", |
| | "Paired": "colorbrewer.qualitative.Paired", |
| | "Pastel1": "colorbrewer.qualitative.Pastel1", |
| | "Pastel2": "colorbrewer.qualitative.Pastel2", |
| | "Accent": "colorbrewer.qualitative.Accent", |
| | "Tab10": "colorbrewer.qualitative.Set1", |
| | } |
| |
|
| | _FALLBACK_COLORS: List[str] = [ |
| | MIAMI_RED, |
| | MIAMI_BLACK, |
| | "#4E79A7", |
| | "#F28E2B", |
| | "#76B7B2", |
| | "#E15759", |
| | "#59A14F", |
| | "#EDC948", |
| | ] |
| |
|
| |
|
| | def _resolve_palette(name: str) -> Optional[List[str]]: |
| | """Dynamically import a palettable ColorBrewer palette by *name*. |
| | |
| | Palettable organises palettes by maximum number of classes, e.g. |
| | ``colorbrewer.qualitative.Set2_8``. We find the variant with the |
| | most colours available so the caller gets the richest palette. |
| | """ |
| | import importlib |
| |
|
| | module_path = _PALETTE_MAP.get(name) |
| | if module_path is None: |
| | |
| | module_path = f"colorbrewer.qualitative.{name}" |
| |
|
| | |
| | try: |
| | mod = importlib.import_module(f"palettable.{module_path}") |
| | except (ImportError, ModuleNotFoundError): |
| | return None |
| |
|
| | |
| | best = None |
| | best_n = 0 |
| | base = name.split(".")[-1] if "." in name else name |
| | for attr_name in dir(mod): |
| | if not attr_name.startswith(base + "_"): |
| | continue |
| | try: |
| | suffix = int(attr_name.split("_")[-1]) |
| | except ValueError: |
| | continue |
| | if suffix > best_n: |
| | best_n = suffix |
| | best = attr_name |
| |
|
| | if best is None: |
| | return None |
| |
|
| | palette_obj = getattr(mod, best, None) |
| | if palette_obj is None: |
| | return None |
| |
|
| | return [ |
| | "#{:02X}{:02X}{:02X}".format(*rgb) for rgb in palette_obj.colors |
| | ] |
| |
|
| |
|
| | def get_palette_colors(name: str = "Set2", n: int = 8) -> List[str]: |
| | """Load *n* hex colour strings from a ColorBrewer palette. |
| | |
| | Parameters |
| | ---------- |
| | name: |
| | Friendly palette name such as ``"Set2"``, ``"Dark2"``, ``"Paired"``. |
| | n: |
| | Number of colours required. If *n* exceeds the palette length the |
| | colours are cycled. |
| | |
| | Returns |
| | ------- |
| | list[str] |
| | List of *n* hex colour strings (e.g. ``["#66C2A5", ...]``). |
| | |
| | Notes |
| | ----- |
| | If the requested palette cannot be found, a sensible fallback list is |
| | returned so that calling code never receives an empty list. |
| | """ |
| | n = max(1, n) |
| | colors = _resolve_palette(name) |
| | if colors is None: |
| | colors = _FALLBACK_COLORS |
| |
|
| | |
| | cycled = list(itertools.islice(itertools.cycle(colors), n)) |
| | return cycled |
| |
|
| |
|
| | |
| | |
| | |
| | def render_palette_preview( |
| | colors: List[str], |
| | swatch_width: float = 1.0, |
| | swatch_height: float = 0.4, |
| | ) -> matplotlib.figure.Figure: |
| | """Create a small matplotlib figure showing colour swatches. |
| | |
| | Parameters |
| | ---------- |
| | colors: |
| | List of hex colour strings to display. |
| | swatch_width: |
| | Width of each individual swatch in inches. |
| | swatch_height: |
| | Height of the swatch strip in inches. |
| | |
| | Returns |
| | ------- |
| | matplotlib.figure.Figure |
| | A Figure instance ready to be passed to ``gr.Plot`` or saved. |
| | """ |
| | n = len(colors) |
| | fig_width = max(swatch_width * n, 2.0) |
| | fig, ax = plt.subplots( |
| | figsize=(fig_width, swatch_height + 0.3), dpi=100 |
| | ) |
| |
|
| | for i, colour in enumerate(colors): |
| | ax.add_patch( |
| | plt.Rectangle( |
| | (i, 0), |
| | width=1, |
| | height=1, |
| | facecolor=colour, |
| | edgecolor=_WHITE, |
| | linewidth=1.5, |
| | ) |
| | ) |
| |
|
| | ax.set_xlim(0, n) |
| | ax.set_ylim(0, 1) |
| | ax.set_aspect("equal") |
| | ax.axis("off") |
| | fig.subplots_adjust(left=0, right=1, top=1, bottom=0) |
| | plt.close(fig) |
| | return fig |
| |
|