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:
2026-05-19 01:40:22 +00:00
parent 74d0ee270f
commit 0bb72ecd7e
2 changed files with 254 additions and 28 deletions

View File

@@ -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

View File

@@ -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>