diff --git a/src/gui/_home.py b/src/gui/_home.py
index 0f5d0e8..5f7e12d 100644
--- a/src/gui/_home.py
+++ b/src/gui/_home.py
@@ -186,24 +186,28 @@ def _home_page() -> None:
unsafe_allow_html=True,
)
- st.markdown(f"### {t('upload.heading')}")
- st.caption(t("upload.intro_multi"))
-
# Source of truth for uploaded files. dict[name -> {"bytes", "size"}].
home_uploads: dict = st.session_state.setdefault("home_uploads", {})
- # File uploader — syncs into home_uploads via on_change. We deliberately
- # do NOT merge widget state into home_uploads at render time: navigation
- # can remount the widget with value ``[]``, and a render-time merge
- # would mistakenly leave home_uploads untouched while the user thinks
- # they're looking at empty state.
+ # Streamlit's file_uploader is the only path that actually receives
+ # bytes from the browser, but we don't want its dropzone UI to
+ # compete with the in-card "Add more files" button below. Park the
+ # whole widget off-screen via the ``dt-fileuploader-offscreen``
+ # CSS rule (declared in ``_DESIGN_TOKENS_CSS``) while keeping the
+ # underlying ```` reachable to JS — the Add
+ # button programmatically clicks it to open the OS file picker.
#
# ``on_change`` fires ONLY on user-initiated value changes (uploads
# and the widget's built-in "✕" remove). It does NOT fire on the
# remount-induced reset. That lets us treat the callback as ground
- # truth for both adds AND removes — fixing the previous bug where
- # the widget's "✕" appeared to do nothing because the file persisted
- # in home_uploads and immediately re-rendered in the list below.
+ # truth for both adds AND removes.
+ st.markdown(
+ '',
+ unsafe_allow_html=True,
+ )
st.file_uploader(
t("upload.uploader_label_multi"),
type=["csv", "tsv", "xlsx", "xls"],
@@ -211,108 +215,162 @@ def _home_page() -> None:
key="home_upload",
help=t("upload.uploader_help"),
on_change=_sync_uploader_to_home_uploads,
+ label_visibility="collapsed",
)
- # Persistent file list with per-file remove buttons. We render this
- # ourselves rather than trusting Streamlit's widget chrome because
- # the widget's "✕" only mutates widget-state, leaving home_uploads
- # out of sync.
- #
- # Two-phase click capture pattern (avoids the "hit-or-miss" click
- # losses we had previously):
- #
- # 1. ``st.button(key=stable_hash)`` returns True on the rerun where
- # it was clicked. We use a sha1 hash of the filename as the key
- # so it's identifier-safe regardless of spaces / dots / unicode
- # in the file name — Streamlit's widget-identity hashing on raw
- # filenames was the root cause of inconsistent removals.
- # 2. Inside a single pass we collect WHICH file to remove (if any),
- # then mutate state ONCE after the loop and rerun. Mutating mid
- # -loop while continuing to render other buttons risked
- # interleaving widget-key updates with state changes.
- if home_uploads:
- import hashlib
- # Files-card head: count + total size (mockup §section-head).
+ # ``Files`` section header — count + total size on the right, or
+ # "No files imported yet" when empty (mockup §section-head).
+ import hashlib
+ n_files = len(home_uploads)
+ if n_files:
total_bytes = sum(meta["size"] for meta in home_uploads.values())
if total_bytes >= 1024:
total_label = f"{total_bytes / 1024:.1f} KB"
else:
total_label = f"{total_bytes:,} B"
- n_files = len(home_uploads)
files_word = "file" if n_files == 1 else "files"
+ meta_html = (
+ f'{n_files} {files_word} · {_html.escape(total_label)} total'
+ )
+ else:
+ meta_html = "No files imported yet"
+ st.markdown(
+ '
'
+ f'
Files
'
+ f'{meta_html}'
+ '
',
+ unsafe_allow_html=True,
+ )
+
+ # Files card — always rendered. Body is file rows (if any) + the
+ # in-card "Add more files" button that triggers the off-screen
+ # file_uploader. Two-phase click capture for the X buttons: walk
+ # all rows once, accumulate ``to_remove`` if any was clicked,
+ # then mutate state + rerun ONCE after the loop.
+ to_remove: str | None = None
+ _DOC_SVG = (
+ ''
+ )
+ _PLUS_SVG = (
+ ''
+ )
+ with st.container(border=True):
+ for name in list(home_uploads.keys()):
+ digest = hashlib.sha1(
+ name.encode("utf-8"), usedforsecurity=False,
+ ).hexdigest()[:10]
+ col_name, col_size, col_x = st.columns([8, 1.6, 0.55])
+ col_name.markdown(
+ '
'
+ f'{_DOC_SVG}'
+ f'{_html.escape(name)}'
+ '
',
+ unsafe_allow_html=True,
+ )
+ col_size.markdown(
+ f'
'
+ f''
+ f'{home_uploads[name]["size"]:,} B'
+ '
',
+ unsafe_allow_html=True,
+ )
+ if col_x.button(
+ "✕",
+ key=f"_home_remove_{digest}",
+ help=f"Remove {name}",
+ type="tertiary",
+ ):
+ to_remove = name
+ # In-card "Add more files" — clicks the (off-screen)
+ # ``stFileUploaderDropzoneInput`` so the OS file picker opens.
+ # Inline ``onclick`` would be cleanest but Streamlit's HTML
+ # sanitizer strips event-handler attributes from
+ # ``unsafe_allow_html`` content; the wiring is done from
+ # ``_ADD_FILES_BUTTON_JS`` further down via ``st.iframe``.
st.markdown(
- '
',
+ '',
unsafe_allow_html=True,
)
- to_remove: str | None = None
- # Document-icon SVG used by every file row's icon chip
- # (mockup §file-row .file-icon).
- _DOC_SVG = (
- ''
- )
- with st.container(border=True):
- for name in list(home_uploads.keys()):
- digest = hashlib.sha1(
- name.encode("utf-8"), usedforsecurity=False,
- ).hexdigest()[:10]
- col_name, col_size, col_x = st.columns([8, 1.6, 0.55])
- col_name.markdown(
- '
'
- f'{_DOC_SVG}'
- f'{_html.escape(name)}'
- '
',
- unsafe_allow_html=True,
- )
- col_size.markdown(
- f'
'
- f''
- f'{home_uploads[name]["size"]:,} B'
- '
',
- unsafe_allow_html=True,
- )
- if col_x.button(
- "✕",
- key=f"_home_remove_{digest}",
- help=f"Remove {name}",
- type="tertiary",
- ):
- to_remove = name
+ # Wire the in-card "Add more files" button to the off-screen
+ # ``stFileUploaderDropzoneInput`` (Streamlit strips inline
+ # ``onclick`` attributes; we have to do the binding from a real
+ # script element, which Streamlit only ships through component
+ # iframes — same pattern as the sticky footer + Upload→Import
+ # rewriter). A ``MutationObserver`` re-wires after reruns when
+ # Streamlit remounts the button.
+ st.iframe(
+ """
+
+""",
+ height=1,
+ )
- if to_remove is not None:
- from src.audit import log_event
- log_event(
- "upload",
- f"Removed {to_remove}",
- filename=to_remove,
- )
- del home_uploads[to_remove]
- # Drop any findings/results tied to the removed file.
- findings_by_file_drop = st.session_state.get(
- "home_findings_by_file", {}
- )
- findings_by_file_drop.pop(to_remove, None)
- st.session_state["home_uploads"] = home_uploads
- st.session_state["home_findings_by_file"] = findings_by_file_drop
- # If we just removed the active upload, also clear the
- # singular ``home_uploaded_*`` keys so tool pages don't
- # pick up stale bytes; the next render will repopulate
- # them from whatever file is now first.
- if st.session_state.get("home_uploaded_name") == to_remove:
- st.session_state.pop("home_uploaded_name", None)
- st.session_state.pop("home_uploaded_size", None)
- st.session_state.pop("home_uploaded_bytes", None)
- st.rerun()
+ if to_remove is not None:
+ from src.audit import log_event
+ log_event(
+ "upload",
+ f"Removed {to_remove}",
+ filename=to_remove,
+ )
+ del home_uploads[to_remove]
+ # Drop any findings/results tied to the removed file.
+ findings_by_file_drop = st.session_state.get(
+ "home_findings_by_file", {}
+ )
+ findings_by_file_drop.pop(to_remove, None)
+ st.session_state["home_uploads"] = home_uploads
+ st.session_state["home_findings_by_file"] = findings_by_file_drop
+ # If we just removed the active upload, also clear the
+ # singular ``home_uploaded_*`` keys so tool pages don't
+ # pick up stale bytes; the next render will repopulate
+ # them from whatever file is now first.
+ if st.session_state.get("home_uploaded_name") == to_remove:
+ st.session_state.pop("home_uploaded_name", None)
+ st.session_state.pop("home_uploaded_size", None)
+ st.session_state.pop("home_uploaded_bytes", None)
+ st.rerun()
if not home_uploads:
- st.info(t("upload.empty_state"))
+ # Empty state — page ends cleanly after the Files card. The
+ # in-card "Add more files" button is the only affordance the
+ # user needs; the old ``upload.empty_state`` info alert was
+ # redundant and out of step with the mockup.
return
# Expose the first uploaded file via the singular ``home_uploaded_*``
diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py
index 8c22c1c..1c5ab98 100644
--- a/src/gui/components/_legacy.py
+++ b/src/gui/components/_legacy.py
@@ -520,6 +520,61 @@ div[data-testid="stContainer"][data-border="true"] {
font-feature-settings: "ss02";
}
+/* "+ Add more files" — last row of the files card (mockup §file-add).
+ The button stays in the document; ``onclick`` triggers a programmatic
+ click on Streamlit's (off-screen) file_uploader input so the OS file
+ picker opens. Negative margins bleed the button to the card edges so
+ the dashed top-border and corner radii match the surrounding card
+ chrome. */
+.dt-file-add {
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: calc(100% + 2rem);
+ padding: 12px 16px;
+ background: var(--surface-hover);
+ border: none;
+ border-top: 1px dashed var(--border-strong);
+ border-radius: 0 0 var(--r-lg) var(--r-lg);
+ cursor: pointer;
+ font-family: var(--font-sans) !important;
+ font-size: 13px !important;
+ font-weight: 500 !important;
+ color: var(--ink-secondary) !important;
+ margin: 14px -1rem -1rem;
+ line-height: 1;
+ transition: background 0.12s ease, color 0.12s ease;
+}
+.dt-file-add:hover {
+ background: var(--accent-fill);
+ color: var(--accent) !important;
+}
+.dt-file-add svg {
+ width: 14px; height: 14px;
+ stroke-width: 2;
+}
+
+/* Empty-state placeholder centered in the empty files card. */
+.dt-files-empty {
+ margin: 8px 0 4px !important;
+ text-align: center;
+ color: var(--ink-tertiary) !important;
+ font-size: 13px;
+}
+
+/* Streamlit's file_uploader is rendered off-screen so the OS file
+ picker stays wired up to our in-card "Add more files" button — its
+ input element is still reachable via JS ``.click()``. */
+.dt-fileuploader-offscreen [data-testid="stFileUploader"] {
+ position: absolute !important;
+ left: -10000px !important;
+ width: 1px !important;
+ height: 1px !important;
+ overflow: hidden !important;
+ pointer-events: none !important;
+}
+
/* ---------- Findings — per-file group cards (mockup §findings) ---------- */
.dt-finding-group-head {
display: flex;