Spaces:
Sleeping
Sleeping
Fix: proposal </div> render, persist dossier uploads across navigation, robust dark-mode bridge
f9a06ca verified | """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> | |
| """ | |
| 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) | |