feat(home,sidebar): brand block + collapsible findings + many polish tweaks
Batch of UX tweaks the user asked for in quick succession: - Sidebar brand block (mockup §brand) — 28px ink chip with a "D" wordmark plus the "DataTools" text — injected into ``stSidebarHeader`` by a small JS bundled into the iframe-mounted script that already runs from ``hide_streamlit_chrome``. The Streamlit ``stLogoSpacer`` is hidden when the brand block is present so it sits flush at the top of the sidebar. - Findings cards are now collapsible. Each file's card head carries ``data-dt-collapsed="true"`` on first render; clicking the head flips the attribute via the new ``_WIRE_COLLAPSIBLE_FINDINGS_JS`` (MutationObserver re-wires after reruns). A CSS rule ``[stElementContainer]:has(.dt-finding-group-head[data-dt-collapsed ="true"]) ~ *`` hides every later sibling of the head's element container — covers both ``stLayoutWrapper`` (the columns rows in this Streamlit release) and ``stElementContainer`` so the rule survives future Streamlit layout renames. A chevron icon (``chevron_right``) rotates 90° when expanded. The head itself gets ``cursor: pointer`` + an accent-fill hover. - Tool-link buttons in finding rows dropped the leading ``Open`` — now read ``Clean Text →``, ``Standardize Formats →`` etc. - Finding-row column order: action is now LEFT of the description, matching user feedback (``[icon] [Tool →] [description + meta]``). - Head padding bumped to ``16px 22px`` so the filename has visible breathing room from the card's left edge (previously the mono filename felt like it was bleeding into the rounded corner). - Head margin-bottom bumped to 1.5rem for breathing room before the first finding row when expanded; collapsed state tucks the head flush against the card bottom with full ``--r-lg`` corner radius and no visible bottom border. - Files card row layout: ``✕`` button moved to the LEFT of the filename (``[✕] [chip + filename] [size]``). - Sidebar nav rows tightened: link padding 7px → 4px, line-height 1.25, 1px margin-bottom per li, section-header padding-top reduced. Plus a new ``--gap: 0.25rem`` rule for vertical blocks inside bordered containers so the Files card and findings card body have denser inter-row spacing. - Sidebar Language selector restyled: widget labels render as the spec's "Eyebrow" row (11.5px / 500 / 0.08em uppercase, tertiary ink), selectbox combobox gets a paper surface + soft border that matches the rest of the sidebar chrome. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -279,7 +279,16 @@ def _home_page() -> None:
|
||||
digest = hashlib.sha1(
|
||||
name.encode("utf-8"), usedforsecurity=False,
|
||||
).hexdigest()[:10]
|
||||
col_name, col_size, col_x = st.columns([8, 1.6, 0.55])
|
||||
# X button on the LEFT of the row per UX feedback —
|
||||
# ``✕ | filename + chip | size``.
|
||||
col_x, col_name, col_size = st.columns([0.55, 8, 1.6])
|
||||
if col_x.button(
|
||||
"✕",
|
||||
key=f"_home_remove_{digest}",
|
||||
help=f"Remove {name}",
|
||||
type="tertiary",
|
||||
):
|
||||
to_remove = name
|
||||
col_name.markdown(
|
||||
'<div class="dt-file-row">'
|
||||
f'<span class="dt-file-icon-chip">{_DOC_SVG}</span>'
|
||||
@@ -294,13 +303,6 @@ def _home_page() -> None:
|
||||
'</span></div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
if col_x.button(
|
||||
"✕",
|
||||
key=f"_home_remove_{digest}",
|
||||
help=f"Remove {name}",
|
||||
type="tertiary",
|
||||
):
|
||||
to_remove = name
|
||||
# In-card "Add more files" — clicks the (off-screen)
|
||||
# ``stFileUploaderDropzoneInput`` so the OS file picker opens.
|
||||
# Inline ``onclick`` would be cleanest but Streamlit's HTML
|
||||
|
||||
@@ -179,6 +179,50 @@ body, .stApp {
|
||||
background: #f5f4ef !important;
|
||||
}
|
||||
|
||||
/* Brand block at the top of the sidebar (mockup §brand) — a 28px
|
||||
ink-filled rounded square with the wordmark "D" + "DataTools"
|
||||
text. Injected into ``[data-testid="stSidebarHeader"]`` by the JS
|
||||
below; ``stLogoSpacer`` is hidden so the brand block takes its
|
||||
place flush against the left edge of the sidebar header. */
|
||||
.dt-brand {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 0 0 4px;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
.dt-brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
background: var(--ink);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-fill);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dt-brand-name {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
line-height: 1;
|
||||
}
|
||||
/* The stock Streamlit logo placeholder takes 100x32 of space; hide
|
||||
it so the injected brand has room to breathe. */
|
||||
[data-testid="stLogoSpacer"]:not(:has(.dt-brand)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Section labels in the page-nav: tiny uppercase tracking — the
|
||||
"Eyebrow" row from spec §4. Streamlit renders these as <span> nodes
|
||||
with class ``st-emotion-cache-…`` inside ``stSidebarNav`` — class
|
||||
@@ -200,16 +244,25 @@ body, .stApp {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Nav items: comfortable padding, soft hover. */
|
||||
/* Nav items — tight padding so the menu lists feel dense and don't
|
||||
waste vertical space. */
|
||||
[data-testid="stSidebarNav"] a[data-testid="stSidebarNavLink"],
|
||||
[data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"] a {
|
||||
color: var(--ink-secondary) !important;
|
||||
font-size: 13.5px !important;
|
||||
line-height: 1.3 !important;
|
||||
padding: 7px 10px !important;
|
||||
line-height: 1.25 !important;
|
||||
padding: 4px 10px !important;
|
||||
border-radius: var(--r-sm) !important;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
[data-testid="stSidebarNav"] li,
|
||||
[data-testid="stSidebarNavItems"] > li {
|
||||
margin-bottom: 1px !important;
|
||||
}
|
||||
[data-testid="stSidebarNavSectionHeader"] {
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
[data-testid="stSidebarNav"] a[data-testid="stSidebarNavLink"]:hover,
|
||||
[data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"] a:hover {
|
||||
background: rgba(0,0,0,0.04) !important;
|
||||
@@ -393,6 +446,12 @@ div[data-testid="stContainer"][data-border="true"] {
|
||||
border-radius: var(--r-lg) !important;
|
||||
box-shadow: 0 1px 2px rgba(28,25,23,0.03);
|
||||
}
|
||||
/* Tighten the inter-row gap inside bordered containers — applies to
|
||||
the Files card rows after import and the findings-card rows alike,
|
||||
so the dense card body has less wasted vertical whitespace. */
|
||||
[data-testid="stVerticalBlockBorderWrapper"] [data-testid="stVerticalBlock"] {
|
||||
gap: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* ---------- Alerts — soft fills, no harsh borders ---------- */
|
||||
[data-testid="stAlert"] [data-testid="stAlertContainer"],
|
||||
@@ -438,6 +497,39 @@ div[data-testid="stContainer"][data-border="true"] {
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* Sidebar widget labels — render as the "Eyebrow" row from spec §4
|
||||
(tiny uppercase tracking, tertiary ink) so the ``Language`` /
|
||||
``Core · 1820 days left`` blocks at the bottom of the sidebar
|
||||
match the section-title rhythm of the nav above. */
|
||||
[data-testid="stSidebar"] [data-testid="stWidgetLabel"] p,
|
||||
[data-testid="stSidebar"] label[data-testid="stWidgetLabel"] {
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 500 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
color: var(--ink-tertiary) !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
/* Sidebar selectbox — quiet outline, cream surface that reads as
|
||||
part of the sidebar rather than a Streamlit-default white island. */
|
||||
[data-testid="stSidebar"] [data-testid="stSelectbox"] div[role="combobox"],
|
||||
[data-testid="stSidebar"] [data-baseweb="select"] > div {
|
||||
background: var(--surface) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: var(--r-sm) !important;
|
||||
font-size: 13px !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
[data-testid="stSidebar"] [data-testid="stSelectbox"] div[role="combobox"]:hover,
|
||||
[data-testid="stSidebar"] [data-baseweb="select"] > div:hover {
|
||||
border-color: var(--border-strong) !important;
|
||||
}
|
||||
/* Streamlit pads the selectbox internals; tighten the chevron column
|
||||
so the control isn't taller than the nav items above it. */
|
||||
[data-testid="stSidebar"] [data-baseweb="select"] > div > div {
|
||||
padding: 4px 8px !important;
|
||||
}
|
||||
|
||||
/* Divider — softer warm gray instead of cool Streamlit default. */
|
||||
[data-testid="stMarkdownContainer"] hr,
|
||||
.stApp hr {
|
||||
@@ -605,15 +697,57 @@ div[data-testid="stContainer"][data-border="true"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
/* Generous left/right padding so the filename + counts have visible
|
||||
breathing room against the card's rounded edges — the head bleeds
|
||||
out to those edges via the negative margin below, so without the
|
||||
extra padding the content sits flush against the border. */
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-hover);
|
||||
/* -1rem on top/sides bleeds the head to the card edges (the parent
|
||||
``st.container(border=True)`` has 1rem padding). +0.75rem on the
|
||||
bottom is the breathing room before the first finding row —
|
||||
without it the row sits flush against the head's bottom border. */
|
||||
margin: -1rem -1rem 0.75rem;
|
||||
``st.container(border=True)`` has 1rem padding). +1.5rem on the
|
||||
bottom is breathing room before the first finding row — without
|
||||
it the row sits flush against the head's bottom border. */
|
||||
margin: -1rem -1rem 1.5rem;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.dt-finding-group-head:hover {
|
||||
background: var(--accent-fill);
|
||||
}
|
||||
/* Chevron lives on the right of the head, rotates to indicate state. */
|
||||
.dt-finding-group-chevron {
|
||||
margin-left: 8px;
|
||||
color: var(--ink-tertiary);
|
||||
font-family: "Material Symbols Outlined" !important;
|
||||
font-size: 20px !important;
|
||||
font-feature-settings: normal !important;
|
||||
line-height: 1 !important;
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dt-finding-group-head[data-dt-collapsed="false"] .dt-finding-group-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
/* Collapsed = body rows hidden + head tucks tight against card bottom.
|
||||
The head's siblings inside the bordered container are the
|
||||
``stHorizontalBlock``s emitted by each ``st.columns`` row — when the
|
||||
head carries ``data-dt-collapsed="true"`` they collapse to nothing
|
||||
and the head's bottom border becomes the card's bottom edge. */
|
||||
.dt-finding-group-head[data-dt-collapsed="true"] {
|
||||
margin: -1rem -1rem -1rem;
|
||||
border-bottom: none;
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
/* Hide every sibling that comes AFTER the head's element-container
|
||||
(the rows are emitted as ``stLayoutWrapper`` or
|
||||
``stElementContainer`` siblings depending on Streamlit's internal
|
||||
layout reducer; ``~ *`` matches both and survives future renames). */
|
||||
[data-testid="stElementContainer"]:has(.dt-finding-group-head[data-dt-collapsed="true"])
|
||||
~ * {
|
||||
display: none !important;
|
||||
}
|
||||
.dt-severity-dot {
|
||||
width: 8px; height: 8px;
|
||||
@@ -770,6 +904,85 @@ div[data-testid="stContainer"][data-border="true"] {
|
||||
# script walks the dropzone buttons after first paint and rewrites the
|
||||
# label to "Import" — and re-runs on Streamlit's component-rerender
|
||||
# DOM mutations so the swap survives navigation and reruns.
|
||||
# Injects the sidebar brand block (mockup §brand) at the top of
|
||||
# Streamlit's ``stSidebarHeader``: the 28px ink-filled rounded square
|
||||
# with the "D" wordmark followed by the "DataTools" word. Streamlit's
|
||||
# ``stLogoSpacer`` reserves the slot but doesn't render anything
|
||||
# without a ``st.logo()`` call; we replace its content rather than
|
||||
# call ``st.logo`` because the brand wants both a chip AND wordmark
|
||||
# in one block, which ``st.logo`` can't do without shipping a static
|
||||
# image asset. MutationObserver re-injects when Streamlit remounts
|
||||
# the sidebar header.
|
||||
_INJECT_BRAND_JS = """
|
||||
<script>
|
||||
(function () {
|
||||
function inject(doc) {
|
||||
var header = doc.querySelector('[data-testid="stSidebarHeader"]');
|
||||
if (!header) return;
|
||||
if (header.querySelector('.dt-brand')) return;
|
||||
var brand = doc.createElement('div');
|
||||
brand.className = 'dt-brand';
|
||||
brand.innerHTML =
|
||||
'<div class="dt-brand-mark">D</div>' +
|
||||
'<div class="dt-brand-name">DataTools</div>';
|
||||
header.insertBefore(brand, header.firstChild);
|
||||
}
|
||||
var doc;
|
||||
try { doc = window.parent.document; }
|
||||
catch (e) { doc = document; }
|
||||
inject(doc);
|
||||
var win = doc.defaultView || window.parent || window;
|
||||
if ('MutationObserver' in win) {
|
||||
var raf = 0;
|
||||
try {
|
||||
new win.MutationObserver(function () {
|
||||
if (raf) return;
|
||||
raf = win.requestAnimationFrame(function () { raf = 0; inject(doc); });
|
||||
}).observe(doc.body, { childList: true, subtree: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
# Toggle a ``.dt-finding-group-head``'s ``data-dt-collapsed`` attribute
|
||||
# on click. CSS handles the visual collapse (hide siblings, tuck the
|
||||
# head against the card bottom) — all this script does is flip the
|
||||
# attribute. MutationObserver re-binds when Streamlit remounts heads.
|
||||
_WIRE_COLLAPSIBLE_FINDINGS_JS = """
|
||||
<script>
|
||||
(function () {
|
||||
function wire(doc) {
|
||||
var heads = doc.querySelectorAll('.dt-finding-group-head');
|
||||
heads.forEach(function (h) {
|
||||
if (h.dataset.dtWired === '1') return;
|
||||
h.dataset.dtWired = '1';
|
||||
h.addEventListener('click', function () {
|
||||
var collapsed = h.getAttribute('data-dt-collapsed') === 'true';
|
||||
h.setAttribute('data-dt-collapsed', collapsed ? 'false' : 'true');
|
||||
});
|
||||
});
|
||||
}
|
||||
var doc;
|
||||
try { doc = window.parent.document; }
|
||||
catch (e) { doc = document; }
|
||||
wire(doc);
|
||||
var win = doc.defaultView || window.parent || window;
|
||||
if ('MutationObserver' in win) {
|
||||
var raf = 0;
|
||||
try {
|
||||
new win.MutationObserver(function () {
|
||||
if (raf) return;
|
||||
raf = win.requestAnimationFrame(function () { raf = 0; wire(doc); });
|
||||
}).observe(doc.body, { childList: true, subtree: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
_RENAME_UPLOAD_BUTTON_JS = """
|
||||
<script>
|
||||
(function () {
|
||||
@@ -836,10 +1049,16 @@ def hide_streamlit_chrome(*, gate_license: bool = True) -> None:
|
||||
from src.gui.theme import apply_theme
|
||||
apply_theme()
|
||||
st.markdown(_DESIGN_TOKENS_CSS, unsafe_allow_html=True)
|
||||
# ``st.markdown`` doesn't execute embedded scripts; ship the
|
||||
# Upload→Import rewriter through an iframe component the same way
|
||||
# the sticky footer mounts on ``<body>``.
|
||||
st.iframe(_RENAME_UPLOAD_BUTTON_JS, height=1)
|
||||
# ``st.markdown`` doesn't execute embedded scripts; ship every
|
||||
# DOM-mutating script through a single iframe component (same way
|
||||
# the sticky footer mounts on ``<body>``). Bundled together so
|
||||
# there's one component-iframe per page, not three.
|
||||
st.iframe(
|
||||
_INJECT_BRAND_JS
|
||||
+ _RENAME_UPLOAD_BUTTON_JS
|
||||
+ _WIRE_COLLAPSIBLE_FINDINGS_JS,
|
||||
height=1,
|
||||
)
|
||||
# Stamp a session-start record into the audit log the first time
|
||||
# any page renders. Idempotent — subsequent calls are no-ops.
|
||||
# Wrapped because a broken audit log MUST NOT take the GUI down.
|
||||
@@ -2599,10 +2818,11 @@ def render_findings_panel(
|
||||
)
|
||||
|
||||
head_html = (
|
||||
'<div class="dt-finding-group-head">'
|
||||
'<div class="dt-finding-group-head" data-dt-collapsed="true">'
|
||||
f'<span class="dt-severity-dot {worst}"></span>'
|
||||
f'<span class="dt-group-filename">{_html.escape(header)}</span>'
|
||||
f'<div class="dt-group-counts">{pills_html}</div>'
|
||||
'<span class="dt-finding-group-chevron">chevron_right</span>'
|
||||
'</div>'
|
||||
)
|
||||
st.markdown(head_html, unsafe_allow_html=True)
|
||||
@@ -2664,7 +2884,11 @@ def _render_finding_row_v2(f, *, row_key: str) -> None:
|
||||
f"{'' if len(f.samples) == 1 else 's'} captured")
|
||||
meta_html = " · ".join(meta_parts)
|
||||
|
||||
col_icon, col_body, col_action = st.columns([0.4, 8, 1.6])
|
||||
# Action button moved to the LEFT of the description per UX
|
||||
# feedback: ``[icon] [Open <Tool> →] [description]`` — the action
|
||||
# is now the prominent affordance in the row, with the description
|
||||
# taking the wide remaining column.
|
||||
col_icon, col_action, col_body = st.columns([0.4, 1.8, 8])
|
||||
|
||||
col_icon.markdown(
|
||||
f'<div class="dt-finding-icon {f.severity}">'
|
||||
@@ -2673,22 +2897,22 @@ def _render_finding_row_v2(f, *, row_key: str) -> None:
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
body_html = f'<p class="dt-finding-title">{title_html}</p>'
|
||||
if meta_html:
|
||||
body_html += f'<p class="dt-finding-meta">{meta_html}</p>'
|
||||
col_body.markdown(body_html, unsafe_allow_html=True)
|
||||
|
||||
page_slug = _tool_page_slug(f.tool) if getattr(f, "tool", "") else ""
|
||||
if page_slug:
|
||||
tool_label = tool_display_name(f.tool)
|
||||
if col_action.button(
|
||||
f"Open {tool_label} →",
|
||||
f"{tool_label} →",
|
||||
key=f"_finding_open_{row_key}",
|
||||
type="tertiary",
|
||||
width="stretch",
|
||||
):
|
||||
st.switch_page(page_slug)
|
||||
|
||||
body_html = f'<p class="dt-finding-title">{title_html}</p>'
|
||||
if meta_html:
|
||||
body_html += f'<p class="dt-finding-meta">{meta_html}</p>'
|
||||
col_body.markdown(body_html, unsafe_allow_html=True)
|
||||
|
||||
|
||||
_PREVIEW_TABLE_CSS = """
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user