fix(footer): mount Back-to-Home outside Streamlit's container tree

Reported: the sticky footer rendered, but the Back to Home button
inside it wasn't visible.

Likely cause: ``st.markdown`` inserts the footer div inside Streamlit's
content tree, which sits under ``.stApp { zoom: 0.85 }`` (our compact
scaler) and several nested padding/positioning contexts. Streamlit's
own ``<a>`` styling rules can also colour-collide with our anchor.

Switch the mount strategy. Two passes:

1. CSS rules go to the parent document via ``st.markdown`` as before,
   but every property carries ``!important`` and the selectors key on
   ``#datatools-sticky-footer`` (id, not class) plus a dedicated
   ``.datatools-sticky-footer-link`` class on the anchor — so
   Streamlit's default ``<a>`` styles can't override colour or
   padding. ``z-index: 2147483646`` keeps the footer above
   anything else in the page.

2. The footer DOM node itself is created by a script inside a
   zero-height ``streamlit.components.v1.html`` iframe. The script
   does ``window.parent.document.body.appendChild(...)`` so the div
   lives as a direct child of ``<body>`` — outside ``.stApp``,
   outside every Streamlit container, free of every parent's
   ``zoom`` / ``transform`` / ``overflow`` rules.

   If the cross-frame access ever fails (Streamlit sandbox config
   change), the script falls through to appending inside the
   iframe's own document — degraded but still visible.

Each rerun replaces any prior ``#datatools-sticky-footer`` so we
don't accumulate stacked footers on every script pass.

2220 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 00:47:44 +00:00
parent 7ad19ac7f4
commit 229e1afd45

View File

@@ -495,83 +495,119 @@ def render_sticky_footer() -> None:
"""Render a slim fixed-position footer at the bottom of the viewport. """Render a slim fixed-position footer at the bottom of the viewport.
Contains a "Back to Home" link that's always visible regardless of Contains a "Back to Home" link that's always visible regardless of
scroll position. Replaces the previous top + bottom-of-page scroll position. The footer is mounted as a direct child of
``back_to_home_link`` buttons: a single sticky bar is more ``<body>`` via a component-iframe script so it lives OUTSIDE every
discoverable, doesn't scroll out of view, and visually reads as a Streamlit container — that matters because ``.stApp`` carries
"non-movable footer" (border-top + slim padding) rather than ``zoom: 0.85`` (our compact-layout scaler) and Streamlit's content
just-another-button-at-the-bottom. columns add their own padding/positioning context that previously
swallowed the in-place ``st.markdown`` footer.
Implementation notes: The implementation is two-pass:
- Uses a styled ``<a href="home">`` anchor rather than an 1. ``st.markdown`` injects the CSS rules into the parent document.
``st.button`` because Streamlit widgets can't be CSS-positioned Class-targeted, so the rules apply once the footer DOM node
reliably (their DOM container is owned by Streamlit's renderer exists regardless of where it lives.
and gets re-mounted on every rerun). A real anchor sits anywhere 2. ``streamlit.components.v1.html`` renders a zero-height iframe
we want and triggers Streamlit's URL routing to the home page. whose JS reaches ``window.parent.document`` and creates / moves
- ``href="home"`` is a relative path so it works behind a reverse a ``#datatools-sticky-footer`` div directly under ``<body>``.
proxy / non-root mount. This bypasses every Streamlit container.
- We bump ``padding-bottom`` on the main container so the last
widget isn't hidden behind the fixed bar. The anchor uses ``href="home"`` (relative) so Streamlit's URL
- Theme: ``rgba(255,255,255,0.96)`` background with a thin routing resolves it to the Home page and the link works correctly
semi-transparent border-top. The blur backdrop keeps content behind a reverse proxy or non-root mount.
slightly visible underneath. Works against both light and dark
Streamlit themes — explicit colours, not theme variables,
because Streamlit's CSS variable surface is unstable across
minor versions.
""" """
import html as _html import html as _html
label = _html.escape(_t("nav.back_to_home")) import json as _json
label_raw = _t("nav.back_to_home")
label_esc = _html.escape(label_raw)
# CSS rules live in the parent document. Class selector so a
# re-rendered/relocated footer div picks them up automatically.
st.markdown( st.markdown(
f""" """
<style> <style>
[data-testid="stAppViewBlockContainer"] {{ [data-testid="stAppViewBlockContainer"] {
padding-bottom: 3.5rem !important; padding-bottom: 4rem !important;
}} }
.datatools-sticky-footer {{ #datatools-sticky-footer {
position: fixed; position: fixed !important;
bottom: 0; bottom: 0 !important;
left: 0; left: 0 !important;
right: 0; right: 0 !important;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.97) !important;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border-top: 1px solid rgba(49, 51, 63, 0.22); border-top: 1px solid rgba(49, 51, 63, 0.25) !important;
padding: 0.45rem 1.25rem; padding: 0.5rem 1.25rem !important;
z-index: 9999; z-index: 2147483646 !important;
display: flex; display: flex !important;
align-items: center; align-items: center !important;
justify-content: flex-start; justify-content: flex-start !important;
font-family: inherit; font-family: system-ui, -apple-system, sans-serif !important;
}} box-sizing: border-box !important;
.datatools-sticky-footer a {{ }
display: inline-block; #datatools-sticky-footer a.datatools-sticky-footer-link {
color: rgb(38, 39, 48); display: inline-block !important;
text-decoration: none; color: rgb(38, 39, 48) !important;
padding: 0.3rem 0.85rem; text-decoration: none !important;
border-radius: 0.5rem; padding: 0.4rem 0.9rem !important;
border: 1px solid rgba(49, 51, 63, 0.22); border-radius: 0.5rem !important;
background: rgb(240, 242, 246); border: 1px solid rgba(49, 51, 63, 0.28) !important;
font-size: 14px; background: rgb(240, 242, 246) !important;
font-weight: 500; font-size: 14px !important;
line-height: 1.4; font-weight: 500 !important;
cursor: pointer; line-height: 1.4 !important;
cursor: pointer !important;
transition: background 0.12s ease, border-color 0.12s ease; transition: background 0.12s ease, border-color 0.12s ease;
}} }
.datatools-sticky-footer a:hover {{ #datatools-sticky-footer a.datatools-sticky-footer-link:hover {
background: rgb(225, 228, 235); background: rgb(225, 228, 235) !important;
border-color: rgba(49, 51, 63, 0.35); border-color: rgba(49, 51, 63, 0.4) !important;
}} }
.datatools-sticky-footer a:active {{ #datatools-sticky-footer a.datatools-sticky-footer-link:active {
background: rgb(210, 214, 222); background: rgb(210, 214, 222) !important;
}} }
</style> </style>
<div class="datatools-sticky-footer">
<a href="home" target="_self">{label}</a>
</div>
""", """,
unsafe_allow_html=True, unsafe_allow_html=True,
) )
# Move the footer to <body> directly via component iframe. The
# iframe carries allow-same-origin so window.parent.document is
# reachable; if a sandbox config ever blocks that we fall back to
# rendering inside the iframe itself (still visible, just sized
# to the iframe rather than the viewport).
from streamlit.components.v1 import html as _components_html
_components_html(
f"""
<script>
(function () {{
var label = {_json.dumps(label_raw)};
function build(doc) {{
var prev = doc.getElementById('datatools-sticky-footer');
if (prev) prev.remove();
var div = doc.createElement('div');
div.id = 'datatools-sticky-footer';
var a = doc.createElement('a');
a.className = 'datatools-sticky-footer-link';
a.href = 'home';
a.target = '_self';
a.textContent = label;
div.appendChild(a);
return div;
}}
try {{
var doc = window.parent.document;
doc.body.appendChild(build(doc));
}} catch (e) {{
document.body.appendChild(build(document));
}}
}})();
</script>
""",
height=0,
)
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 "← Back to Home" affordance on a tool page. """Render a "← Back to Home" affordance on a tool page.