feat(home): in-card "+ Add more files" replaces Streamlit's dropzone

Mockup §file-add lands as the canonical import affordance:

- Streamlit's ``st.file_uploader`` widget is still mounted (only path
  that actually receives browser file events), but parked off-screen
  via a new ``[data-testid="stFileUploader"] { position:absolute;
  left:-10000px; … pointer-events:none }`` rule. Its hidden
  ``<input type="file">`` stays reachable to JavaScript.
- The Files card is now always rendered (header + bordered body).
  The bottom row of the card is a ``button.dt-file-add`` styled per
  mockup §file-add: dashed top border bleeding to the card edges,
  surface-hover background, ``+ Add more files`` text in
  ``--ink-secondary``, accent-fill on hover.
- A small ``<script>`` shipped through ``st.iframe`` wires the
  button: ``click → input.click()`` on the off-screen
  ``stFileUploaderDropzoneInput``. Streamlit's HTML sanitizer
  strips inline ``onclick`` from ``unsafe_allow_html`` content, so
  the binding has to come from a real script element — same pattern
  the sticky footer and Upload→Import rewriter use. A
  ``MutationObserver`` re-wires the button when Streamlit remounts
  it across reruns. The ``dataset.dtWired`` guard prevents double
  binding.

Section structure also tightened to match the mockup:

- Section heading is now ``<h2>Files</h2>`` (was ``### Import one
  or more files to start``) with the count + total size on the
  right of the same flex row. When no files: ``No files imported
  yet``. When files exist: ``1 file · 4.8 KB total``.
- Dropped the ``upload.intro_multi`` caption and the
  ``upload.empty_state`` info banner — the card itself plus the
  in-card Add button cover both prompts.
- Empty state now ends after the Files card (no stats / no action
  bar / no findings rendered) — matches mockup's single-section
  empty view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 00:56:11 +00:00
parent a9788ba712
commit 6703e2c15c
2 changed files with 211 additions and 98 deletions

View File

@@ -186,24 +186,28 @@ def _home_page() -> None:
unsafe_allow_html=True, 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"}]. # Source of truth for uploaded files. dict[name -> {"bytes", "size"}].
home_uploads: dict = st.session_state.setdefault("home_uploads", {}) home_uploads: dict = st.session_state.setdefault("home_uploads", {})
# File uploader — syncs into home_uploads via on_change. We deliberately # Streamlit's file_uploader is the only path that actually receives
# do NOT merge widget state into home_uploads at render time: navigation # bytes from the browser, but we don't want its dropzone UI to
# can remount the widget with value ``[]``, and a render-time merge # compete with the in-card "Add more files" button below. Park the
# would mistakenly leave home_uploads untouched while the user thinks # whole widget off-screen via the ``dt-fileuploader-offscreen``
# they're looking at empty state. # CSS rule (declared in ``_DESIGN_TOKENS_CSS``) while keeping the
# underlying ``<input type="file">`` 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 # ``on_change`` fires ONLY on user-initiated value changes (uploads
# and the widget's built-in "✕" remove). It does NOT fire on the # and the widget's built-in "✕" remove). It does NOT fire on the
# remount-induced reset. That lets us treat the callback as ground # remount-induced reset. That lets us treat the callback as ground
# truth for both adds AND removes — fixing the previous bug where # truth for both adds AND removes.
# the widget's "✕" appeared to do nothing because the file persisted st.markdown(
# in home_uploads and immediately re-rendered in the list below. '<style>[data-testid="stFileUploader"] {'
'position:absolute!important;left:-10000px!important;'
'width:1px!important;height:1px!important;overflow:hidden!important;'
'pointer-events:none!important;}</style>',
unsafe_allow_html=True,
)
st.file_uploader( st.file_uploader(
t("upload.uploader_label_multi"), t("upload.uploader_label_multi"),
type=["csv", "tsv", "xlsx", "xls"], type=["csv", "tsv", "xlsx", "xls"],
@@ -211,53 +215,50 @@ def _home_page() -> None:
key="home_upload", key="home_upload",
help=t("upload.uploader_help"), help=t("upload.uploader_help"),
on_change=_sync_uploader_to_home_uploads, on_change=_sync_uploader_to_home_uploads,
label_visibility="collapsed",
) )
# Persistent file list with per-file remove buttons. We render this # ``Files`` section header — count + total size on the right, or
# ourselves rather than trusting Streamlit's widget chrome because # "No files imported yet" when empty (mockup §section-head).
# 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 import hashlib
# Files-card head: count + total size (mockup §section-head). n_files = len(home_uploads)
if n_files:
total_bytes = sum(meta["size"] for meta in home_uploads.values()) total_bytes = sum(meta["size"] for meta in home_uploads.values())
if total_bytes >= 1024: if total_bytes >= 1024:
total_label = f"{total_bytes / 1024:.1f} KB" total_label = f"{total_bytes / 1024:.1f} KB"
else: else:
total_label = f"{total_bytes:,} B" total_label = f"{total_bytes:,} B"
n_files = len(home_uploads)
files_word = "file" if n_files == 1 else "files" 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( st.markdown(
'<div class="dt-files-section-head">' '<div class="dt-files-section-head">'
f'<h3>Imported files</h3>' f'<h2>Files</h2>'
f'<span class="dt-section-meta">{n_files} {files_word}' f'<span class="dt-section-meta">{meta_html}</span>'
f' · {_html.escape(total_label)} total</span>'
'</div>', '</div>',
unsafe_allow_html=True, 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 to_remove: str | None = None
# Document-icon SVG used by every file row's icon chip
# (mockup §file-row .file-icon).
_DOC_SVG = ( _DOC_SVG = (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">' '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
'<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>' '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>'
'<path d="M14 2v6h6"/>' '<path d="M14 2v6h6"/>'
'</svg>' '</svg>'
) )
_PLUS_SVG = (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
'<path d="M12 5v14M5 12h14"/>'
'</svg>'
)
with st.container(border=True): with st.container(border=True):
for name in list(home_uploads.keys()): for name in list(home_uploads.keys()):
digest = hashlib.sha1( digest = hashlib.sha1(
@@ -285,6 +286,60 @@ def _home_page() -> None:
type="tertiary", type="tertiary",
): ):
to_remove = name 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(
'<button class="dt-file-add" type="button">'
f'{_PLUS_SVG} Add more files'
'</button>',
unsafe_allow_html=True,
)
# 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(
"""
<script>
(function () {
function wire(doc) {
var btn = doc.querySelector('button.dt-file-add');
var input = doc.querySelector('[data-testid="stFileUploaderDropzoneInput"]');
if (!btn || !input) return;
if (btn.dataset.dtWired === '1') return;
btn.dataset.dtWired = '1';
btn.addEventListener('click', function (e) {
e.preventDefault();
input.click();
});
}
var doc;
try { doc = window.parent.document; }
catch (e) { doc = document; }
wire(doc);
var win = doc.defaultView || window.parent || window;
if ('MutationObserver' in win) {
var raf = 0;
try {
new win.MutationObserver(function () {
if (raf) return;
raf = win.requestAnimationFrame(function () { raf = 0; wire(doc); });
}).observe(doc.body, { childList: true, subtree: true });
} catch (e) {}
}
})();
</script>
""",
height=1,
)
if to_remove is not None: if to_remove is not None:
from src.audit import log_event from src.audit import log_event
@@ -312,7 +367,10 @@ def _home_page() -> None:
st.rerun() st.rerun()
if not home_uploads: 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 return
# Expose the first uploaded file via the singular ``home_uploaded_*`` # Expose the first uploaded file via the singular ``home_uploaded_*``

View File

@@ -520,6 +520,61 @@ div[data-testid="stContainer"][data-border="true"] {
font-feature-settings: "ss02"; 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) ---------- */ /* ---------- Findings — per-file group cards (mockup §findings) ---------- */
.dt-finding-group-head { .dt-finding-group-head {
display: flex; display: flex;