fix(nav): switch_page resolves correctly + bottom-of-page back link

Two issues, same fix surface.

(1) Reported crash on Back-to-Home:

    StreamlitAPIException: Could not find page: app.py.

``st.switch_page("app.py")`` doesn't work under ``st.navigation`` —
the entry script is the nav manager itself and is not a registered
page. The fix needs to pass an ``st.Page`` object whose script
identity matches one registered in the nav.

First-pass attempt (``from src.gui.app import _home_page``) hit a
worse failure: importing ``app.py`` from inside a tool-page render
re-executes the nav setup with the WRONG "main script" context, so
every ``st.Page("pages/N_foo.py", ...)`` call in ``_build_navigation``
fails with "file could not be found".

Extract the home renderer into its own module ``src/gui/_home.py``
which has no top-level Streamlit side effects. Both the nav manager
and the back-link helper import ``_home_page`` from there. The Page
object built at click time has the same callable identity as the one
registered, so ``st.switch_page`` resolves it.

(2) Reported UX: the back button scrolled out of view on long pages.

Add a second ``back_to_home_link(key="_back_to_home_link_bottom")``
call near the footer of every tool page (1-9). The unique key avoids
widget-id collision with the top instance. Coming-Soon stubs get it
unconditionally; Ready tools render it only after a result exists
because the page short-circuits with ``st.stop()`` before then —
when no result is on screen the page is short enough that the top
link is sufficient.

2220 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 23:58:33 +00:00
parent 42f8d78dd5
commit 21fd8a4cd7
12 changed files with 177 additions and 120 deletions

131
src/gui/_home.py Normal file
View File

@@ -0,0 +1,131 @@
"""Home-page renderer extracted into its own module.
This used to live inside ``src/gui/app.py`` as a local function. Pulling
it out into a side-effect-free module lets the ``back_to_home_link``
helper (in ``components/_legacy.py``) import the home callable to pass
into ``st.switch_page`` — without re-running ``app.py``'s navigation
setup, which would itself blow up because tool pages have a different
"main script" context that breaks the registry's relative ``pages/…``
paths.
Keep this module imports-light: nothing that runs Streamlit commands
at module top level, nothing that triggers config loads. Just the
``_home_page`` callable.
"""
from __future__ import annotations
import streamlit as st
def _home_page() -> None:
"""Render the home page — multi-file upload + per-file analysis."""
from src.gui.components import hide_streamlit_chrome, render_findings_panel
from src.gui.components._legacy import _run_analysis_on_upload
from src.i18n import t
st.set_page_config(
page_title=t("home.page_title"),
page_icon="🧹",
layout="wide",
)
hide_streamlit_chrome()
st.title(t("home.title"))
st.caption(t("home.caption"))
st.divider()
st.markdown(f"### {t('upload.heading')}")
st.caption(t("upload.intro_multi"))
uploaded_files = st.file_uploader(
t("upload.uploader_label_multi"),
type=["csv", "tsv", "xlsx", "xls"],
accept_multiple_files=True,
key="home_upload",
help=t("upload.uploader_help"),
)
if not uploaded_files:
st.info(t("upload.empty_state"))
return
# Keep tool pages working: they consume a single ``home_uploaded_*``
# set via ``pickup_or_upload``. Expose the first uploaded file as
# the "active" upload for that contract; the rest live alongside
# for per-file analysis on this page.
first = uploaded_files[0]
if (
st.session_state.get("home_uploaded_name") != first.name
or st.session_state.get("home_uploaded_size") != first.size
):
st.session_state["home_uploaded_name"] = first.name
st.session_state["home_uploaded_size"] = first.size
st.session_state["home_uploaded_bytes"] = first.getvalue()
# Per-file findings live in a dict so removing a file from the
# uploader (Streamlit's "x" button) drops its results too. We only
# re-analyze files we haven't already analyzed in this session.
findings_by_file: dict = st.session_state.setdefault(
"home_findings_by_file", {}
)
current_names = {f.name for f in uploaded_files}
findings_by_file = {
name: result for name, result in findings_by_file.items()
if name in current_names
}
st.session_state["home_findings_by_file"] = findings_by_file
pending = [f for f in uploaded_files if f.name not in findings_by_file]
col_run, col_clear, _ = st.columns([1, 1, 4])
with col_run:
run_clicked = st.button(
t("upload.run_button"),
type="primary",
key="home_run_analysis",
disabled=not pending,
use_container_width=True,
)
with col_clear:
clear_clicked = st.button(
t("upload.clear_results"),
key="home_clear_results",
disabled=not findings_by_file,
use_container_width=True,
)
if clear_clicked:
st.session_state["home_findings_by_file"] = {}
st.rerun()
if run_clicked:
progress = st.progress(0.0, text=t("upload.scanning"))
for i, f in enumerate(pending, start=1):
findings_by_file[f.name] = _run_analysis_on_upload(f)
progress.progress(i / len(pending), text=f"{f.name}")
st.session_state["home_findings_by_file"] = findings_by_file
progress.empty()
st.rerun()
if findings_by_file:
st.divider()
# Preserve uploader order so the user sees results in the same
# order they appear in the file list above. Each file's findings
# render via ``render_findings_panel`` so the per-tool grouping
# (and the "Open <Tool>" jump link under each group) is kept —
# that's how the user reaches the cleaner that fixes a specific
# finding without hunting through the sidebar.
for f in uploaded_files:
if f.name not in findings_by_file:
continue
findings = findings_by_file[f.name]
with st.container(border=True):
if not findings:
st.markdown(f"### 📄 {f.name}")
st.success(t("findings.none"))
else:
render_findings_panel(findings, header=f"📄 {f.name}")
st.divider()
st.caption(t("chrome.footer"))

View File

@@ -28,118 +28,15 @@ if str(_project_root) not in sys.path:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Home page (rendered when the user selects the default nav entry) # Home page (rendered when the user selects the default nav entry)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#
# The renderer lives in ``src/gui/_home.py`` so the ``back_to_home_link``
# helper on tool pages can ``import _home_page`` and pass it into
# ``st.switch_page`` without re-executing this entry script's
# navigation setup (which would crash because tool pages have a
# different "main script" context and the relative ``pages/…`` paths
# below would no longer resolve).
def _home_page() -> None: from src.gui._home import _home_page # noqa: E402
"""Render the home page — multi-file upload + per-file analysis."""
from src.gui.components import hide_streamlit_chrome, render_findings_panel
from src.gui.components._legacy import _run_analysis_on_upload
from src.i18n import t
st.set_page_config(
page_title=t("home.page_title"),
page_icon="🧹",
layout="wide",
)
hide_streamlit_chrome()
st.title(t("home.title"))
st.caption(t("home.caption"))
st.divider()
st.markdown(f"### {t('upload.heading')}")
st.caption(t("upload.intro_multi"))
uploaded_files = st.file_uploader(
t("upload.uploader_label_multi"),
type=["csv", "tsv", "xlsx", "xls"],
accept_multiple_files=True,
key="home_upload",
help=t("upload.uploader_help"),
)
if not uploaded_files:
st.info(t("upload.empty_state"))
return
# Keep tool pages working: they consume a single ``home_uploaded_*``
# set via ``pickup_or_upload``. Expose the first uploaded file as
# the "active" upload for that contract; the rest live alongside
# for per-file analysis on this page.
first = uploaded_files[0]
if (
st.session_state.get("home_uploaded_name") != first.name
or st.session_state.get("home_uploaded_size") != first.size
):
st.session_state["home_uploaded_name"] = first.name
st.session_state["home_uploaded_size"] = first.size
st.session_state["home_uploaded_bytes"] = first.getvalue()
# Per-file findings live in a dict so removing a file from the
# uploader (Streamlit's "x" button) drops its results too. We only
# re-analyze files we haven't already analyzed in this session.
findings_by_file: dict = st.session_state.setdefault(
"home_findings_by_file", {}
)
current_names = {f.name for f in uploaded_files}
findings_by_file = {
name: result for name, result in findings_by_file.items()
if name in current_names
}
st.session_state["home_findings_by_file"] = findings_by_file
pending = [f for f in uploaded_files if f.name not in findings_by_file]
col_run, col_clear, _ = st.columns([1, 1, 4])
with col_run:
run_clicked = st.button(
t("upload.run_button"),
type="primary",
key="home_run_analysis",
disabled=not pending,
use_container_width=True,
)
with col_clear:
clear_clicked = st.button(
t("upload.clear_results"),
key="home_clear_results",
disabled=not findings_by_file,
use_container_width=True,
)
if clear_clicked:
st.session_state["home_findings_by_file"] = {}
st.rerun()
if run_clicked:
progress = st.progress(0.0, text=t("upload.scanning"))
for i, f in enumerate(pending, start=1):
findings_by_file[f.name] = _run_analysis_on_upload(f)
progress.progress(i / len(pending), text=f"{f.name}")
st.session_state["home_findings_by_file"] = findings_by_file
progress.empty()
st.rerun()
if findings_by_file:
st.divider()
# Preserve uploader order so the user sees results in the same
# order they appear in the file list above. Each file's findings
# render via ``render_findings_panel`` so the per-tool grouping
# (and the "Open <Tool>" jump link under each group) is kept —
# that's how the user reaches the cleaner that fixes a specific
# finding without hunting through the sidebar.
for f in uploaded_files:
if f.name not in findings_by_file:
continue
findings = findings_by_file[f.name]
with st.container(border=True):
if not findings:
st.markdown(f"### 📄 {f.name}")
st.success(t("findings.none"))
else:
render_findings_panel(findings, header=f"📄 {f.name}")
st.divider()
st.caption(t("chrome.footer"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -410,22 +410,33 @@ html_download_button = local_download_button
def back_to_home_link(*, key: str = "_back_to_home_link") -> None: def back_to_home_link(*, key: str = "_back_to_home_link") -> None:
"""Render a small "← Back to Home" affordance near the top of a tool page. """Render a "← Back to Home" affordance on a tool page.
Tool pages reached from the home findings panel benefit from an Tool pages reached from the home findings panel benefit from an
explicit return-to-home control so a user working through findings explicit return-to-home control so a user working through findings
on multiple uploaded files can hop between files without hunting on multiple uploaded files can hop between files without hunting
through the sidebar. through the sidebar. Call this twice on each tool page — once
near the top (default key) and once at the bottom with
``key="_back_to_home_link_bottom"`` so the control stays reachable
after the user scrolls through long results.
Implementation note: ``st.switch_page("app.py")`` routes back to the Implementation: ``st.switch_page`` under ``st.navigation`` requires
entry script which, under ``st.navigation``, lands on the default either a file path to a page in ``pages/`` or a ``StreamlitPage``
page (Home). Streamlit's button is used (rather than ``st.page_link``) object whose script identity matches one registered in the nav.
because the entry script is a navigation manager, not a registered The entry script ``app.py`` is the nav manager itself — it cannot
Page object, and ``page_link`` to ``app.py`` renders inconsistently be switched-to by filename. So we import the home callable from
across Streamlit minor versions. ``src.gui.app`` and rebuild the same ``st.Page`` registration here.
Streamlit identifies pages by the underlying callable's qualified
name, so a freshly-constructed Page resolves to the registered one.
""" """
if st.button(_t("nav.back_to_home"), key=key, type="secondary"): if st.button(_t("nav.back_to_home"), key=key, type="secondary"):
st.switch_page("app.py") # Import from the renderer module (not from app.py — importing
# app.py would re-execute its navigation setup with the wrong
# "main script" context and blow up the pages/ path resolution).
from src.gui._home import _home_page
st.switch_page(
st.Page(_home_page, title="Home", icon="🧹", url_path="home"),
)
def shutdown_app() -> None: def shutdown_app() -> None:

View File

@@ -399,6 +399,8 @@ else:
# Footer # Footer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption( st.caption(
"Runs locally. Your data never leaves this computer. " "Runs locally. Your data never leaves this computer. "

View File

@@ -379,6 +379,8 @@ with dl_c:
mime="application/json", mime="application/json",
) )
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")

View File

@@ -650,6 +650,8 @@ with dl_c:
mime="application/json", mime="application/json",
) )
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")

View File

@@ -412,6 +412,8 @@ with dl_c:
mime="application/json", mime="application/json",
) )
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")

View File

@@ -456,6 +456,8 @@ with dl_c:
mime="application/json", mime="application/json",
) )
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")

View File

@@ -96,6 +96,8 @@ st.button("Detect Outliers", type="primary", use_container_width=True, disabled=
# Footer # Footer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption( st.caption(
"Runs locally. Your data never leaves this computer. " "Runs locally. Your data never leaves this computer. "

View File

@@ -94,6 +94,8 @@ st.button("Merge Files", type="primary", use_container_width=True, disabled=True
# Footer # Footer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption( st.caption(
"Runs locally. Your data never leaves this computer. " "Runs locally. Your data never leaves this computer. "

View File

@@ -101,6 +101,8 @@ st.button("Validate & Generate Report", type="primary", use_container_width=True
# Footer # Footer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption( st.caption(
"Runs locally. Your data never leaves this computer. " "Runs locally. Your data never leaves this computer. "

View File

@@ -414,6 +414,8 @@ with dl_c:
mime="application/json", mime="application/json",
) )
back_to_home_link(key="_back_to_home_link_bottom")
st.divider() st.divider()
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0") st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")