"""Shared visual theme for Streamlit apps — mark.dev paper (or mark.dev after dark) with one project accent. Copy this file into a repo as ``ui_theme.py`` (next to the app, or inside the package), set MODE and 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 colours. Light (MODE = "light"): [theme] base = "light" primaryColor = "" backgroundColor = "#fefaf5" secondaryBackgroundColor = "#faf2e9" textColor = "#1c1714" font = "sans serif" Dark (MODE = "dark"): [theme] base = "dark" primaryColor = "" backgroundColor = "#14110f" secondaryBackgroundColor = "#1f1a17" textColor = "#f4ede4" font = "sans serif" """ from __future__ import annotations import streamlit as st # ---- the only values that change between projects ------------------------- MODE = "dark" # "light" = mark.dev paper, "dark" = mark.dev after dark ACCENT = "#F0655A" # main accent (buttons, links, active tab, metric values) ACCENT_STRONG = "#F4877E" # hover / pressed ACCENT_SOFT = "#D0564C" # partner for gradients and secondary marks # --------------------------------------------------------------------------- _PALETTES = { "light": dict( paper="#fefaf5", paper_alt="#faf2e9", card="#fffdfa", ink="#1c1714", ink_2="#57504a", ink_3="#756c65", line="#e9dbcd", line_strong="#d8c3b2", on_accent="#fefaf5", header="rgba(254, 250, 245, .82)", placeholder="#a89a8f", shadow_sm="0 1px 2px rgba(60, 40, 20, .04), 0 4px 16px rgba(60, 40, 20, .05)", shadow_md="0 2px 4px rgba(60, 40, 20, .05), 0 14px 38px rgba(60, 40, 20, .09)", neutrals=("#8c7b6b", "#c9a27e", "#3b3230", "#b9ad9f"), dots=1.0, ), "dark": dict( paper="#14110f", paper_alt="#1a1613", card="#1f1a17", ink="#f4ede4", ink_2="#c9bcae", ink_3="#9a8c7f", line="#2f2823", line_strong="#463b33", on_accent="#14110f", header="rgba(20, 17, 15, .82)", placeholder="#7d6f63", shadow_sm="0 1px 2px rgba(0, 0, 0, .35), 0 6px 18px rgba(0, 0, 0, .28)", shadow_md="0 2px 4px rgba(0, 0, 0, .4), 0 16px 40px rgba(0, 0, 0, .45)", neutrals=("#b3a595", "#d9b48f", "#f4ede4", "#7d6f63"), dots=0.75, ), } def _palette() -> dict: return _PALETTES["dark" if MODE == "dark" else "light"] # module-level names for app code that builds inline HTML (resolved from MODE above) PAPER = _palette()["paper"] PAPER_ALT = _palette()["paper_alt"] CARD = _palette()["card"] INK = _palette()["ink"] INK_2 = _palette()["ink_2"] INK_3 = _palette()["ink_3"] LINE = _palette()["line"] LINE_STRONG = _palette()["line_strong"] ON_ACCENT = _palette()["on_accent"] 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 the ground.""" n = _palette()["neutrals"] return [ACCENT, n[0], ACCENT_SOFT, n[1], n[2], n[3]] def sequential(n: int = 6) -> list[str]: """Ground-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)) g0 = _palette()["paper_alt"].lstrip("#") base = tuple(int(g0[i:i + 2], 16) for i in (0, 2, 4)) 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 ground. Render with ``st.plotly_chart(fig, theme=None)`` — Streamlit's default chart theme otherwise repaints the traces. Do not add a ``title_font`` without a title: Streamlit then prints "undefined". Prefer ``style_fig(fig)``. """ p = _palette() 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=p["ink_2"], size=13), colorway=colorway(), xaxis=dict(gridcolor=p["line"], linecolor=p["line_strong"], zerolinecolor=p["line"], automargin=True), yaxis=dict(gridcolor=p["line"], linecolor=p["line_strong"], zerolinecolor=p["line"], automargin=True), legend=dict(bgcolor="rgba(0,0,0,0)", font=dict(color=p["ink_2"])), hoverlabel=dict(bgcolor=p["card"], bordercolor=p["line_strong"], font=dict(color=p["ink"])), ) for key, value in overrides.items(): # axis overrides add to the themed axis instead of replacing it if key in ("xaxis", "yaxis") and isinstance(value, dict): layout[key] = {**layout[key], **value} else: layout[key] = value 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 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") k = _palette()["dots"] 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='{round(o * k, 3)}'/%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: p = _palette() tint = _rgba(ACCENT, .08 if MODE == "dark" else .06) glow = _rgba(ACCENT, .22 if MODE == "dark" else .14) line_accent = _rgba(ACCENT, .32 if MODE == "dark" else .24) return f""" """ # =========================================================================== # SENTINEL extensions (Fraud-Detection-MLOps) — status colours, chart scales # and the app's own components. Everything below resolves from MODE/ACCENT # above, exactly like the shared half, so nothing here hardcodes light or dark. # =========================================================================== _STATUS = { "light": dict(ok="#3f7a3a", ok_tint="rgba(63, 122, 58, .10)", warn="#a86a12", warn_tint="rgba(168, 106, 18, .10)", bad="#b3261e", bad_tint="rgba(179, 38, 30, .08)", info="#2f5f8a", info_tint="rgba(47, 95, 138, .09)"), "dark": dict(ok="#6fbf73", ok_tint="rgba(111, 191, 115, .12)", warn="#e0a445", warn_tint="rgba(224, 164, 69, .12)", bad="#f07167", bad_tint="rgba(240, 113, 103, .12)", info="#7fb0e0", info_tint="rgba(127, 176, 224, .12)"), } def _status() -> dict: return _STATUS["dark" if MODE == "dark" else "light"] # status colours keep their meaning in every project; always shown on their tint OK = _status()["ok"] WARN = _status()["warn"] BAD = _status()["bad"] INFO = _status()["info"] NEUTRAL = _palette()["neutrals"][0] # first warm neutral of the colorway def bar_scale() -> list[list]: """Warm neutral -> accent, for bars coloured by value (both ends stay visible).""" return [[0.0, NEUTRAL], [1.0, ACCENT]] def components_css() -> str: """SENTINEL's own components, built on the shared tokens. Class names are the ones ui_kit.py and views/*.py emit. Layout rules for Streamlit's top navigation and bordered containers live here too, since the shared half above targets the sidebar layout. """ s = _status() tint = _rgba(ACCENT, .12) return f""" """ def apply_theme() -> None: st.markdown(theme_css(), unsafe_allow_html=True) st.markdown(components_css(), unsafe_allow_html=True) # SENTINEL components 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, )