Spaces:
Sleeping
Sleeping
Fix: proposal </div> render, persist dossier uploads across navigation, robust dark-mode bridge
Browse files- app/ui/dossier_screen.py +89 -29
- app/ui/theme.py +55 -17
app/ui/dossier_screen.py
CHANGED
|
@@ -216,13 +216,27 @@ def is_cloud_mode() -> bool:
|
|
| 216 |
return bool(os.getenv("SPACE_ID") or os.getenv("UPWORK_CLOUD"))
|
| 217 |
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
"""
|
| 227 |
base = st.session_state.get("_dossier_upload_dir")
|
| 228 |
if not base or not Path(base).is_dir():
|
|
@@ -234,15 +248,66 @@ def _materialize_uploads(files) -> str:
|
|
| 234 |
existing.unlink()
|
| 235 |
except OSError: # pragma: no cover - best effort cleanup
|
| 236 |
pass
|
| 237 |
-
for
|
| 238 |
-
#
|
| 239 |
-
safe_name = Path(f.name).name
|
| 240 |
if not safe_name:
|
| 241 |
continue
|
| 242 |
-
(base_path / safe_name).write_bytes(
|
| 243 |
return str(base_path)
|
| 244 |
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
def _card_upload_dossier(debug: bool) -> None:
|
| 247 |
"""Cloud variant of the dossier step: upload files, then continue.
|
| 248 |
|
|
@@ -255,26 +320,21 @@ def _card_upload_dossier(debug: bool) -> None:
|
|
| 255 |
theme.section_label(
|
| 256 |
"Step 1 Β· Upload dossier files" if debug else "Upload dossier files"
|
| 257 |
)
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
"
|
| 266 |
-
"
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
if path != (st.session_state.get("dossier_folder_path") or "").strip():
|
| 273 |
-
st.session_state["dossier_folder_path"] = path
|
| 274 |
-
_reset_downstream()
|
| 275 |
-
st.caption(f"{len(files)} file(s) ready.")
|
| 276 |
|
| 277 |
-
has_files = bool(files)
|
| 278 |
if debug:
|
| 279 |
# Validate against the uploaded folder so the file count / strength
|
| 280 |
# panel works the same as the desktop debug flow.
|
|
|
|
| 216 |
return bool(os.getenv("SPACE_ID") or os.getenv("UPWORK_CLOUD"))
|
| 217 |
|
| 218 |
|
| 219 |
+
# Session keys for the persisted upload set. The records (name + bytes) are
|
| 220 |
+
# kept in session β NOT just in the file_uploader widget β because the widget
|
| 221 |
+
# returns an empty list after the user navigates to another step and back, so
|
| 222 |
+
# relying on it alone makes the dossier appear "lost". Kept until the page is
|
| 223 |
+
# reloaded (new session) or the user clears them.
|
| 224 |
+
_UPLOAD_RECORDS_KEY = "dossier_uploaded_files"
|
| 225 |
+
_UPLOAD_SEQ_KEY = "dossier_uploader_seq"
|
| 226 |
|
| 227 |
+
|
| 228 |
+
def _upload_signature(records: list[dict]) -> tuple:
|
| 229 |
+
"""(name, size) per file β detects a genuinely changed upload set."""
|
| 230 |
+
return tuple((r["name"], len(r["bytes"])) for r in records)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _materialize_records(records: list[dict]) -> str:
|
| 234 |
+
"""Write the persisted upload records into this session's temp dir.
|
| 235 |
+
|
| 236 |
+
Fully replaces the previous set. The directory lives under the OS temp
|
| 237 |
+
location and holds the files only for the lifetime of the session; it is
|
| 238 |
+
never committed or shared. Returns the directory path for the standard
|
| 239 |
+
folder pipeline.
|
| 240 |
"""
|
| 241 |
base = st.session_state.get("_dossier_upload_dir")
|
| 242 |
if not base or not Path(base).is_dir():
|
|
|
|
| 248 |
existing.unlink()
|
| 249 |
except OSError: # pragma: no cover - best effort cleanup
|
| 250 |
pass
|
| 251 |
+
for r in records:
|
| 252 |
+
safe_name = Path(r["name"]).name # strip any directory components
|
|
|
|
| 253 |
if not safe_name:
|
| 254 |
continue
|
| 255 |
+
(base_path / safe_name).write_bytes(r["bytes"])
|
| 256 |
return str(base_path)
|
| 257 |
|
| 258 |
|
| 259 |
+
def _sync_dossier_uploads() -> list[dict]:
|
| 260 |
+
"""Reconcile the live uploader with the persisted record set.
|
| 261 |
+
|
| 262 |
+
* A new/changed upload replaces the stored records, re-materializes the
|
| 263 |
+
temp folder, and invalidates downstream evidence.
|
| 264 |
+
* An empty uploader (e.g. after navigating away and back) keeps the stored
|
| 265 |
+
records, so the dossier is never silently lost.
|
| 266 |
+
* If the records exist but their temp folder is gone (rare), it is rebuilt.
|
| 267 |
+
|
| 268 |
+
Returns the current persisted records.
|
| 269 |
+
"""
|
| 270 |
+
ss = st.session_state
|
| 271 |
+
accepted = sorted(ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS)
|
| 272 |
+
uploader_key = f"dossier_uploads_{ss.get(_UPLOAD_SEQ_KEY, 0)}"
|
| 273 |
+
files = st.file_uploader(
|
| 274 |
+
"Upload your profile evidence",
|
| 275 |
+
type=accepted,
|
| 276 |
+
accept_multiple_files=True,
|
| 277 |
+
key=uploader_key,
|
| 278 |
+
help=(
|
| 279 |
+
"Portfolio, rΓ©sumΓ©, testimonials, past proposals. Your files are "
|
| 280 |
+
"processed in this session only and are never stored on the server."
|
| 281 |
+
),
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
stored: list[dict] = ss.get(_UPLOAD_RECORDS_KEY) or []
|
| 285 |
+
if files:
|
| 286 |
+
records = [{"name": f.name, "bytes": f.getvalue()} for f in files]
|
| 287 |
+
if _upload_signature(records) != _upload_signature(stored):
|
| 288 |
+
ss[_UPLOAD_RECORDS_KEY] = records
|
| 289 |
+
ss["dossier_folder_path"] = _materialize_records(records)
|
| 290 |
+
_reset_downstream()
|
| 291 |
+
stored = records
|
| 292 |
+
elif stored:
|
| 293 |
+
# Uploader came back empty (navigation), but we still hold the files β
|
| 294 |
+
# make sure the temp folder still backs them for the read pipeline.
|
| 295 |
+
path = (ss.get("dossier_folder_path") or "").strip()
|
| 296 |
+
if not path or not Path(path).is_dir():
|
| 297 |
+
ss["dossier_folder_path"] = _materialize_records(stored)
|
| 298 |
+
return stored
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _clear_dossier_uploads() -> None:
|
| 302 |
+
"""Remove the persisted dossier upload set and reset the uploader widget."""
|
| 303 |
+
ss = st.session_state
|
| 304 |
+
ss[_UPLOAD_RECORDS_KEY] = []
|
| 305 |
+
ss["dossier_folder_path"] = ""
|
| 306 |
+
# Bump the uploader key so the widget itself re-mounts empty.
|
| 307 |
+
ss[_UPLOAD_SEQ_KEY] = ss.get(_UPLOAD_SEQ_KEY, 0) + 1
|
| 308 |
+
_reset_downstream()
|
| 309 |
+
|
| 310 |
+
|
| 311 |
def _card_upload_dossier(debug: bool) -> None:
|
| 312 |
"""Cloud variant of the dossier step: upload files, then continue.
|
| 313 |
|
|
|
|
| 320 |
theme.section_label(
|
| 321 |
"Step 1 Β· Upload dossier files" if debug else "Upload dossier files"
|
| 322 |
)
|
| 323 |
+
stored = _sync_dossier_uploads()
|
| 324 |
+
has_files = bool(stored)
|
| 325 |
+
|
| 326 |
+
if has_files:
|
| 327 |
+
col_ready, col_clear = st.columns([3, 1])
|
| 328 |
+
col_ready.caption(f"{len(stored)} file(s) ready.")
|
| 329 |
+
if col_clear.button(
|
| 330 |
+
"Remove all",
|
| 331 |
+
key="dossier_clear_uploads_btn",
|
| 332 |
+
use_container_width=True,
|
| 333 |
+
help="Clear these files so you can upload a different set.",
|
| 334 |
+
):
|
| 335 |
+
_clear_dossier_uploads()
|
| 336 |
+
st.rerun()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
|
|
|
| 338 |
if debug:
|
| 339 |
# Validate against the uploaded folder so the file count / strength
|
| 340 |
# panel works the same as the desktop debug flow.
|
app/ui/theme.py
CHANGED
|
@@ -416,18 +416,41 @@ def inject_css() -> None:
|
|
| 416 |
|
| 417 |
|
| 418 |
def apply_step_accent(step_key: str, *, dark: bool = False) -> None:
|
| 419 |
-
"""Re-tint the whole screen to the current step's signature hue.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
h, c, h2 = STEP_COLORS.get(step_key, _DEFAULT_ACCENT)
|
| 421 |
theme_val = "dark" if dark else "light"
|
| 422 |
components.html(
|
| 423 |
f"""<script>
|
| 424 |
(function(){{
|
| 425 |
-
var
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
}})();
|
| 432 |
</script>""",
|
| 433 |
height=0,
|
|
@@ -478,8 +501,14 @@ def verdict_banner(beginner_result: str, headline: str, body: str) -> None:
|
|
| 478 |
# ββ Proposal document ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 479 |
|
| 480 |
|
| 481 |
-
def
|
| 482 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
import html as _html
|
| 484 |
import re as _re
|
| 485 |
|
|
@@ -515,15 +544,24 @@ def proposal_doc(text: str, word_count: int, file_count: int = 0) -> None:
|
|
| 515 |
else ""
|
| 516 |
)
|
| 517 |
wc_label = f"{word_count} words" if word_count else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
st.markdown(
|
| 519 |
-
|
| 520 |
-
<span class="wordcount">{wc_label}</span>
|
| 521 |
-
<div class="toolbar-spacer"></div>
|
| 522 |
-
</div>
|
| 523 |
-
<div class="proposal-doc">
|
| 524 |
-
{paras_html}
|
| 525 |
-
{grounded}
|
| 526 |
-
</div>""",
|
| 527 |
unsafe_allow_html=True,
|
| 528 |
)
|
| 529 |
|
|
|
|
| 416 |
|
| 417 |
|
| 418 |
def apply_step_accent(step_key: str, *, dark: bool = False) -> None:
|
| 419 |
+
"""Re-tint the whole screen to the current step's signature hue + theme.
|
| 420 |
+
|
| 421 |
+
The design tokens live under ``[data-theme]`` on the parent ``<html>``, so
|
| 422 |
+
this bridges from the component iframe to set it. It is hardened against the
|
| 423 |
+
two things that made dark mode feel flaky: the parent DOM not being ready
|
| 424 |
+
when the iframe first runs (so it retries briefly), and a write throwing in
|
| 425 |
+
some embedding contexts (so it is wrapped in try/catch). It also sets
|
| 426 |
+
``color-scheme`` so native widgets (uploader, inputs) adopt the dark look.
|
| 427 |
+
"""
|
| 428 |
h, c, h2 = STEP_COLORS.get(step_key, _DEFAULT_ACCENT)
|
| 429 |
theme_val = "dark" if dark else "light"
|
| 430 |
components.html(
|
| 431 |
f"""<script>
|
| 432 |
(function(){{
|
| 433 |
+
var theme = '{theme_val}';
|
| 434 |
+
function apply(){{
|
| 435 |
+
try {{
|
| 436 |
+
var r = window.parent.document.documentElement;
|
| 437 |
+
if (!r) return false;
|
| 438 |
+
r.setAttribute('data-theme', theme);
|
| 439 |
+
r.style.setProperty('color-scheme', theme);
|
| 440 |
+
r.style.setProperty('--accent-h', '{h}');
|
| 441 |
+
r.style.setProperty('--accent-c', '{c}');
|
| 442 |
+
r.style.setProperty('--accent2-h', '{h2}');
|
| 443 |
+
try {{ window.parent.document.body.style.colorScheme = theme; }} catch (e) {{}}
|
| 444 |
+
return true;
|
| 445 |
+
}} catch (e) {{ return false; }}
|
| 446 |
+
}}
|
| 447 |
+
if (!apply()) {{
|
| 448 |
+
// Parent not ready yet β retry a few times, then give up quietly.
|
| 449 |
+
var n = 0;
|
| 450 |
+
var t = setInterval(function(){{
|
| 451 |
+
if (apply() || ++n > 20) clearInterval(t);
|
| 452 |
+
}}, 50);
|
| 453 |
+
}}
|
| 454 |
}})();
|
| 455 |
</script>""",
|
| 456 |
height=0,
|
|
|
|
| 501 |
# ββ Proposal document ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 502 |
|
| 503 |
|
| 504 |
+
def _proposal_doc_html(text: str, word_count: int, file_count: int = 0) -> str:
|
| 505 |
+
"""Build the proposal card HTML as a single line (see :func:`proposal_doc`).
|
| 506 |
+
|
| 507 |
+
Returned as one line with no leading indentation and no whitespace-only
|
| 508 |
+
gaps so Streamlit's markdown parser renders it as pure HTML β a blank line
|
| 509 |
+
(e.g. when ``grounded`` is empty) would otherwise terminate the HTML block
|
| 510 |
+
and turn the trailing ``</div>`` into a literal code block.
|
| 511 |
+
"""
|
| 512 |
import html as _html
|
| 513 |
import re as _re
|
| 514 |
|
|
|
|
| 544 |
else ""
|
| 545 |
)
|
| 546 |
wc_label = f"{word_count} words" if word_count else ""
|
| 547 |
+
# Build as ONE line with no leading indentation and no whitespace-only
|
| 548 |
+
# gaps. Streamlit runs this through a markdown parser: a blank/whitespace
|
| 549 |
+
# line (e.g. when ``grounded`` is empty) would terminate the HTML block and
|
| 550 |
+
# the next indented ``</div>`` would render as a literal code block. Keeping
|
| 551 |
+
# it single-line avoids that entirely.
|
| 552 |
+
return (
|
| 553 |
+
f'<div class="doc-toolbar">'
|
| 554 |
+
f'<span class="wordcount">{wc_label}</span>'
|
| 555 |
+
f'<div class="toolbar-spacer"></div>'
|
| 556 |
+
f"</div>"
|
| 557 |
+
f'<div class="proposal-doc">{paras_html}{grounded}</div>'
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def proposal_doc(text: str, word_count: int, file_count: int = 0) -> None:
|
| 562 |
+
"""Render the styled proposal document card from the prototype."""
|
| 563 |
st.markdown(
|
| 564 |
+
_proposal_doc_html(text, word_count, file_count),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
unsafe_allow_html=True,
|
| 566 |
)
|
| 567 |
|