fix(downloads): swap st.download_button for an HTML <a download> helper

Reported symptom: only the FIRST download button in a multi-button
row pops the browser save dialog. The second and third do nothing on
click. Affects every tool page that exposes (cleaned + audit + config)
downloads.

Root cause is ``st.download_button`` itself — when several render in
the same script pass, the click-to-bytes wiring on the browser side
mis-routes and only one button's data is actually exposed. Explicit
``key`` arguments don't fix it; ``use_container_width=True`` doesn't
help either; we confirmed this in the Text Cleaner reverts.

Replace the widget with a real ``<a download="file" href="data:...">``
anchor rendered via ``st.markdown(..., unsafe_allow_html=True)``.
Bypasses Streamlit's widget machinery entirely; behaves identically to
a native browser download. Side benefit: clicking it does NOT trigger
a script rerun, so other in-flight UI state survives.

New helper ``html_download_button`` lives in
``src/gui/components/_legacy.py`` (exported from ``components``). API:

    html_download_button(
        label, data,
        *, file_name, mime="application/octet-stream",
        disabled=False, help=None, use_container_width=True,
    )

Translation pattern applied across every tool page (and shared
``results_summary`` / ``config_panel`` widgets in ``_legacy.py``):

- ``st.download_button(`` -> ``html_download_button(``
- ``data=foo_bytes`` kwarg -> positional second arg
- ``key="..."`` -> dropped (helper has no widget identity)
- ``use_container_width=True`` -> dropped (default)
- ``disabled=`` and ``help=`` pass through unchanged
- Pre-computed byte buffers kept where they were

Total: 17 sites replaced (3 in Text Cleaner, 3 in Format
Standardizer, 3 in Fix Missing Values, 3 in Map Columns, 3 in
Automated Workflows, 2 in Find Duplicates page + 4 in shared
_legacy.py widgets used by Find Duplicates).

Caveat: data: URLs balloon by 33% (base64). Fine for tool output
sizes we ship; if a future result topped a few hundred MB we'd want a
Blob-URL fallback.

The marketing demo at src/gui/app_demo.py keeps its single
st.download_button — single button, no collision, no need to switch.

2008 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:13:41 +00:00
parent 6415be8bf4
commit aeead05e4c
8 changed files with 135 additions and 97 deletions

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
back_to_home_link,
hide_streamlit_chrome,
html_download_button,
pickup_or_upload,
require_feature_or_render_upgrade,
)
@@ -364,13 +365,14 @@ st.dataframe(result.handled_df.head(10), use_container_width=True)
# Downloads
# ---------------------------------------------------------------------------
#
# All three byte buffers are prepared up front (outside the columns) so
# each ``st.download_button`` sees stable ``data`` across reruns and an
# explicit ``key`` — without those, Streamlit auto-derived widget IDs
# can collide for multiple download_buttons in adjacent columns and
# only the first one actually fires on click. The empty-changes case
# now renders a disabled button (rather than vanishing) so the layout
# stays steady and the user understands why nothing's available.
# Rendered via ``html_download_button`` (raw <a download> anchor) rather
# than ``st.download_button``. The latter has a long-standing bug where
# the second and third download_buttons rendered in the same script pass
# fail to fire — only the first one's click reaches the browser save
# dialog. The HTML helper bypasses the widget system entirely and works
# uniformly across all browsers. The empty-changes case still renders a
# disabled button (rather than vanishing) so the layout stays steady and
# the user understands why nothing's available.
st.divider()
stem = Path(st.session_state.get("missing_input_name", "input")).stem
@@ -387,33 +389,27 @@ config_bytes = json.dumps(
dl_a, dl_b, dl_c = st.columns(3)
with dl_a:
st.download_button(
html_download_button(
"Download handled CSV",
data=handled_bytes,
handled_bytes,
file_name=f"{stem}_missing.csv",
mime="text/csv",
key="missing_dl_handled",
use_container_width=True,
)
with dl_b:
st.download_button(
html_download_button(
"Download changes audit",
data=changes_bytes,
changes_bytes,
file_name=f"{stem}_missing_changes.csv",
mime="text/csv",
key="missing_dl_changes",
disabled=result.changes.empty,
help="No changes to audit." if result.changes.empty else None,
use_container_width=True,
)
with dl_c:
st.download_button(
html_download_button(
"Download config JSON",
data=config_bytes,
config_bytes,
file_name="missing_config.json",
mime="application/json",
key="missing_dl_config",
use_container_width=True,
)
st.divider()