Kelvin-programmer/pulsecommerce
0
1"""Shared design system for PulseCommerce - a professional BI platform.2 3Light, card-based UI inspired by Stripe / Shopify analytics. Every page imports4`apply_theme()` at the top and uses the helpers below for consistent visuals.5"""6 7from __future__ import annotations8 9from typing import Any, Sequence10 11import plotly.graph_objects as go12import streamlit as st13 14COLORS = {15 # Brand16 "primary": "#4F46E5", # Indigo17 "primary_dark": "#3730A3",18 "primary_light": "#EEF2FF",19 "accent": "#0EA5E9", # Sky20 # Neutrals21 "bg": "#F8FAFC",22 "surface": "#FFFFFF",23 "border": "#E5E7EB",24 "border_strong": "#D1D5DB",25 "text": "#0F172A",26 "text_muted": "#475569",27 "text_subtle": "#64748B",28 # Status29 "success": "#10B981",30 "success_bg": "#ECFDF5",31 "warning": "#F59E0B",32 "warning_bg": "#FFFBEB",33 "danger": "#EF4444",34 "danger_bg": "#FEF2F2",35 "info": "#3B82F6",36 "info_bg": "#EFF6FF",37}38 39SEQ_INDIGO = ["#EEF2FF", "#C7D2FE", "#A5B4FC", "#818CF8", "#6366F1", "#4F46E5", "#4338CA", "#3730A3"]40SEQ_TEAL = ["#F0FDFA", "#CCFBF1", "#99F6E4", "#5EEAD4", "#2DD4BF", "#14B8A6", "#0D9488", "#0F766E"]41 42CATEGORICAL = ["#4F46E5", "#0EA5E9", "#10B981", "#F59E0B", "#EC4899", "#8B5CF6", "#14B8A6", "#F97316"]43 44 45def apply_theme(page_title: str, page_icon: str) -> None:46 """Call once at the top of every page."""47 st.set_page_config(48 page_title=f"{page_title} · PulseCommerce",49 page_icon=page_icon,50 layout="wide",51 initial_sidebar_state="auto",52 )53 st.markdown(_GLOBAL_CSS, unsafe_allow_html=True)54 _sync_sidebar_for_viewport()55 56 57def _sync_sidebar_for_viewport() -> None:58 """Keep desktop navigation discoverable without covering mobile content."""59 from streamlit.components.v1 import html as components_html60 61 components_html(62 """63 <script>64 (function() {65 const parentWin = window.parent;66 const parentDoc = parentWin.document;67 68 const isMobile = () => parentWin.innerWidth <= 900;69 const isExpanded = () => {70 const sb = parentDoc.querySelector('section[data-testid="stSidebar"]');71 return sb && sb.getAttribute('aria-expanded') === 'true';72 };73 const collapseSidebar = () => {74 if (!isMobile() || !isExpanded()) return true;75 const btn = parentDoc.querySelector(76 '[data-testid="stSidebarCollapseButton"] button, '77 + '[data-testid="stSidebarCollapseButton"], '78 + 'button[aria-label="Close sidebar"]'79 );80 if (btn) { btn.click(); return true; }81 return false;82 };83 84 let tries = 0;85 const attempt = () => {86 if (collapseSidebar()) return;87 if (++tries < 40) requestAnimationFrame(attempt);88 };89 attempt();90 parentWin.addEventListener('resize', attempt);91 parentWin.addEventListener('load', attempt);92 })();93 </script>94 """,95 height=0,96 )97 98 99_GLOBAL_CSS = f"""100<style>101 @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');102 103 html, body, [class*="css"] {{104 font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;105 color: {COLORS['text']};106 }}107 108 /* Page background */109 .stApp {{110 background: {COLORS['bg']};111 }}112 113 /* Main block - fluid width so content expands when the sidebar is collapsed */114 .main .block-container {{115 padding-top: 1.5rem;116 padding-bottom: 4rem;117 padding-left: 2.5rem;118 padding-right: 2.5rem;119 max-width: 100%;120 }}121 @media (min-width: 1600px) {{122 .main .block-container {{123 padding-left: 4rem;124 padding-right: 4rem;125 }}126 }}127 128 /* Hide only the three-dot menu, Deploy button, and footer. */129 #MainMenu {{ display: none !important; }}130 footer {{ display: none !important; }}131 .stDeployButton {{ display: none !important; }}132 header[data-testid="stHeader"] {{133 background: transparent;134 z-index: 100;135 }}136 /* Hide only the toolbar actions (deploy/menu), NOT the sidebar toggle */137 [data-testid="stToolbarActions"] {{ display: none !important; }}138 139 /* Force the "re-open sidebar" button to always be visible and prominent140 so users are never stranded after collapsing the sidebar. */141 [data-testid="stSidebarCollapsedControl"],142 [data-testid="collapsedControl"],143 button[kind="header"][data-testid="baseButton-headerNoPadding"] {{144 visibility: visible !important;145 display: flex !important;146 opacity: 1 !important;147 z-index: 999 !important;148 }}149 [data-testid="stSidebarCollapsedControl"] button,150 [data-testid="collapsedControl"] button {{151 background: {COLORS['primary']} !important;152 color: white !important;153 border-radius: 8px !important;154 box-shadow: 0 2px 8px rgba(79,70,229,0.35) !important;155 }}156 [data-testid="stSidebarCollapsedControl"] button svg,157 [data-testid="collapsedControl"] button svg {{158 color: white !important;159 fill: white !important;160 }}161 /* Sidebar's own collapse chevron - keep obvious */162 [data-testid="stSidebarCollapseButton"] {{163 visibility: visible !important;164 display: flex !important;165 }}166 167 /* Sidebar */168 section[data-testid="stSidebar"] {{169 background: #FFFFFF;170 border-right: 1px solid {COLORS['border']};171 }}172 /* Only pin width when the sidebar is actually expanded, so it can173 collapse to 0 and let main content flush left. */174 section[data-testid="stSidebar"][aria-expanded="true"] {{175 min-width: 280px !important;176 width: 280px !important;177 }}178 /* Ensure the collapsed sidebar has no visible footprint */179 section[data-testid="stSidebar"][aria-expanded="false"] {{180 min-width: 0 !important;181 width: 0 !important;182 border-right: none !important;183 }}184 /* Streamlit's auto page-navigation list */185 [data-testid="stSidebarNav"] {{186 padding-top: 8px;187 }}188 [data-testid="stSidebarNav"] ul {{ padding: 0 8px; }}189 [data-testid="stSidebarNav"] a {{190 color: {COLORS['text']} !important;191 font-weight: 500;192 border-radius: 8px;193 padding: 6px 10px;194 }}195 [data-testid="stSidebarNav"] a:hover {{196 background: {COLORS['primary_light']};197 }}198 199 section[data-testid="stSidebar"] .stRadio label,200 section[data-testid="stSidebar"] p {{201 color: {COLORS['text']} !important;202 }}203 204 /* Typography */205 h1 {{206 font-weight: 700 !important;207 font-size: 1.75rem !important;208 letter-spacing: -0.02em;209 color: {COLORS['text']} !important;210 margin-bottom: 0.25rem !important;211 }}212 h2 {{213 font-weight: 600 !important;214 font-size: 1.25rem !important;215 letter-spacing: -0.01em;216 color: {COLORS['text']} !important;217 }}218 h3, h4 {{219 font-weight: 600 !important;220 color: {COLORS['text']} !important;221 }}222 223 /* KPI metric override */224 [data-testid="stMetric"] {{225 background: {COLORS['surface']};226 border: 1px solid {COLORS['border']};227 border-radius: 12px;228 padding: 18px 20px;229 box-shadow: 0 1px 2px rgba(15,23,42,0.04);230 }}231 [data-testid="stMetricLabel"] {{232 font-size: 0.8rem !important;233 color: {COLORS['text_muted']} !important;234 font-weight: 500;235 text-transform: uppercase;236 letter-spacing: 0.04em;237 white-space: normal !important;238 overflow: visible !important;239 text-overflow: clip !important;240 }}241 [data-testid="stMetricValue"] {{242 font-size: 1.5rem !important;243 font-weight: 700 !important;244 color: {COLORS['text']} !important;245 line-height: 1.1;246 white-space: normal !important;247 overflow: visible !important;248 text-overflow: clip !important;249 overflow-wrap: anywhere;250 }}251 [data-testid="stMetricDelta"] {{252 font-size: 0.8rem !important;253 font-weight: 500 !important;254 }}255 256 /* Divider */257 hr {{ border-top: 1px solid {COLORS['border']} !important; }}258 259 /* Tabs */260 .stTabs [data-baseweb="tab-list"] {{261 gap: 4px;262 border-bottom: 1px solid {COLORS['border']};263 }}264 .stTabs [data-baseweb="tab"] {{265 background: transparent;266 border: none;267 color: {COLORS['text_muted']};268 font-weight: 500;269 padding: 10px 16px;270 }}271 .stTabs [aria-selected="true"] {{272 color: {COLORS['primary']} !important;273 border-bottom: 2px solid {COLORS['primary']} !important;274 }}275 276 /* DataFrame */277 .stDataFrame {{278 border: 1px solid {COLORS['border']};279 border-radius: 10px;280 overflow: hidden;281 background: {COLORS['surface']} !important;282 }}283 284 /* Plotly chart containers */285 .js-plotly-plot {{ border-radius: 8px; }}286 287 /* Buttons */288 .stButton > button {{289 background: {COLORS['primary']};290 color: white;291 border: none;292 border-radius: 8px;293 font-weight: 500;294 padding: 8px 16px;295 transition: all 0.15s;296 }}297 .stButton > button:hover {{298 background: {COLORS['primary_dark']};299 transform: translateY(-1px);300 }}301 302 /* Selectbox / slider labels */303 .stSelectbox label, .stSlider label {{304 color: {COLORS['text_muted']} !important;305 font-weight: 500;306 font-size: 0.85rem;307 }}308 309 /* Caption */310 .stCaption, [data-testid="stCaptionContainer"] {{311 color: {COLORS['text_muted']} !important;312 }}313 314 /* Card container helper */315 .pc-card {{316 background: {COLORS['surface']};317 border: 1px solid {COLORS['border']};318 border-radius: 12px;319 padding: 20px 22px;320 box-shadow: 0 1px 2px rgba(15,23,42,0.04);321 margin-bottom: 16px;322 }}323 .pc-card-title {{324 font-size: 0.95rem;325 font-weight: 600;326 color: {COLORS['text']};327 margin-bottom: 4px;328 }}329 .pc-card-sub {{330 font-size: 0.8rem;331 color: {COLORS['text_muted']};332 margin-bottom: 14px;333 }}334 335 /* Page hero */336 .pc-hero {{337 background: linear-gradient(135deg, #EEF2FF 0%, #F5F3FF 100%);338 border: 1px solid {COLORS['border']};339 border-radius: 14px;340 padding: 22px 26px;341 margin-bottom: 22px;342 }}343 .pc-hero-eyebrow {{344 font-size: 0.72rem;345 font-weight: 600;346 letter-spacing: 0.12em;347 color: {COLORS['primary']};348 text-transform: uppercase;349 }}350 .pc-hero-title {{351 font-size: 1.65rem;352 font-weight: 700;353 color: {COLORS['text']};354 margin: 4px 0 6px 0;355 letter-spacing: -0.02em;356 }}357 .pc-hero-sub {{358 font-size: 0.95rem;359 color: {COLORS['text_muted']};360 max-width: 780px;361 }}362 363 @media (max-width: 900px) {{364 .main .block-container {{365 padding-top: 1.1rem !important;366 padding-left: 1rem !important;367 padding-right: 1rem !important;368 padding-bottom: 2.5rem !important;369 }}370 section[data-testid="stSidebar"][aria-expanded="true"] {{371 max-width: min(82vw, 300px) !important;372 min-width: min(82vw, 300px) !important;373 width: min(82vw, 300px) !important;374 }}375 .pc-hero {{376 padding: 18px 20px;377 margin-bottom: 18px;378 }}379 .pc-hero-title {{380 font-size: 1.45rem;381 line-height: 1.18;382 }}383 .pc-hero-sub {{384 font-size: 0.9rem;385 line-height: 1.55;386 }}387 [data-testid="stMetric"] {{388 padding: 14px 16px;389 min-height: 112px;390 }}391 [data-testid="stMetricLabel"] {{392 font-size: 0.74rem !important;393 line-height: 1.25 !important;394 }}395 [data-testid="stMetricValue"] {{396 font-size: 1.35rem !important;397 line-height: 1.12 !important;398 }}399 .stTabs [data-baseweb="tab-list"] {{400 overflow-x: auto;401 flex-wrap: nowrap;402 }}403 .stTabs [data-baseweb="tab"] {{404 white-space: nowrap;405 padding: 9px 12px;406 }}407 }}408 409 @media (max-width: 520px) {{410 .main .block-container {{411 padding-left: 0.85rem !important;412 padding-right: 0.85rem !important;413 }}414 .pc-card,415 .pc-hero,416 .pc-insight {{417 border-radius: 10px;418 }}419 }}420 421 /* Insight card */422 .pc-insight {{423 background: {COLORS['surface']};424 border: 1px solid {COLORS['border']};425 border-left: 3px solid {COLORS['primary']};426 border-radius: 10px;427 padding: 14px 18px;428 margin-bottom: 10px;429 }}430 .pc-insight.success {{ border-left-color: {COLORS['success']}; }}431 .pc-insight.warning {{ border-left-color: {COLORS['warning']}; }}432 .pc-insight.danger {{ border-left-color: {COLORS['danger']}; }}433 .pc-insight-title {{434 font-weight: 600;435 font-size: 0.9rem;436 color: {COLORS['text']};437 margin-bottom: 2px;438 }}439 .pc-insight-body {{440 color: {COLORS['text_muted']};441 font-size: 0.88rem;442 line-height: 1.5;443 }}444 445 /* Badges */446 .pc-badge {{447 display: inline-block;448 padding: 2px 10px;449 border-radius: 999px;450 font-size: 0.72rem;451 font-weight: 600;452 letter-spacing: 0.02em;453 }}454 .pc-badge.success {{ background: {COLORS['success_bg']}; color: #065F46; }}455 .pc-badge.warning {{ background: {COLORS['warning_bg']}; color: #92400E; }}456 .pc-badge.danger {{ background: {COLORS['danger_bg']}; color: #991B1B; }}457 .pc-badge.info {{ background: {COLORS['info_bg']}; color: #1E40AF; }}458 .pc-badge.neutral {{ background: #F1F5F9; color: {COLORS['text_muted']}; }}459 460 /* Verdict banner */461 .pc-verdict {{462 border-radius: 14px;463 padding: 22px 26px;464 margin-bottom: 22px;465 border: 1px solid transparent;466 }}467 .pc-verdict .label {{468 font-size: 0.72rem;469 letter-spacing: 0.14em;470 text-transform: uppercase;471 font-weight: 700;472 opacity: 0.8;473 }}474 .pc-verdict .title {{475 font-size: 1.9rem;476 font-weight: 700;477 letter-spacing: -0.02em;478 margin: 2px 0 6px 0;479 }}480 .pc-verdict .sub {{481 font-size: 0.95rem;482 opacity: 0.85;483 }}484 .pc-verdict.ship {{ background: {COLORS['success_bg']}; border-color: #A7F3D0; color: #064E3B; }}485 .pc-verdict.iterate {{ background: {COLORS['warning_bg']}; border-color: #FDE68A; color: #78350F; }}486 .pc-verdict.reject {{ background: {COLORS['danger_bg']}; border-color: #FECACA; color: #7F1D1D; }}487 488 /* Nav pills (Home) */489 .pc-navgrid {{490 display: grid;491 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));492 gap: 14px;493 margin-top: 10px;494 }}495 .pc-navcard {{496 background: {COLORS['surface']};497 border: 1px solid {COLORS['border']};498 border-radius: 12px;499 padding: 16px 18px;500 transition: all 0.15s;501 }}502 .pc-navcard:hover {{503 border-color: {COLORS['primary']};504 transform: translateY(-1px);505 box-shadow: 0 4px 10px rgba(79,70,229,0.08);506 }}507 .pc-navcard .n {{508 font-size: 0.7rem;509 font-weight: 700;510 color: {COLORS['primary']};511 letter-spacing: 0.12em;512 }}513 .pc-navcard .t {{514 font-weight: 600;515 font-size: 1rem;516 margin: 2px 0 4px 0;517 color: {COLORS['text']};518 }}519 .pc-navcard .d {{520 font-size: 0.82rem;521 color: {COLORS['text_muted']};522 line-height: 1.4;523 }}524 525 /* Section header (title + subtitle) */526 .pc-section-title {{527 font-size: 1.05rem;528 font-weight: 600;529 color: {COLORS['text']};530 margin-top: 18px;531 margin-bottom: 2px;532 }}533 .pc-section-sub {{534 font-size: 0.85rem;535 color: {COLORS['text_muted']};536 margin-bottom: 14px;537 }}538</style>539"""540 541 542def plotly_layout(**overrides: Any) -> dict[str, Any]:543 """Return a clean white-theme layout dict for Plotly figures."""544 base: dict[str, Any] = dict(545 template="plotly_white",546 font=dict(family="Inter, -apple-system, sans-serif", size=12, color=COLORS["text"]),547 paper_bgcolor=COLORS["surface"],548 plot_bgcolor=COLORS["surface"],549 margin=dict(l=20, r=20, t=40, b=20),550 xaxis=dict(551 showgrid=False,552 showline=False,553 linecolor=COLORS["border"],554 tickfont=dict(color=COLORS["text_muted"], size=11),555 ),556 yaxis=dict(557 showgrid=True,558 gridcolor=COLORS["border"],559 gridwidth=1,560 showline=False,561 tickfont=dict(color=COLORS["text_muted"], size=11),562 ),563 colorway=CATEGORICAL,564 hoverlabel=dict(bgcolor="white", font_size=12, font_family="Inter",565 bordercolor=COLORS["border"]),566 legend=dict(bgcolor="rgba(255,255,255,0)", bordercolor=COLORS["border"],567 font=dict(color=COLORS["text_muted"], size=11)),568 )569 base.update(overrides)570 return base571 572 573def style_fig(fig: go.Figure, **overrides: Any) -> go.Figure:574 fig.update_layout(**plotly_layout(**overrides))575 return fig576 577 578def hero(eyebrow: str, title: str, subtitle: str) -> None:579 st.markdown(580 f"""581 <div class="pc-hero">582 <div class="pc-hero-eyebrow">{eyebrow}</div>583 <div class="pc-hero-title">{title}</div>584 <div class="pc-hero-sub">{subtitle}</div>585 </div>586 """,587 unsafe_allow_html=True,588 )589 590 591def section(title: str, subtitle: str = "") -> None:592 sub = f'<div class="pc-section-sub">{subtitle}</div>' if subtitle else ""593 st.markdown(594 f'<div class="pc-section-title">{title}</div>{sub}',595 unsafe_allow_html=True,596 )597 598 599def insight_card(title: str, body: str, tone: str = "") -> None:600 cls = f"pc-insight {tone}" if tone else "pc-insight"601 st.markdown(602 f'<div class="{cls}"><div class="pc-insight-title">{title}</div>'603 f'<div class="pc-insight-body">{body}</div></div>',604 unsafe_allow_html=True,605 )606 607 608def badge(text: str, tone: str = "neutral") -> str:609 return f'<span class="pc-badge {tone}">{text}</span>'610 611 612def verdict_banner(recommendation: str, rationale: str) -> None:613 rec = (recommendation or "").lower()614 mapping = {615 "ship": ("ship", "✓ SHIP", "Clear win on primary metric with guardrails intact."),616 "iterate": ("iterate", "◐ ITERATE", "Directional but not conclusive. Needs more data or refinement."),617 "reject": ("reject", "✕ REJECT", "Primary metric did not move, or a guardrail was breached."),618 }619 tone, title, default_sub = mapping.get(rec, ("iterate", rec.upper() or "-", ""))620 st.markdown(621 f"""622 <div class="pc-verdict {tone}">623 <div class="label">Experiment Verdict</div>624 <div class="title">{title}</div>625 <div class="sub">{rationale or default_sub}</div>626 </div>627 """,628 unsafe_allow_html=True,629 )630 631 632def kpi_row(items: Sequence[dict[str, Any]]) -> None:633 """Render a row of KPI cards.634 635 Each item: {label, value, delta (optional), delta_tone (optional: 'success'/'danger'/'neutral')}.636 """637 cols = st.columns(len(items))638 for col, item in zip(cols, items, strict=False):639 delta = item.get("delta")640 if delta is None:641 col.metric(item["label"], item["value"])642 else:643 col.metric(item["label"], item["value"], delta)644 645 646def sidebar_brand() -> None:647 """Brand mark at the top of the sidebar."""648 with st.sidebar:649 st.markdown(650 f"""651 <div style="padding: 8px 4px 16px 4px; border-bottom: 1px solid {COLORS['border']}; margin-bottom: 12px;">652 <div style="display: flex; align-items: center; gap: 10px;">653 <div style="width: 34px; height: 34px; border-radius: 9px;654 background: linear-gradient(135deg, #4F46E5, #7C3AED);655 display:flex; align-items:center; justify-content:center;656 color:white; font-weight:700; font-size:18px;">P</div>657 <div>658 <div style="font-weight:700; font-size:1.02rem; color:{COLORS['text']}; line-height:1.1;">PulseCommerce</div>659 <div style="font-size:0.72rem; color:{COLORS['text_muted']};">Commerce Intelligence</div>660 </div>661 </div>662 </div>663 """,664 unsafe_allow_html=True,665 )666 