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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user