ananyajoshi commited on
Commit
f9a06ca
Β·
verified Β·
1 Parent(s): b5d28f1

Fix: proposal </div> render, persist dossier uploads across navigation, robust dark-mode bridge

Browse files
Files changed (2) hide show
  1. app/ui/dossier_screen.py +89 -29
  2. 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
- def _materialize_uploads(files) -> str:
220
- """Write uploaded files into this session's temp dir; return its path.
 
 
 
 
 
221
 
222
- A re-upload fully replaces the previous set. The directory lives under the
223
- OS temp location and holds the files only for the lifetime of the session;
224
- it is never committed or shared. Returns the directory path so the standard
225
- folder pipeline can read it.
 
 
 
 
 
 
 
 
 
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 f in files:
238
- # Strip any directory components from the browser-supplied name.
239
- safe_name = Path(f.name).name
240
  if not safe_name:
241
  continue
242
- (base_path / safe_name).write_bytes(f.getvalue())
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
- accepted = sorted(ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS)
259
- files = st.file_uploader(
260
- "Upload your profile evidence",
261
- type=accepted,
262
- accept_multiple_files=True,
263
- key="dossier_uploads",
264
- help=(
265
- "Portfolio, rΓ©sumΓ©, testimonials, past proposals. Your files "
266
- "are processed in this session only and are never stored on "
267
- "the server."
268
- ),
269
- )
270
- if files:
271
- path = _materialize_uploads(files)
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 r = window.parent.document.documentElement;
426
- if (!r) return;
427
- r.setAttribute('data-theme', '{theme_val}');
428
- r.style.setProperty('--accent-h', '{h}');
429
- r.style.setProperty('--accent-c', '{c}');
430
- r.style.setProperty('--accent2-h', '{h2}');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 proposal_doc(text: str, word_count: int, file_count: int = 0) -> None:
482
- """Render the styled proposal document card from the prototype."""
 
 
 
 
 
 
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
- f"""<div class="doc-toolbar">
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