Files
datatools-dev/src/gui/_home.py
Michael f106275643 test(home): replace clutter outliner with click-to-inspect
User reported the previous diagnostic was too cluttered to read,
and the white bar showed no outline anyway — meaning the flat
``querySelectorAll('body *')`` walker missed it (likely inside an
iframe's contentDocument, which the script didn't recurse into).

New approach: a single red button "CLAUDE: click here, then click
the white bar" in the top-right. Clicking the button arms an
inspect handler. The next click anywhere on the page reports the
full element stack at that point via ``elementsFromPoint`` AND
recursively descends into any same-origin iframe at the click
location, so iframe contents are no longer invisible.

A black report panel lists every element in the stack with its
tag/id/testid/class, position, z-index, background color, and
bounding rect — TOP element highlighted in red. User clicks the
white bar exactly once and we know what it is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:23:35 +00:00

451 lines
18 KiB
Python

"""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
class _StashedUpload:
"""Duck-types Streamlit's ``UploadedFile`` so ``_run_analysis_on_upload``
accepts entries restored from session-state without changes. Exposes
``.name``, ``.size``, and ``.getvalue()`` — the contract used by the
analyzer's read path.
"""
__slots__ = ("name", "size", "_data")
def __init__(self, name: str, data: bytes) -> None:
self.name = name
self.size = len(data)
self._data = data
def getvalue(self) -> bytes:
return self._data
def _sync_uploader_to_home_uploads() -> None:
"""``on_change`` callback for the home-page file_uploader.
Reconciles ``home_uploads`` (our persistent stash) with the widget's
current value: adds newly-uploaded files, and drops files the user
explicitly removed via the widget's built-in "" button. Per
Streamlit semantics ``on_change`` only runs for user-initiated
value changes, so the navigation-induced ``[]`` reset never reaches
here — the stash survives intact across page switches.
"""
from src.audit import log_event
widget_files = st.session_state.get("home_upload") or []
home_uploads: dict = st.session_state.setdefault("home_uploads", {})
findings: dict = st.session_state.setdefault("home_findings_by_file", {})
widget_names = {f.name for f in widget_files}
for f in widget_files:
if f.name not in home_uploads:
home_uploads[f.name] = {"bytes": f.getvalue(), "size": f.size}
log_event("upload", f"Uploaded {f.name}", filename=f.name, bytes=f.size)
for name in list(home_uploads.keys()):
if name not in widget_names:
del home_uploads[name]
findings.pop(name, None)
log_event("upload", f"Removed {name}", filename=name)
if st.session_state.get("home_uploaded_name") == name:
st.session_state.pop("home_uploaded_name", None)
st.session_state.pop("home_uploaded_size", None)
st.session_state.pop("home_uploaded_bytes", None)
st.session_state["home_uploads"] = home_uploads
st.session_state["home_findings_by_file"] = findings
def _home_page() -> None:
"""Render the home page — multi-file upload + per-file analysis.
Uploaded files live in ``st.session_state["home_uploads"]`` (a
dict keyed by filename), NOT in the widget's transient state.
Streamlit's ``st.file_uploader`` widget gets unmounted when the
user navigates away to a tool page, and its ``UploadedFile``
objects don't always re-attach on remount — so we capture the
bytes into our own session-state stash on first sight and treat
that stash as the source of truth for everything downstream
(active-file pickup, analysis, findings rendering).
Removing a file: per-row "" buttons next to each uploaded
filename. Clearing findings: the "Clear results" button only
wipes the analysis cache, not the upload stash — the files
persist until the user explicitly removes them.
"""
from src.gui.components import (
hide_streamlit_chrome,
render_findings_panel,
render_sticky_footer,
)
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()
render_sticky_footer()
st.title(t("home.title"))
st.caption(t("home.caption"))
st.divider()
st.markdown(f"### {t('upload.heading')}")
st.caption(t("upload.intro_multi"))
# Source of truth for uploaded files. dict[name -> {"bytes", "size"}].
home_uploads: dict = st.session_state.setdefault("home_uploads", {})
# File uploader — syncs into home_uploads via on_change. We deliberately
# do NOT merge widget state into home_uploads at render time: navigation
# can remount the widget with value ``[]``, and a render-time merge
# would mistakenly leave home_uploads untouched while the user thinks
# they're looking at empty state.
#
# ``on_change`` fires ONLY on user-initiated value changes (uploads
# and the widget's built-in "✕" remove). It does NOT fire on the
# remount-induced reset. That lets us treat the callback as ground
# truth for both adds AND removes — fixing the previous bug where
# the widget's "✕" appeared to do nothing because the file persisted
# in home_uploads and immediately re-rendered in the list below.
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"),
on_change=_sync_uploader_to_home_uploads,
)
# Persistent file list with per-file remove buttons. We render this
# ourselves rather than trusting Streamlit's widget chrome because
# 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
st.markdown("**Uploaded files**")
to_remove: str | None = None
for name in list(home_uploads.keys()):
digest = hashlib.sha1(
name.encode("utf-8"), usedforsecurity=False,
).hexdigest()[:10]
col_file, col_remove = st.columns([8, 1])
col_file.markdown(
f"📄 `{name}` &nbsp; "
f"<span style='opacity:0.6'>"
f"({home_uploads[name]['size']:,} bytes)</span>",
unsafe_allow_html=True,
)
if col_remove.button(
"Remove",
key=f"_home_remove_{digest}",
help=f"Remove {name}",
type="secondary",
use_container_width=True,
):
to_remove = name
if to_remove is not None:
from src.audit import log_event
log_event(
"upload",
f"Removed {to_remove}",
filename=to_remove,
)
del home_uploads[to_remove]
# Drop any findings/results tied to the removed file.
findings_by_file_drop = st.session_state.get(
"home_findings_by_file", {}
)
findings_by_file_drop.pop(to_remove, None)
st.session_state["home_uploads"] = home_uploads
st.session_state["home_findings_by_file"] = findings_by_file_drop
# If we just removed the active upload, also clear the
# singular ``home_uploaded_*`` keys so tool pages don't
# pick up stale bytes; the next render will repopulate
# them from whatever file is now first.
if st.session_state.get("home_uploaded_name") == to_remove:
st.session_state.pop("home_uploaded_name", None)
st.session_state.pop("home_uploaded_size", None)
st.session_state.pop("home_uploaded_bytes", None)
st.rerun()
if not home_uploads:
st.info(t("upload.empty_state"))
return
# Expose the first uploaded file via the singular ``home_uploaded_*``
# session keys so tool pages reached via "Open <Tool>" still find an
# active upload through ``pickup_or_upload``.
first_name = next(iter(home_uploads))
first_meta = home_uploads[first_name]
if (
st.session_state.get("home_uploaded_name") != first_name
or st.session_state.get("home_uploaded_size") != first_meta["size"]
):
st.session_state["home_uploaded_name"] = first_name
st.session_state["home_uploaded_size"] = first_meta["size"]
st.session_state["home_uploaded_bytes"] = first_meta["bytes"]
# Findings cache — drop entries whose underlying file is no longer
# in the stash (e.g. user just clicked "✕").
findings_by_file: dict = st.session_state.setdefault(
"home_findings_by_file", {}
)
findings_by_file = {
name: result for name, result in findings_by_file.items()
if name in home_uploads
}
st.session_state["home_findings_by_file"] = findings_by_file
pending = [name for name in home_uploads if 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:
from src.audit import log_event
log_event(
"analyze",
f"Run analysis clicked on {len(pending)} file(s)",
files=list(pending),
)
progress = st.progress(0.0, text=t("upload.scanning"))
for i, name in enumerate(pending, start=1):
stashed = _StashedUpload(name, home_uploads[name]["bytes"])
findings_by_file[name] = _run_analysis_on_upload(stashed)
progress.progress(i / len(pending), text=name)
st.session_state["home_findings_by_file"] = findings_by_file
progress.empty()
st.rerun()
if findings_by_file:
st.divider()
# Preserve the upload-stash order so the user sees results in
# the same order they appear in the file list above.
for name in home_uploads:
if name not in findings_by_file:
continue
findings = findings_by_file[name]
with st.container(border=True):
if not findings:
st.markdown(f"### 📄 {name}")
st.success(t("findings.none"))
else:
render_findings_panel(
findings,
header=f"📄 {name}",
key_namespace=name,
)
# TEMP: end-of-content marker — confirmed at the true bottom of
# the home page's main content. The user reports content scrolls
# *behind* the offending white bar, so the bar is fixed-positioned.
# Only ``#datatools-sticky-footer`` is fixed at the bottom per
# our CSS, but the user already confirmed the sticky footer is
# NOT the offending bar. So there's a fixed element we haven't
# accounted for. The JS below outlines EVERY fixed/sticky element
# in the parent document with a labelled colored border so we can
# see exactly what's overlaying scrolled content.
st.markdown(
'<div style="background:#ffe0e0;border:2px dashed #b00020;'
'color:#b00020;font-weight:700;font-size:13px;text-align:center;'
'padding:8px 12px;margin:4px 0;font-family:system-ui,sans-serif;">'
'◀ CLAUDE TEST #3 — END OF MAIN CONTENT ▶'
'</div>',
unsafe_allow_html=True,
)
st.iframe(
"""
<script>
(function () {
// CLICK-TO-INSPECT: a single button enters inspect mode. The next
// click anywhere on the page reports the full element stack at
// that point — including elements inside same-origin iframes
// (which earlier diagnostics couldn't reach with a flat walker).
// No clutter outside the button + the report panel; nothing
// re-runs on scroll.
function init() {
var doc = window.parent.document;
var winp = window.parent;
if (!doc || !doc.body) { setTimeout(init, 300); return; }
// Clear any earlier diagnostic chrome.
['claude-diag-panel', 'claude-inspect-btn', 'claude-inspect-report']
.forEach(function (id) {
var prev = doc.getElementById(id);
if (prev) prev.remove();
});
doc.querySelectorAll('[data-claude-label]').forEach(function (n) {
n.remove();
});
doc.querySelectorAll('[data-claude-outlined]').forEach(function (el) {
el.style.outline = '';
el.style.outlineOffset = '';
el.removeAttribute('data-claude-outlined');
});
var btn = doc.createElement('button');
btn.id = 'claude-inspect-btn';
btn.type = 'button';
btn.textContent = 'CLAUDE: click here, then click the white bar';
btn.style.cssText =
'position:fixed;top:8px;right:8px;z-index:2147483647;'
+ 'background:#b00020;color:#fff;font:13px/1.2 system-ui,sans-serif;'
+ 'font-weight:700;padding:8px 14px;border:none;border-radius:6px;'
+ 'cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,0.35);';
doc.body.appendChild(btn);
function describe(el) {
if (!el || !el.tagName) return '?';
var testid = el.getAttribute && el.getAttribute('data-testid');
var cls = (el.className && typeof el.className === 'string')
? el.className.split(/\\s+/).slice(0, 3).join('.') : '';
var cs = (el.ownerDocument.defaultView || winp)
.getComputedStyle(el);
var rect = el.getBoundingClientRect();
return el.tagName.toLowerCase()
+ (el.id ? '#' + el.id : '')
+ (testid ? '[testid=' + testid + ']' : '')
+ (cls ? '.' + cls : '')
+ ' { pos=' + cs.position
+ ', z=' + cs.zIndex
+ ', bg=' + cs.backgroundColor
+ ', rect=(' + Math.round(rect.left) + ','
+ Math.round(rect.top) + ' '
+ Math.round(rect.width) + 'x'
+ Math.round(rect.height) + ') }';
}
function showReport(stack, x, y, frameLabel) {
var prev = doc.getElementById('claude-inspect-report');
if (prev) prev.remove();
var panel = doc.createElement('div');
panel.id = 'claude-inspect-report';
panel.style.cssText =
'position:fixed;top:8px;left:8px;right:8px;max-height:60vh;'
+ 'overflow:auto;background:rgba(0,0,0,0.92);color:#fff;'
+ 'font:12px/1.4 ui-monospace,monospace;padding:10px 12px;'
+ 'border-radius:6px;z-index:2147483647;'
+ 'box-shadow:0 4px 18px rgba(0,0,0,0.5);';
var html =
'<div style="font-weight:700;color:#ffb;margin-bottom:6px;">'
+ 'CLICKED AT (' + x + ',' + y + ') — ' + frameLabel + '</div>';
stack.forEach(function (el, i) {
html += '<div style="margin:3px 0;padding:3px 6px;'
+ 'background:' + (i === 0 ? '#3a0010' : '#111')
+ ';border-left:3px solid '
+ (i === 0 ? '#ff4060' : '#555') + ';">'
+ (i === 0 ? '<b>TOP: </b>' : ('[' + i + '] '))
+ describe(el) + '</div>';
});
html += '<button onclick="this.parentElement.remove()" '
+ 'style="margin-top:8px;background:#444;color:#fff;'
+ 'border:none;padding:4px 10px;border-radius:4px;'
+ 'cursor:pointer;">close</button>';
panel.innerHTML = html;
doc.body.appendChild(panel);
}
function pickFromPoint(targetDoc, x, y, label, depth) {
depth = depth || 0;
var stack = (targetDoc.elementsFromPoint
? targetDoc.elementsFromPoint(x, y) : []);
// Recurse into any same-origin iframe at the click point.
for (var i = 0; i < stack.length; i++) {
var el = stack[i];
if (el && el.tagName === 'IFRAME' && depth < 3) {
try {
var inner = el.contentDocument;
if (inner) {
var r = el.getBoundingClientRect();
var innerStack = pickFromPoint(inner,
x - r.left, y - r.top,
label + ' > IFRAME(' + (el.src || 'srcdoc') + ')',
depth + 1);
if (innerStack && innerStack.length) {
return innerStack.concat(stack);
}
}
} catch (e) { /* cross-origin */ }
}
}
// Tag the frame label onto the returned stack via showReport.
showReport(stack, x, y, label);
return stack;
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
btn.textContent = 'click the white bar now…';
btn.style.background = '#0a8';
function onPick(ev) {
ev.preventDefault();
ev.stopPropagation();
doc.removeEventListener('click', onPick, true);
btn.textContent = 'CLAUDE: click here, then click the white bar';
btn.style.background = '#b00020';
pickFromPoint(doc, ev.clientX, ev.clientY, 'parent doc', 0);
}
setTimeout(function () {
doc.addEventListener('click', onPick, true);
}, 50);
});
}
init();
})();
</script>
""",
height=1,
)