readthrough / dashboard /theme.py
Viney's picture
Claude Opus 5 (1M context)
feat: The Brief renders as the published research note
93f9cc0
Raw History Blame Contribute Delete
24.5 kB
"""Centralized design tokens and CSS injection for Readthrough.
The palette is the published research note's, so the app and the document
it serves are one design. dashboard/note_css.py is the source of truth for
the values; the constants below restate them as literals because plotly
cannot resolve a CSS variable.
"""
from __future__ import annotations
import streamlit as st
from dashboard.note_css import FONT_IMPORT, NOTE_CSS, TOKENS_LIGHT
# ── Brand ─────────────────────────────────────────────────────────────────────
BRAND_GREEN = "#1E6B55"
BRAND_GREEN_DARK = "#134A3B"
# ── Surfaces ──────────────────────────────────────────────────────────────────
BG = "#F5F6F3"
BG_MUTED = "#F5F6F3"
BORDER = "#D5DAD4"
BORDER_STRONG = "#8C968F"
# ── Text ──────────────────────────────────────────────────────────────────────
TEXT = "#15201B"
TEXT_MUTED = "#3E4A44"
TEXT_FAINT = "#8C968F"
# ── Semantic palette β€” the note's own tokens ──────────────────────────────────
# These are literals rather than var() references because plotly consumes them
# directly and cannot resolve a CSS variable. Values track dashboard/note_css.py.
GREEN = "#1E6B55"
BULL = "#1E6B55"
BULL_BG = "#DCEBE3"
BULL_BORDER = "#1E6B55"
RED = "#A63A2B"
BEAR = "#A63A2B"
BEAR_BG = "#F3DDD8"
BEAR_BORDER = "#A63A2B"
AMBER = "#B8791F"
WARN = "#B8791F"
WARN_BG = "#F4E8D2"
WARN_BORDER = "#B8791F"
BLUE = "#3E4A44"
INFO = "#3E4A44"
INFO_BG = "#ECEEE9"
INFO_BORDER = "#D5DAD4"
GRAY = "#3E4A44"
PURPLE = "#1E6B55"
AI_BG = "#DCEBE3"
AI_BORDER = "#1E6B55"
AI_COLOR = "#1E6B55"
AI_BADGE_BG = "#DCEBE3"
IMPACT_HIGH_BG = "#15201B" # near-white chip β€” highest contrast on dark
IMPACT_HIGH_TEXT = "#F5F6F3"
IMPACT_MED_BG = "#8C968F" # slate-400
IMPACT_MED_TEXT = "#F5F6F3"
IMPACT_LOW_BG = "transparent"
IMPACT_LOW_TEXT = "#8C968F" # slate-400
IMPACT_LOW_BORDER = "#8C968F" # slate-600
# ── Plotly rgba aliases ───────────────────────────────────────────────────────
GREEN_A53 = "rgba(30,107,85,0.55)"
GREEN_A40 = "rgba(30,107,85,0.42)"
RED_A40 = "rgba(166,58,43,0.42)"
PURPLE_A53 = "rgba(19,74,59,0.55)"
BLUE_A53 = "rgba(62,74,68,0.55)"
# ── Radius ────────────────────────────────────────────────────────────────────
RADIUS_CARD = "12px"
RADIUS_BTN = "8px"
RADIUS_CHIP = "6px"
# ── Typographic scale ─────────────────────────────────────────────────────────
FS_PAGE = "1.55rem" # ~25px page/screen title
FS_SECTION = "1.05rem" # ~17px section sub-header
FS_CARD = "0.95rem" # ~15px card headline (semibold)
FS_BODY = "0.9rem" # ~14.5px body text
FS_META = "0.75rem" # ~12px meta/attribution line
FS_EYEBROW = "0.6rem" # uppercase micro-label (was scattered 0.56–0.72rem)
FS_SECTION_LABEL = "0.78rem"
# ── Spacing scale ─────────────────────────────────────────────────────────────
SPACE_1 = "4px"
SPACE_2 = "8px"
SPACE_3 = "12px"
SPACE_4 = "16px"
SPACE_5 = "20px"
SPACE_6 = "28px" # intentionally 8px jump β€” used for section-break vertical rhythm
# NAV_GROUPS now lives in dashboard.nav β€” imported from there in app.py.
# Kept as a re-export for any legacy callers.
def _lazy_nav_groups(): # noqa: F401 β€” accessed via dashboard.nav.NAV_GROUPS
from dashboard.nav import NAV_GROUPS # noqa: F401
return NAV_GROUPS
# ── Logo SVG β€” geometric P in emerald rounded square ─────────────────────────
# The counter of the P is punched out in the canvas colour so the mark reads
# correctly on the dark sidebar (it used to be punched out in white).
LOGO_SVG = """<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="36" rx="9" fill="#1E6B55"/>
<path d="M12 26V11H19.5C20.9 11 22.1 11.5 23.1 12.5C24.1 13.5 24.6 14.7 24.6 16.2C24.6 17.7 24.1 18.9 23.1 19.9C22.1 20.9 20.9 21.4 19.5 21.4H15.5V26H12Z" fill="#F5F6F3"/>
<path d="M15.5 18.2H19C19.8 18.2 20.4 17.9 20.9 17.4C21.4 16.9 21.6 16.2 21.6 15.4C21.6 14.6 21.4 14 20.9 13.5C20.4 13 19.8 12.7 19 12.7H15.5V18.2Z" fill="#1E6B55"/>
</svg>"""
def _register_plotly_template() -> None:
"""Make dark the plotly default so charts inherit it without per-figure work.
Individual figures still override plot/paper background and font colour;
what this template actually buys is the styling nothing sets by hand β€”
hover labels and the modebar, which otherwise render light-on-light.
"""
try:
import plotly.graph_objects as go
import plotly.io as pio
except Exception: # plotly missing β€” charts are optional, styling is not fatal
return
# Already the default: nothing to do, and importantly nothing to validate.
# Assigning it again makes plotly deep-copy and re-validate the template,
# which reaches into numpy β€” see the guard in app.py for why that is a
# hazard on a rerun.
if pio.templates.default == "readthrough_paper":
return
if "readthrough_paper" not in pio.templates:
pio.templates["readthrough_paper"] = go.layout.Template(
layout=dict(
paper_bgcolor=BG,
plot_bgcolor=BG,
font=dict(family="IBM Plex Sans", color=TEXT_MUTED, size=11),
hoverlabel=dict(
bgcolor="#ECEEE9",
bordercolor=BORDER_STRONG,
font=dict(family="IBM Plex Sans", color=TEXT, size=12),
),
xaxis=dict(gridcolor="#ECEEE9", zerolinecolor="#D5DAD4",
linecolor=BORDER, tickfont=dict(color=TEXT_MUTED)),
yaxis=dict(gridcolor="#ECEEE9", zerolinecolor="#D5DAD4",
linecolor=BORDER, tickfont=dict(color=TEXT_MUTED)),
colorway=[BRAND_GREEN, BLUE, PURPLE, AMBER, RED, "#8C968F"],
)
)
pio.templates.default = "readthrough_paper"
def inject_global_css() -> None:
"""Inject the note's design system app-wide. Call once from app.py.
Loads the three families the note is set in, defines its palette as CSS
variables, ships the note stylesheet itself, then restates Streamlit's
own chrome in those same variables so the app and the document it serves
are one design rather than two.
"""
_register_plotly_template()
st.html(
"""
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<style>"""
+ FONT_IMPORT
+ TOKENS_LIGHT
+ NOTE_CSS
+ """
/* ── Typography ─────────────────────────────────────────────────────── */
html, body, .stApp, .main, [data-testid="stAppViewContainer"],
[data-testid="stSidebar"], button, input, select, textarea {
font-family: "IBM Plex Sans", -apple-system, "Segoe UI", Helvetica, Arial, sans-serif !important;
}
html, body, .stApp, .main, [data-testid="stAppViewContainer"],
[data-testid="stSidebar"] {
line-height: 1.55 !important;
}
/* Headings take the note's serif, as they do in the document itself. */
.stApp h1, .stApp h2, .stApp h3,
[data-testid="stSidebar"] h1, [data-testid="stSidebar"] h2 {
font-family: "Newsreader", Georgia, "Times New Roman", serif !important;
font-weight: 500 !important;
letter-spacing: -0.005em !important;
}
/* The note is one document: Streamlit's gutters would inset it inside
its own .wrap, so the page gives it the full column and .wrap does
the centring, exactly as it does when published. */
.main .block-container:has(.rt-note) {
max-width: none !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
/* ── Hide default Streamlit chrome ──────────────────────────────────── */
#MainMenu { visibility: hidden; }
header[data-testid="stHeader"] { background: transparent !important; }
footer { visibility: hidden; }
/* ── Canvas background ──────────────────────────────────────────────────── */
.stApp, [data-testid="stAppViewContainer"] {
background: #F5F6F3 !important;
}
/* ── Sidebar ────────────────────────────────────────────────────────── */
[data-testid="stSidebar"] {
background-color: #F5F6F3 !important;
border-right: 1px solid #D5DAD4 !important;
}
/* ── Logo row (custom div) ──────────────────────────────────────────── */
.primer-logo-row {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
border-bottom: 1px solid #D5DAD4;
margin-bottom: 12px;
}
.primer-logo-wordmark {
font-size: 1rem;
font-weight: 700;
color: #15201B;
letter-spacing: -0.02em;
line-height: 1.2;
}
.primer-logo-tagline {
font-size: 0.65rem;
color: #8C968F;
font-weight: 400;
margin-top: 1px;
}
/* ── Text inputs ────────────────────────────────────────────────────── */
[data-testid="stTextInput"] input {
border-radius: 8px !important;
border: 1px solid #D5DAD4 !important;
font-size: 0.9rem !important;
background: #F5F6F3 !important;
color: #15201B !important;
}
[data-testid="stTextInput"] input:focus {
border-color: #1E6B55 !important;
box-shadow: 0 0 0 3px rgba(30,107,85,0.18) !important;
}
/* ── Primary button β€” inverted (near-white), not green.
Emerald stays reserved for active-nav and positive semantics, so the
CTA must not compete with it. ────────────────────────────────────── */
[data-testid="stBaseButton-primary"] {
background: #15201B !important;
color: #F5F6F3 !important;
border-radius: 8px !important;
border: none !important;
font-weight: 500 !important;
font-size: 0.875rem !important;
letter-spacing: 0.01em !important;
}
[data-testid="stBaseButton-primary"]:hover {
background: #ECEEE9 !important;
border: none !important;
}
/* ── Secondary / default buttons ────────────────────────────────────── */
[data-testid="stBaseButton-secondary"] {
border-radius: 8px !important;
border: 1px solid #D5DAD4 !important;
background: #ECEEE9 !important;
font-weight: 500 !important;
color: #15201B !important;
}
[data-testid="stBaseButton-secondary"]:hover {
background: #ECEEE9 !important;
border-color: #8C968F !important;
}
/* ── Download button ────────────────────────────────────────────────── */
[data-testid="stDownloadButton"] button {
border-radius: 8px !important;
border: 1px solid #D5DAD4 !important;
background: #ECEEE9 !important;
font-weight: 500 !important;
color: #15201B !important;
}
/* ── Bordered containers ────────────────────────────────────────────── */
[data-testid="stVerticalBlockBorderWrapper"] {
border-radius: 12px !important;
border: 1px solid #D5DAD4 !important;
box-shadow: 0 1px 2px rgba(21,32,27,0.06), 0 4px 12px rgba(21,32,27,0.04) !important;
background: #F5F6F3 !important;
padding: 20px 24px !important;
}
/* ── Custom card shadow class ───────────────────────────────────────── */
.primer-card {
box-shadow: 0 1px 2px rgba(21,32,27,0.06), 0 4px 12px rgba(21,32,27,0.04);
}
/* ── Section cards (HTML-rendered sections) ─────────────────────────────── */
.primer-section-card {
background: #F5F6F3;
border: 1px solid #D5DAD4;
border-radius: 12px;
padding: 22px 26px;
margin: 0 0 18px;
box-shadow: 0 1px 2px rgba(21,32,27,0.06), 0 4px 12px rgba(21,32,27,0.04);
}
/* ── Dividers ───────────────────────────────────────────────────────── */
hr { border-color: #D5DAD4 !important; }
/* ── Status box ─────────────────────────────────────────────────────── */
[data-testid="stStatus"] {
border-radius: 8px !important;
border: 1px solid #D5DAD4 !important;
}
/* ── Expanders ──────────────────────────────────────────────────────── */
[data-testid="stExpander"] {
border: 1px solid #D5DAD4 !important;
border-radius: 8px !important;
box-shadow: none !important;
}
/* ── Inner tabs (e.g. Numbers chart tabs) ───────────────────────────── */
[data-testid="stTabs"] [role="tab"] {
font-size: 0.82rem !important;
font-weight: 500 !important;
color: #3E4A44 !important;
}
[data-testid="stTabs"] [role="tab"][aria-selected="true"] {
color: #15201B !important;
}
[data-testid="stTabs"] [role="tablist"] {
border-bottom: 1px solid #D5DAD4 !important;
}
/* ── Captions ───────────────────────────────────────────────────────── */
.stCaption { color: #8C968F !important; font-size: 0.78rem !important; }
/* ── Alerts / info boxes ────────────────────────────────────────────── */
[data-testid="stAlert"] {
border-radius: 8px !important;
}
/* ── Sidebar nav group expanders (deep-dive accordion) ──────────────── */
[data-testid="stSidebar"] [data-testid="stExpander"] {
border: none !important;
border-top: 1px solid #D5DAD4 !important;
border-radius: 0 !important;
box-shadow: none !important;
background: transparent !important;
margin-top: 2px !important;
}
[data-testid="stSidebar"] [data-testid="stExpander"] summary {
font-size: 0.6rem !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.1em !important;
color: #8C968F !important;
padding: 6px 2px !important;
}
[data-testid="stSidebar"] [data-testid="stExpander"] summary:hover {
color: #3E4A44 !important;
}
[data-testid="stSidebar"] [data-testid="stExpander"] summary svg {
width: 12px !important;
height: 12px !important;
color: #8C968F !important;
}
/* ── Content max-width ──────────────────────────────────────────────── */
.main .block-container {
max-width: 1280px !important;
padding-top: 24px !important;
}
/* ── Prose width (text blocks) ─────────────────────────────────────────── */
.primer-prose {
max-width: 700px;
}
/* ── Sticky summary bar ─────────────────────────────────────────────────── */
.primer-sticky-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(245,246,243,0.88);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border-bottom: 1px solid #D5DAD4;
padding: 10px 0;
margin: 0 0 16px;
}
/* ── Chat messages ──────────────────────────────────────────────────── */
[data-testid="stChatMessage"] {
border-radius: 10px !important;
border: 1px solid #D5DAD4 !important;
background: #F5F6F3 !important;
padding: 12px 14px !important;
margin-bottom: 8px !important;
box-shadow: 0 1px 2px rgba(21,32,27,0.05) !important;
}
[data-testid="stChatMessage"][data-message-author-role="user"] {
background: #F5F6F3 !important;
border-color: #D5DAD4 !important;
}
[data-testid="stChatMessage"][data-message-author-role="assistant"] {
background: #DCEBE3 !important;
border-color: #1E6B55 !important;
}
/* ── Chat input ─────────────────────────────────────────────────────── */
[data-testid="stChatInput"] {
border-radius: 10px !important;
border: 1px solid #D5DAD4 !important;
box-shadow: 0 1px 3px rgba(21,32,27,0.06) !important;
}
[data-testid="stChatInput"]:focus-within {
border-color: #1E6B55 !important;
box-shadow: 0 0 0 3px rgba(30,107,85,0.18) !important;
}
[data-testid="stChatInput"] textarea {
font-family: "IBM Plex Sans", -apple-system, "Segoe UI", Helvetica, Arial, sans-serif !important;
font-size: 0.9rem !important;
color: #15201B !important;
}
/* ══ Widget chrome ══════════════════════════════════════════════════════
Streamlit paints a handful of widgets from its own theme rather than
from our tokens. Those are pinned here so no widget arrives in a
colour the note does not contain. */
/* Select / multiselect / number inputs */
[data-baseweb="select"] > div,
[data-testid="stSelectbox"] div[role="combobox"],
[data-testid="stNumberInput"] input {
background-color: #ECEEE9 !important;
border-color: #D5DAD4 !important;
color: #15201B !important;
}
[data-baseweb="popover"] [role="listbox"],
[data-baseweb="menu"] {
background-color: #ECEEE9 !important;
border: 1px solid #D5DAD4 !important;
}
[data-baseweb="menu"] li:hover { background-color: #ECEEE9 !important; }
/* Text input placeholders */
[data-testid="stTextInput"] input::placeholder,
[data-testid="stChatInput"] textarea::placeholder {
color: #8C968F !important;
}
/* Inline code + code blocks */
code, [data-testid="stCode"], pre {
background-color: #ECEEE9 !important;
color: #134A3B !important;
}
/* Links β€” emerald, never the browser default blue */
a, a:visited { color: #1E6B55 !important; }
a:hover { color: #134A3B !important; }
/* Tables / dataframes */
[data-testid="stDataFrame"], [data-testid="stTable"] {
background-color: #F5F6F3 !important;
}
[data-testid="stDataFrame"] * { border-color: #D5DAD4 !important; }
/* Alerts β€” Streamlit ships its own pastels; restate them as the note's tints */
[data-testid="stAlert"] { border: 1px solid #D5DAD4 !important; }
[data-testid="stAlertContentInfo"] { background-color: #ECEEE9 !important; color: #3E4A44 !important; }
[data-testid="stAlertContentSuccess"] { background-color: #DCEBE3 !important; color: #134A3B !important; }
[data-testid="stAlertContentWarning"] { background-color: #F4E8D2 !important; color: #B8791F !important; }
[data-testid="stAlertContentError"] { background-color: #F3DDD8 !important; color: #A63A2B !important; }
/* Active tab underline β€” Streamlit draws it in primaryColor */
[data-testid="stTabs"] [role="tab"][aria-selected="true"]::after,
[data-testid="stTabs"] [data-baseweb="tab-highlight"] {
background-color: #1E6B55 !important;
}
/* Spinner + progress use primaryColor too */
[data-testid="stSpinner"] i { border-top-color: #1E6B55 !important; }
/* Scrollbars β€” drawn in the rule colour so they read as part of the page */
* { scrollbar-color: #8C968F #F5F6F3; scrollbar-width: thin; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: #F5F6F3; }
::-webkit-scrollbar-thumb {
background: #8C968F; border-radius: 6px; border: 2px solid #F5F6F3;
}
::-webkit-scrollbar-thumb:hover { background: #8C968F; }
/* ── Inline "show the rest" disclosures (redline passages) ────────── */
details > summary { list-style: none; }
details > summary::-webkit-details-marker { display: none; }
details > summary::marker { content: ""; }
details[open] > summary .primer-more { display: none; }
details > summary:hover .primer-more { color: #1E6B55 !important; }
/* Tell the UA the page is light so form controls, selection and
autofill are drawn to match rather than inverted. */
:root { color-scheme: light; }
::selection { background: rgba(30,107,85,0.22); color: #15201B; }
</style>
""",
)