"""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 = ( '' '' '' ) # Extra CSS layered on top of design_system.css to bridge Streamlit's DOM # with the design's component classes. _BRIDGE_CSS = """ """ @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("", "").replace("", 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 ````, 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"""""", 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"""
{eyebrow}

{title}

{subtitle}

""", 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"""
{badge_label}

{headline}

{body}

""", 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 ```` 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 # ). 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"]*>", "", 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*(? for readable line breaks. def _para_html(p: str) -> str: escaped = _html.escape(p) escaped = escaped.replace("\n", "
") return f"

{escaped}

" paras_html = "".join(_para_html(p) for p in paragraphs) grounded = ( f'
' f'Grounded in {file_count} file{"s" if file_count != 1 else ""} from your dossier' f"
" 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 ```` would render as a literal code block. Keeping # it single-line avoids that entirely. return ( f'
' f'{wc_label}' f'
' f"
" f'
{paras_html}{grounded}
' ) 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'{label}' 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'{step_label}' ) if chip_label: right_bits.append(status_chip(chip_label, chip_kind)) right_html = ( '
' + "".join(right_bits) + "
" if right_bits else "" ) st.markdown( f"""

{title}

{subtitle}

{right_html}
""", unsafe_allow_html=True, ) st.divider() def section_label(text: str) -> None: st.markdown(f'

{text}

', unsafe_allow_html=True) def sidebar_title(text: str) -> None: st.markdown(f'

{text}

', unsafe_allow_html=True)