"""Shared visual theme for Streamlit apps — mark.dev paper with one project accent. Copy this file into a repo as ``ui_theme.py`` (next to the app, or inside the package), set the three ACCENT values from the palette, and call ``apply_theme()`` right after ``st.set_page_config(...)`` on EVERY page script (multipage apps run each page on its own, so CSS does not carry over). Pair it with ``.streamlit/config.toml`` at the repo root so native widgets (sliders, checkboxes, focus rings) pick up the same accent: [theme] base = "light" primaryColor = "" backgroundColor = "#fefaf5" secondaryBackgroundColor = "#faf2e9" textColor = "#1c1714" font = "sans serif" """ from __future__ import annotations import streamlit as st # ---- the only values that change between projects ------------------------- ACCENT = "#0369A1" # main accent (buttons, links, active tab, metric values) ACCENT_STRONG = "#025481" # hover / pressed ACCENT_SOFT = "#3A8DBD" # lighter partner for gradients and secondary marks # --------------------------------------------------------------------------- PAPER = "#fefaf5" PAPER_ALT = "#faf2e9" CARD = "#fffdfa" INK = "#1c1714" INK_2 = "#57504a" INK_3 = "#756c65" LINE = "#e9dbcd" LINE_STRONG = "#d8c3b2" FONT_DISPLAY = "'Fraunces', Georgia, 'Times New Roman', serif" FONT_BODY = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" FONT_MONO = "'JetBrains Mono', ui-monospace, Consolas, monospace" def _rgba(hex_color: str, alpha: float) -> str: h = hex_color.lstrip("#") r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4)) return f"rgba({r}, {g}, {b}, {alpha})" def colorway() -> list[str]: """Categorical chart colours: accent first, then warm neutrals that sit on paper.""" return [ACCENT, "#8c7b6b", ACCENT_SOFT, "#c9a27e", "#3b3230", "#b9ad9f"] def sequential(n: int = 6) -> list[str]: """Light-to-accent ramp for heatmaps / ordered categories.""" h = ACCENT.lstrip("#") r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4)) base = (250, 242, 233) # PAPER_ALT out = [] for i in range(n): t = 0.15 + 0.85 * i / max(n - 1, 1) out.append("#%02x%02x%02x" % tuple(round(base[k] + (c - base[k]) * t) for k, c in enumerate((r, g, b)))) return out def plotly_layout(**overrides) -> dict: """Transparent plotly layout that blends into the paper ground. Render with ``st.plotly_chart(fig, theme=None)`` — Streamlit's default chart theme otherwise repaints the traces in its own palette. Do not add a ``title_font`` without a title: Streamlit then prints "undefined". """ layout = dict( paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=dict(family="Inter, -apple-system, Segoe UI, sans-serif", color=INK_2, size=13), colorway=colorway(), xaxis=dict(gridcolor=LINE, linecolor=LINE_STRONG, zerolinecolor=LINE), yaxis=dict(gridcolor=LINE, linecolor=LINE_STRONG, zerolinecolor=LINE), legend=dict(bgcolor="rgba(0,0,0,0)", font=dict(color=INK_2)), hoverlabel=dict(bgcolor=CARD, bordercolor=LINE_STRONG, font=dict(color=INK)), ) layout.update(overrides) return layout # plotly's stock palette — traces still wearing these get repainted by style_fig() _PLOTLY_DEFAULTS = {"#636efa", "#ef553b", "#00cc96", "#ab63fa", "#ffa15a", "#19d3f3", "#ff6692", "#b6e880", "#ff97ff", "#fecb52"} def style_fig(fig, **layout_overrides): """Apply the paper layout AND recolour traces, then render with theme=None. plotly express stamps its own colour onto each trace at creation time, so setting ``colorway`` afterwards does nothing — hence the second pass. Even better: pass ``color_discrete_sequence=ui_theme.colorway()`` to the px call. """ fig.update_layout(**plotly_layout(**layout_overrides)) palette, i = colorway(), 0 for trace in fig.data: for holder in ("marker", "line"): obj = getattr(trace, holder, None) colour = getattr(obj, "color", None) if obj is not None else None if isinstance(colour, str) and colour.lower() in _PLOTLY_DEFAULTS: obj.color = palette[i % len(palette)] i += 1 return fig def _dots_svg() -> str: fill = ACCENT.replace("#", "%23") dots = [(52, 71, 1.4, .28), (178, 38, .9, .22), (312, 95, 1.6, .3), (458, 52, 1, .24), (546, 128, 1.3, .26), (96, 188, 1.1, .25), (236, 222, 1.5, .28), (392, 176, .9, .2), (508, 246, 1.4, .27), (28, 298, 1.2, .24), (158, 344, 1.6, .3), (286, 312, 1, .22), (430, 366, 1.3, .26), (566, 402, 1.1, .23), (72, 452, 1.5, .29), (204, 486, .9, .21), (348, 438, 1.4, .27), (482, 528, 1.2, .25), (128, 566, 1.3, .26), (268, 592, 1, .22), (412, 556, 1.5, .28), (588, 486, .9, .2)] circles = "".join(f"%3Ccircle cx='{x}' cy='{y}' r='{r}' opacity='{o}'/%3E" for x, y, r, o in dots) return ("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='600'%3E" f"%3Cg fill='{fill}'%3E{circles}%3C/g%3E%3C/svg%3E") def theme_css() -> str: tint = _rgba(ACCENT, .06) glow = _rgba(ACCENT, .14) line_accent = _rgba(ACCENT, .24) return f""" """ def apply_theme() -> None: st.markdown(theme_css(), unsafe_allow_html=True) def eyebrow(text: str) -> None: """Small mono uppercase label above a heading, like mark.dev's status badge.""" st.markdown( f"
{text}
", unsafe_allow_html=True, )