UpworkAutomation / app /ui /theme.py
ananyajoshi's picture
Fix: proposal </div> render, persist dossier uploads across navigation, robust dark-mode bridge
f9a06ca verified
Raw
History Blame Contribute Delete
27 kB
"""Shared visual theme β€” the "Spectrum" design system.
Loads ``app/assets/design_system.css`` (the full token + component CSS from
the design file), injects it once per render, and provides helper functions
for emitting the correct HTML markup so the CSS classes actually match.
Key architecture: Streamlit generates its own HTML, so Spectrum's component
classes (.card, .screen-head, .verdict, .proposal-doc, etc.) only take effect
when we explicitly emit matching HTML via st.markdown(unsafe_allow_html=True).
Streamlit's functional widgets (buttons, file_uploader, text_input) are kept
for interactivity but restyled via the CSS widget-override section.
"""
from __future__ import annotations
from functools import lru_cache
import streamlit as st
import streamlit.components.v1 as components
from app.paths import resource_path
# Status-chip kinds β†’ CSS class (legacy, used by a few callers).
_CHIP_KINDS = {
"ready": "ups-chip-ready",
"info": "ups-chip-info",
"missing": "ups-chip-missing",
"neutral": "ups-chip-neutral",
}
# Per-step signature color: (H, C, H2) in oklch. Light default.
STEP_COLORS: dict[str, tuple[float, float, float]] = {
"setup": (286, 0.21, 312),
"dossier": (248, 0.18, 226),
"screenshot": (52, 0.17, 32),
"confirmation": (352, 0.22, 330),
"analysis": (352, 0.22, 330),
"proposal": (168, 0.16, 150),
}
_DEFAULT_ACCENT = STEP_COLORS["setup"]
STEP_EYEBROWS = {
"setup": "Step 01 β€” Setup",
"dossier": "Step 02 β€” Dossier",
"screenshot": "Step 03 β€” Job Screenshot",
"analysis": "Step 04 β€” Analysis",
"proposal": "Step 05 β€” Proposal",
}
_FONTS_LINK = (
'<link rel="preconnect" href="https://fonts.googleapis.com">'
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
'<link href="https://fonts.googleapis.com/css2?'
'family=Space+Grotesk:wght@500;600;700'
'&family=Hanken+Grotesk:wght@400;500;600;700'
'&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">'
)
# Extra CSS layered on top of design_system.css to bridge Streamlit's DOM
# with the design's component classes.
_BRIDGE_CSS = """
<style>
/* ── Global resets for Streamlit chrome ─────────────────────────────── */
#MainMenu, footer, [data-testid="stHeader"] { display: none !important; }
[data-testid="stAppViewContainer"] { background: var(--bg) !important; }
[data-testid="stMain"] { background: transparent !important; }
.block-container {
padding: 28px 32px 80px !important;
max-width: 980px !important;
}
/* ── ALL TEXT FOLLOWS THE THEME (critical for dark-mode contrast) ─────────
Streamlit's own theme paints text a fixed dark colour; on a dark background
that becomes invisible. We force every text surface to the design tokens
(var(--text) / var(--text-muted)), which flip automatically with the active
data-theme, so contrast is correct in BOTH light and dark mode. */
[data-testid="stAppViewContainer"],
[data-testid="stSidebar"],
.stApp, .stMarkdown, .stText,
[data-testid="stMarkdownContainer"],
[data-testid="stMarkdownContainer"] p,
[data-testid="stMarkdownContainer"] li,
[data-testid="stMarkdownContainer"] ul,
[data-testid="stMarkdownContainer"] ol,
[data-testid="stMarkdownContainer"] strong,
[data-testid="stMarkdownContainer"] em,
[data-testid="stMarkdownContainer"] span,
[data-testid="stMarkdownContainer"] td,
[data-testid="stMarkdownContainer"] th,
[data-testid="stHeadingWithActionElements"],
[data-testid="stWidgetLabel"],
[data-testid="stWidgetLabel"] p,
[data-testid="stWidgetLabel"] label,
label, p, li {
color: var(--text) !important;
}
/* Muted/secondary text */
[data-testid="stCaptionContainer"],
[data-testid="stCaptionContainer"] p,
small, .stCaption {
color: var(--text-muted) !important;
}
/* Metrics */
[data-testid="stMetricValue"] { color: var(--text) !important; }
[data-testid="stMetricLabel"], [data-testid="stMetricLabel"] p { color: var(--text-muted) !important; }
/* Select boxes / dropdowns β€” closed control + the open menu */
[data-baseweb="select"] > div {
background: var(--bg-2) !important;
border-color: var(--border-2) !important;
color: var(--text) !important;
}
[data-baseweb="select"] div, [data-baseweb="select"] span, [data-baseweb="select"] input {
color: var(--text) !important;
}
[data-baseweb="popover"], [data-baseweb="menu"], [role="listbox"] {
background: var(--surface) !important;
}
[role="option"] { background: var(--surface) !important; color: var(--text) !important; }
[role="option"]:hover { background: var(--surface-2) !important; }
/* Expander header + body text */
[data-testid="stExpander"] summary,
[data-testid="stExpander"] summary span,
[data-testid="stExpander"] p { color: var(--text) !important; }
/* Radio / checkbox option labels */
[data-baseweb="radio"] div, [data-testid="stCheckbox"] label, [data-testid="stRadio"] label {
color: var(--text) !important;
}
/* ── Sidebar ────────────────────────────────────────────────────────── */
section[data-testid="stSidebar"] {
background: linear-gradient(180deg, var(--bg-2), var(--bg)) !important;
border-right: 1px solid var(--border) !important;
min-width: 240px !important; max-width: 260px !important;
}
section[data-testid="stSidebar"] .block-container {
padding: 20px 14px 24px !important;
}
/* ── Sidebar nav buttons β†’ step rail look ──────────────────────────── */
section[data-testid="stSidebar"] .stButton > button {
background: none !important;
border: 1px solid transparent !important;
border-radius: 10px !important;
text-align: left !important;
justify-content: flex-start !important;
font-family: var(--ff-body) !important;
font-size: 13.5px !important;
font-weight: 500 !important;
color: var(--text-muted) !important;
padding: 9px 11px !important;
height: auto !important;
width: 100% !important;
box-shadow: none !important;
transition: all 0.15s !important;
}
section[data-testid="stSidebar"] .stButton > button:hover {
background: var(--surface-2) !important;
color: var(--text) !important;
}
section[data-testid="stSidebar"] .stButton > button[kind="primary"] {
background: var(--card-grad) !important;
border-color: oklch(0.7 var(--accent-c, 0.15) var(--accent-h, 286) / .45) !important;
color: var(--text) !important;
font-weight: 600 !important;
box-shadow: inset 3px 0 0 var(--accent), 0 2px 8px oklch(0 0 0 / .12) !important;
}
/* Sidebar secondary buttons (e.g. "Change API Settings") β€” clear surface +
high-contrast text so they don't disappear into the sidebar in dark mode. */
section[data-testid="stSidebar"] .stButton > button[kind="secondary"] {
background: var(--surface-2) !important;
border: 1px solid var(--border-2) !important;
color: var(--text) !important;
font-weight: 600 !important;
}
section[data-testid="stSidebar"] .stButton > button[kind="secondary"]:hover {
background: var(--surface-3) !important;
color: var(--text) !important;
}
section[data-testid="stSidebar"] .stButton > button:disabled {
opacity: 0.45 !important;
cursor: not-allowed !important;
}
/* ── Main action buttons ────────────────────────────────────────────── */
.stButton > button {
font-family: var(--ff-body) !important;
font-weight: 600 !important;
font-size: 14px !important;
height: 44px !important;
border-radius: var(--radius-sm) !important;
transition: filter .15s, transform .12s !important;
}
.stButton > button[kind="primary"] {
color: var(--accent-fg) !important;
border: none !important;
background: linear-gradient(150deg, var(--accent), var(--accent-2)) !important;
box-shadow: 0 4px 16px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,.18) !important;
}
.stButton > button[kind="primary"]:hover { filter: brightness(1.06) !important; }
.stButton > button[kind="primary"]:active { transform: translateY(1px) !important; }
.stButton > button[kind="secondary"] {
background: var(--card-grad) !important;
color: var(--text) !important;
border: 1px solid var(--border-2) !important;
box-shadow: inset 0 1px 0 var(--hairline) !important;
}
/* Robust button targeting via Streamlit's stable test-ids. Newer Streamlit
uses data-testid="stBaseButton-secondary/primary" with a hard-coded white
background that the kind="..." selectors above don't always beat β€” these
rules guarantee secondary buttons (Clear screenshots, Analyze another job,
Change API Settings, download, etc.) follow the theme in dark mode. */
[data-testid="stBaseButton-secondary"],
.stDownloadButton > button {
background: var(--card-grad) !important;
color: var(--text) !important;
border: 1px solid var(--border-2) !important;
}
[data-testid="stBaseButton-secondary"]:hover,
.stDownloadButton > button:hover {
background: var(--surface-2) !important;
color: var(--text) !important;
border-color: var(--accent) !important;
}
section[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"] {
background: var(--surface-2) !important;
color: var(--text) !important;
border: 1px solid var(--border-2) !important;
font-weight: 600 !important;
}
section[data-testid="stSidebar"] [data-testid="stBaseButton-secondary"]:hover {
background: var(--surface-3) !important;
color: var(--text) !important;
border-color: var(--accent) !important;
}
/* Catch-all: NO button may lose its text colour on hover (Streamlit's default
hover recolours the label, which can vanish on a dark hover background).
Primary buttons are excluded β€” they keep their accent-foreground colour. */
.stButton > button:not([kind="primary"]):hover,
.stButton > button:not([kind="primary"]):hover *,
[data-testid="stBaseButton-secondary"]:hover,
[data-testid="stBaseButton-secondary"]:hover *,
.stDownloadButton > button:hover,
.stDownloadButton > button:hover * {
color: var(--text) !important;
}
[data-testid="stBaseButton-primary"] {
color: var(--accent-fg) !important;
border: none !important;
background: linear-gradient(150deg, var(--accent), var(--accent-2)) !important;
}
/* ── Inputs ─────────────────────────────────────────────────────────── */
/* Target the baseweb input WRAPPER too, otherwise the field shows a white
box in dark mode (the wrapper, not the <input>, carries the background). */
.stTextInput input, .stTextArea textarea, .stNumberInput input,
.stTextInput div[data-baseweb="input"],
.stTextInput div[data-baseweb="base-input"],
.stNumberInput div[data-baseweb="input"],
.stNumberInput div[data-baseweb="base-input"] {
background: var(--bg-2) !important;
border-color: var(--border-2) !important;
border-radius: var(--radius-sm) !important;
color: var(--text) !important;
font-family: var(--ff-body) !important;
font-size: 14px !important;
-webkit-text-fill-color: var(--text) !important;
}
.stTextInput input:focus, .stTextArea textarea:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 4px var(--ring) !important;
}
/* Password reveal (eye) button next to the API key field */
.stTextInput button { background: var(--bg-2) !important; color: var(--text-muted) !important; }
/* ── File uploader β†’ dropzone ───────────────────────────────────────── */
[data-testid="stFileUploaderDropzone"] {
background: var(--bg-2) !important;
border: 1.5px dashed var(--border-2) !important;
border-radius: var(--radius) !important;
}
[data-testid="stFileUploaderDropzone"]:hover {
border-color: var(--accent) !important;
}
/* Dropzone text + instructions ("Drag and drop", limits) must follow theme,
otherwise it's dark-on-dark and unreadable in dark mode. */
[data-testid="stFileUploaderDropzone"],
[data-testid="stFileUploaderDropzone"] span,
[data-testid="stFileUploaderDropzone"] div,
[data-testid="stFileUploaderDropzoneInstructions"],
[data-testid="stFileUploaderDropzoneInstructions"] span,
[data-testid="stFileUploaderDropzoneInstructions"] div {
color: var(--text) !important;
}
[data-testid="stFileUploaderDropzoneInstructions"] small,
[data-testid="stFileUploader"] small { color: var(--text-muted) !important; }
[data-testid="stFileUploaderDropzone"] button {
background: var(--surface-2) !important;
color: var(--text) !important;
border: 1px solid var(--border-2) !important;
}
[data-testid="stFileUploaderDropzone"] svg { color: var(--text-muted) !important; }
/* Uploaded-file rows (name + size) */
[data-testid="stFileUploaderFile"], [data-testid="stFileUploaderFile"] *,
[data-testid="stFileUploaderFileName"] { color: var(--text) !important; }
/* (Alert boxes keep Streamlit's default light pastel background + dark text,
which stays readable in both light and dark β€” so we don't override them.) */
/* ── Paste-screenshot component iframe β€” blend its frame into the page so
there's no white bar around it in dark mode (the button colours come from
Python args; this just removes the surrounding white iframe background). */
iframe[title*="paste_button"], iframe[title*="streamlit_paste_button"] {
background: transparent !important;
color-scheme: light dark;
/* Shrink the iframe to hug the button so its (white) inner document doesn't
show as a full-width bar in dark mode β€” we can't style across the iframe. */
width: 252px !important;
max-width: 252px !important;
border-radius: var(--radius-sm);
overflow: hidden;
}
[data-testid="stCustomComponentV1"] {
background: transparent !important;
width: 252px !important;
}
/* ── Bordered containers β†’ cards ────────────────────────────────────── */
[data-testid="stVerticalBlockBorderWrapper"] {
background: var(--card-grad) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: 0 2px 8px oklch(0 0 0 / .08), inset 0 1px 0 var(--hairline) !important;
}
/* ── Metrics ────────────────────────────────────────────────────────── */
[data-testid="stMetricValue"] {
font-family: var(--ff-display) !important;
font-size: 28px !important;
font-weight: 600 !important;
}
/* ── Progress bar ───────────────────────────────────────────────────── */
.stProgress > div > div > div > div {
background: linear-gradient(90deg, var(--accent), var(--accent-2)) !important;
}
/* ── Headings ───────────────────────────────────────────────────────── */
h1, h2, h3 {
font-family: var(--ff-display) !important;
letter-spacing: -0.02em !important;
color: var(--text) !important;
}
/* ── Dividers ───────────────────────────────────────────────────────── */
hr { border-color: var(--border) !important; margin: 14px 0 !important; }
/* ── Legacy ups-chip classes (kept for backward compat) ─────────────── */
.ups-chip {
display: inline-block; padding: 5px 13px; border-radius: var(--radius-pill);
font-family: var(--ff-mono); font-size: 0.78rem; font-weight: 600; white-space: nowrap;
}
.ups-chip-ready { background: var(--go-soft); color: var(--go-ink); border: 1px solid var(--go); }
.ups-chip-info { background: var(--accent-soft); color: var(--on-soft); border: 1px solid var(--accent-line); }
.ups-chip-missing { background: var(--stop-soft); color: var(--stop-ink); border: 1px solid var(--stop); }
.ups-chip-neutral { background: var(--warn-soft); color: var(--warn-ink); border: 1px solid var(--warn); }
/* ── Section labels ─────────────────────────────────────────────────── */
.ups-section {
font-family: var(--ff-mono); font-size: 0.68rem; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-faint);
margin: 0 0 0.5rem;
}
.ups-side-title {
font-family: var(--ff-mono); font-size: 0.7rem; font-weight: 700;
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--text-faint); margin: 0 0 10px;
}
/* ── Toggle ─────────────────────────────────────────────────────────── */
.stToggle label { font-family: var(--ff-body) !important; font-size: 13px !important; color: var(--text-muted) !important; }
/* ── Alert / warning boxes ──────────────────────────────────────────── */
[data-testid="stAlert"] {
border-radius: var(--radius) !important;
border: 1px solid var(--border) !important;
}
/* ── Tabs ───────────────────────────────────────────────────────────── */
.stTabs [aria-selected="true"] { color: var(--accent) !important; border-bottom-color: var(--accent) !important; }
</style>
"""
@lru_cache(maxsize=1)
def _design_css() -> str:
"""Read the Spectrum design system CSS once (cached), stripping stray style tags."""
try:
path = resource_path("app", "assets", "design_system.css")
css = path.read_text(encoding="utf-8")
except Exception:
return ""
return css.replace("</style>", "").replace("<style>", "")
def inject_css() -> None:
"""Inject fonts + the Spectrum design system + Streamlit bridge CSS."""
st.markdown(_FONTS_LINK, unsafe_allow_html=True)
css = _design_css()
if css:
st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
st.markdown(_BRIDGE_CSS, unsafe_allow_html=True)
def apply_step_accent(step_key: str, *, dark: bool = False) -> None:
"""Re-tint the whole screen to the current step's signature hue + theme.
The design tokens live under ``[data-theme]`` on the parent ``<html>``, so
this bridges from the component iframe to set it. It is hardened against the
two things that made dark mode feel flaky: the parent DOM not being ready
when the iframe first runs (so it retries briefly), and a write throwing in
some embedding contexts (so it is wrapped in try/catch). It also sets
``color-scheme`` so native widgets (uploader, inputs) adopt the dark look.
"""
h, c, h2 = STEP_COLORS.get(step_key, _DEFAULT_ACCENT)
theme_val = "dark" if dark else "light"
components.html(
f"""<script>
(function(){{
var theme = '{theme_val}';
function apply(){{
try {{
var r = window.parent.document.documentElement;
if (!r) return false;
r.setAttribute('data-theme', theme);
r.style.setProperty('color-scheme', theme);
r.style.setProperty('--accent-h', '{h}');
r.style.setProperty('--accent-c', '{c}');
r.style.setProperty('--accent2-h', '{h2}');
try {{ window.parent.document.body.style.colorScheme = theme; }} catch (e) {{}}
return true;
}} catch (e) {{ return false; }}
}}
if (!apply()) {{
// Parent not ready yet β€” retry a few times, then give up quietly.
var n = 0;
var t = setInterval(function(){{
if (apply() || ++n > 20) clearInterval(t);
}}, 50);
}}
}})();
</script>""",
height=0,
)
# ── Screen header ──────────────────────────────────────────────────────────
def screen_head(step_key: str, title: str, subtitle: str) -> None:
"""Render the eyebrow + title + subtitle header matching the design prototype."""
eyebrow = STEP_EYEBROWS.get(step_key, "")
st.markdown(
f"""<div class="screen-head">
<div class="eyebrow">{eyebrow}</div>
<h1 class="screen-title">{title}</h1>
<p class="screen-sub">{subtitle}</p>
</div>""",
unsafe_allow_html=True,
)
# ── Verdict banner ─────────────────────────────────────────────────────────
_VERDICT_TONE = {
"Apply Confidently": ("go", "Apply confidently"),
"Proceed With Caution": ("warn", "Proceed with caution"),
"Do Not Proceed": ("stop", "Do not proceed"),
}
def verdict_banner(beginner_result: str, headline: str, body: str) -> None:
"""Render the full-bleed coloured verdict banner from the prototype."""
tone, badge_label = _VERDICT_TONE.get(beginner_result, ("warn", beginner_result))
st.markdown(
f"""<div class="verdict {tone}">
<div>
<span class="verdict-badge">
<span class="dot"></span>{badge_label}
</span>
<h2>{headline}</h2>
<p>{body}</p>
</div>
</div>""",
unsafe_allow_html=True,
)
# ── Proposal document ──────────────────────────────────────────────────────
def _proposal_doc_html(text: str, word_count: int, file_count: int = 0) -> str:
"""Build the proposal card HTML as a single line (see :func:`proposal_doc`).
Returned as one line with no leading indentation and no whitespace-only
gaps so Streamlit's markdown parser renders it as pure HTML β€” a blank line
(e.g. when ``grounded`` is empty) would otherwise terminate the HTML block
and turn the trailing ``</div>`` into a literal code block.
"""
import html as _html
import re as _re
# 1. Strip any stray HTML tags the model may have emitted (e.g. a trailing
# </div>). Proposals are plain prose, so removing tag-like tokens is safe
# and prevents leaked markup showing as text or breaking the layout.
text = _re.sub(r"</?\s*[a-zA-Z][a-zA-Z0-9]*[^>]*>", "", text or "")
# 2. Put each "Step N:" / "Step N -" marker on its own paragraph so numbered
# steps always render on separate lines. We ONLY split on the explicit
# "Step N" marker β€” never on bare numbers like "Day 2" or "$2.5".
text = _re.sub(r"\s*(?<!\n)(?<!^)(Step\s+\d+\s*[:\-β€”])", r"\n\n\1", text)
# Split on double newlines to get paragraphs.
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
if not paragraphs:
paragraphs = [text.strip()]
# HTML-escape every paragraph (defence-in-depth) so anything tag-like that
# survived shows as literal text rather than rendering. Single newlines
# within a paragraph become <br> for readable line breaks.
def _para_html(p: str) -> str:
escaped = _html.escape(p)
escaped = escaped.replace("\n", "<br>")
return f"<p>{escaped}</p>"
paras_html = "".join(_para_html(p) for p in paragraphs)
grounded = (
f'<div class="evidence-note">'
f'Grounded in {file_count} file{"s" if file_count != 1 else ""} from your dossier'
f"</div>"
if file_count
else ""
)
wc_label = f"{word_count} words" if word_count else ""
# Build as ONE line with no leading indentation and no whitespace-only
# gaps. Streamlit runs this through a markdown parser: a blank/whitespace
# line (e.g. when ``grounded`` is empty) would terminate the HTML block and
# the next indented ``</div>`` would render as a literal code block. Keeping
# it single-line avoids that entirely.
return (
f'<div class="doc-toolbar">'
f'<span class="wordcount">{wc_label}</span>'
f'<div class="toolbar-spacer"></div>'
f"</div>"
f'<div class="proposal-doc">{paras_html}{grounded}</div>'
)
def proposal_doc(text: str, word_count: int, file_count: int = 0) -> None:
"""Render the styled proposal document card from the prototype."""
st.markdown(
_proposal_doc_html(text, word_count, file_count),
unsafe_allow_html=True,
)
# ── Legacy helpers (kept for backward compat with existing screen code) ────
def status_chip(label: str, kind: str = "neutral") -> str:
css_class = _CHIP_KINDS.get(kind, _CHIP_KINDS["neutral"])
return f'<span class="ups-chip {css_class}">{label}</span>'
def render_app_header(
title: str,
subtitle: str,
*,
chip_label: str | None = None,
chip_kind: str = "neutral",
step_label: str | None = None,
) -> None:
"""Minimal top-of-page header (app title line only β€” screens use screen_head)."""
right_bits = []
if step_label:
right_bits.append(
f'<span style="font-family:var(--ff-mono);font-size:11px;'
f'letter-spacing:.12em;text-transform:uppercase;color:var(--accent);'
f'border:1px solid var(--accent-line);background:var(--accent-soft);'
f'padding:4px 11px;border-radius:999px;">{step_label}</span>'
)
if chip_label:
right_bits.append(status_chip(chip_label, chip_kind))
right_html = (
'<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">'
+ "".join(right_bits) + "</div>"
if right_bits else ""
)
st.markdown(
f"""<div style="display:flex;align-items:flex-start;justify-content:space-between;
gap:1rem;flex-wrap:wrap;margin-bottom:.3rem;">
<div>
<p style="font-family:var(--ff-display);font-size:1.7rem;font-weight:700;
color:var(--text);margin:0;letter-spacing:-.02em;">{title}</p>
<p style="color:var(--text-muted);font-size:.9rem;margin:.2rem 0 0;">{subtitle}</p>
</div>
{right_html}
</div>""",
unsafe_allow_html=True,
)
st.divider()
def section_label(text: str) -> None:
st.markdown(f'<p class="ups-section">{text}</p>', unsafe_allow_html=True)
def sidebar_title(text: str) -> None:
st.markdown(f'<p class="ups-side-title">{text}</p>', unsafe_allow_html=True)