feat(ui): page header + files card + action bar + findings cards (mockup 2)

Closes the remaining gaps between the live home page and the
``datatools_layout_redesign2.html`` mockup. Four pieces land
together because they all consume the same new CSS scaffold:

1. Page header (§page-header)
   ``st.title`` + ``st.caption`` + ``st.divider`` collapse into one
   flex header: h1 + body subtitle on the left, ``Runs 100% locally``
   privacy pill (success-fill + lock SVG) on the right, soft border
   below. The "Runs 100% locally" phrase moved out of
   ``home.caption`` into the new ``home.privacy_pill`` i18n key
   (en + es).

2. Files card (§files-card)
   The "Imported files" list is now a single bordered card with a
   section head (count + KB total on the right, mockup §section-head).
   Each row renders a 28px accent-fill chip carrying the inline
   document SVG, a mono filename, a right-aligned mono size, and a
   compact ``✕`` button. The word-button ``Remove`` is gone —
   replaced by an icon-only tertiary button styled via a new CSS
   rule that goes transparent → danger-fill on hover (mockup
   §file-remove).

3. Action bar (§action-bar)
   Three buttons in one row: ``Run analysis`` (primary ink), a new
   disabled ``Export report`` (secondary; coming soon, tooltip), and
   ``Clear results``. New i18n key ``upload.export_report``.

4. Findings — per-file group cards (§finding-group)
   ``render_findings_panel`` rewritten end-to-end. Output is now:
     • A head row (``dt-finding-group-head``) bleeding to the card
       edges: worst-severity dot · mono filename · count pills
       enumerating non-zero severities (e.g. ``2 info`` blue,
       ``1 warning`` amber, ``1 error`` rose).
     • A flat list of finding rows sorted error → warn → info.
       Each row: tinted Material-icon chip + title (description
       with optional ``<code>`` column chip) + mono meta line
       (rows affected, samples captured) + tertiary
       ``Open <Tool> →`` action button that ``st.switch_page``s
       to the relevant tool.
   The previous tool-grouped expander stack is dropped — the new
   layout is denser and matches the mockup's single-card-per-file
   structure.

   ``_render_one_finding`` (the old per-finding helper that emitted
   markdown lines + sample tables) remains in the file but is no
   longer called from the home flow; left in place for any other
   surface that still depends on the markdown style.

   The "no issues" success state renders a green dot + mono
   filename + ``no issues`` success pill in the same card chrome,
   so empty-result files visually match the rest of the panel
   rather than getting a generic ``st.success`` callout.

CSS additions (``_DESIGN_TOKENS_CSS``):
  ``.dt-page-header / .dt-page-subtitle / .dt-privacy-pill``
  ``.dt-files-section-head / .dt-section-meta``
  ``.dt-file-row / .dt-file-icon-chip / .dt-file-name / .dt-file-size``
  ``.dt-finding-group-head / .dt-severity-dot{.warn,.info,.error,.success}``
  ``.dt-group-filename / .dt-group-counts``
  ``.dt-count-pill{.warn,.info,.error,.success}``
  ``.dt-finding-row / .dt-finding-icon{.warn,.info,.error}``
  ``.dt-finding-title / .dt-finding-meta``
  Tertiary button rule (transparent → danger-fill on hover) for
  the X button and the ``Open Tool →`` row action.

theme.py:
  Explicitly loads Material Symbols Outlined alongside Geist —
  the severity-chip ligatures (``info`` / ``warning`` / ``error``)
  need the font present even when no ``:material/`` token has been
  emitted yet on the page. Tightened ``.dt-finding-icon .dt-mui``
  selector with ``[data-testid="stMarkdownContainer"]``-scoped
  variant so the Material font wins over theme.py's base
  ``var(--font-sans) !important`` on markdown descendants.

Leading section-heading emojis stripped from i18n
(``upload.heading``) for parity with the mockup's clean ``Files``
/ ``Findings`` h2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 00:43:42 +00:00
parent da7d86f457
commit a9788ba712
5 changed files with 462 additions and 89 deletions

View File

@@ -281,6 +281,28 @@ body, .stApp {
background: var(--surface-hover) !important;
border-color: var(--ink-tertiary) !important;
}
/* Tertiary = icon-button style — transparent surface, tertiary ink,
danger tint on hover. Used for the X "remove file" affordance and
other quiet inline actions. */
[data-testid="stButton"] button[kind="tertiary"],
[data-testid="stButton"] button[data-testid="stBaseButton-tertiary"] {
background: transparent !important;
color: var(--ink-tertiary) !important;
border: none !important;
padding: 4px 8px !important;
min-height: 0 !important;
}
[data-testid="stButton"] button[kind="tertiary"]:hover,
[data-testid="stButton"] button[data-testid="stBaseButton-tertiary"]:hover {
background: var(--danger-fill) !important;
color: var(--danger) !important;
}
/* The button label is in a child p; force it to inherit the button's
color so the danger tint shows through on hover. */
[data-testid="stButton"] button[kind="tertiary"] * {
color: inherit !important;
}
/* Disabled state — same low-contrast for both kinds. */
[data-testid="stButton"] button:disabled {
background: var(--surface-hover) !important;
@@ -415,6 +437,191 @@ div[data-testid="stContainer"][data-border="true"] {
overflow: hidden !important;
}
/* ---------- Page header (title + subtitle + privacy pill) ---------- */
.dt-page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin: 0 0 24px;
padding-bottom: 22px;
border-bottom: 1px solid var(--border);
}
.dt-page-header h1 { margin: 0 !important; }
.dt-page-header .dt-page-subtitle {
margin: 6px 0 0;
color: var(--ink-secondary) !important;
font-size: 14px;
line-height: 1.55;
}
.dt-privacy-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 11px;
background: var(--success-fill);
color: var(--success);
border-radius: 999px;
font-family: var(--font-sans);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.dt-privacy-pill svg {
width: 13px; height: 13px;
stroke-width: 2;
}
/* ---------- Files card (mockup §files-card) ---------- */
/* Card head + row layout. The data lives in real ``st.button`` widgets
for the remove action — those are styled separately further down by
keyed selector. */
.dt-files-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 4px 0 10px;
gap: 12px;
}
.dt-files-section-head h3 { margin: 0 !important; }
.dt-files-section-head .dt-section-meta {
font-size: 12.5px;
color: var(--ink-tertiary);
font-family: var(--font-sans);
}
.dt-file-row {
display: flex;
align-items: center;
gap: 12px;
}
.dt-file-icon-chip {
width: 28px;
height: 28px;
border-radius: var(--r-sm);
background: var(--accent-fill);
color: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dt-file-icon-chip svg { width: 14px; height: 14px; stroke-width: 1.8; }
.dt-file-name {
font-family: var(--font-mono) !important;
font-size: 13px;
color: var(--ink) !important;
font-feature-settings: "ss02";
}
.dt-file-size {
font-family: var(--font-mono) !important;
font-size: 12px;
color: var(--ink-tertiary) !important;
font-feature-settings: "ss02";
}
/* ---------- Findings — per-file group cards (mockup §findings) ---------- */
.dt-finding-group-head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-hover);
margin: -1rem -1rem 0;
border-radius: var(--r-lg) var(--r-lg) 0 0;
}
.dt-severity-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.dt-severity-dot.warn { background: var(--warn); }
.dt-severity-dot.info { background: var(--info); }
.dt-severity-dot.error { background: var(--danger); }
.dt-severity-dot.success { background: var(--success); }
.dt-group-filename {
font-family: var(--font-mono) !important;
font-size: 13.5px !important;
font-weight: 500 !important;
color: var(--ink) !important;
font-feature-settings: "ss02";
}
.dt-group-counts {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.dt-count-pill {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: 11.5px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.dt-count-pill.warn { background: var(--warn-fill); color: var(--warn); }
.dt-count-pill.info { background: var(--info-fill); color: var(--info); }
.dt-count-pill.error { background: var(--danger-fill); color: var(--danger); }
.dt-count-pill.success { background: var(--success-fill); color: var(--success); }
.dt-finding-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 0;
border-top: 1px solid var(--border);
}
.dt-finding-row:first-of-type { border-top: none; }
.dt-finding-icon {
width: 24px;
height: 24px;
border-radius: var(--r-sm);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dt-finding-icon.warn { background: var(--warn-fill); color: var(--warn); }
.dt-finding-icon.info { background: var(--info-fill); color: var(--info); }
.dt-finding-icon.error { background: var(--danger-fill); color: var(--danger); }
.dt-finding-icon svg { width: 12px; height: 12px; stroke-width: 2.2; }
/* Material Symbols Outlined applied to the inline ligature span. The
selector is doubled (``.dt-finding-icon .dt-mui``) to give it more
specificity than theme.py's base ``font-family: var(--font-sans)
!important`` on stMarkdownContainer descendants. */
.dt-finding-icon .dt-mui,
[data-testid="stMarkdownContainer"] .dt-finding-icon .dt-mui {
font-family: "Material Symbols Outlined" !important;
font-size: 16px !important;
font-feature-settings: normal !important;
font-weight: 400 !important;
line-height: 1 !important;
letter-spacing: 0 !important;
}
.dt-finding-body { flex: 1; min-width: 0; }
.dt-finding-title {
font-size: 14px !important;
color: var(--ink) !important;
margin: 0 0 2px !important;
line-height: 1.4 !important;
letter-spacing: -0.005em;
}
.dt-finding-title strong { font-weight: 500 !important; }
.dt-finding-meta {
font-family: var(--font-mono) !important;
font-size: 12px !important;
color: var(--ink-tertiary) !important;
line-height: 1.4 !important;
margin: 0 !important;
font-feature-settings: "ss02";
}
/* ---------- Stats overview ---------- */
/* 4-card grid shown above the per-file findings on the home page,
summarizing the most recent analysis run. Numeric values use the
@@ -2216,13 +2423,31 @@ def render_findings_panel(
header: str | None = None,
key_namespace: str = "",
) -> None:
"""Render a list of :class:`Finding` objects grouped by tool.
"""Render a per-file findings card matching ``datatools_layout_redesign2.html``.
Each tool gets a header with the count, an open-tool button, and a list
of the findings underneath. Severity icon + count are shown inline so
the user can decide which tool to open first.
Caller is expected to wrap this in ``st.container(border=True)`` so
the head + body share one card edge. Output layout (per mockup
§finding-group):
┌───────────────────────────────────────────────────────┐
│ ● filename.csv [1 warning][2 info] │ ← head
├───────────────────────────────────────────────────────┤
│ ⚠ <title> in `col` Open Tool → │ ← row
│ meta: rows, hint, … │
│ ────────────────────────────────────────────────────── │
│ ⓘ <title> in `col` Open Tool → │
│ … │
└───────────────────────────────────────────────────────┘
The head's severity dot picks the worst severity present; the count
pills enumerate non-zero severities. Findings are flat-listed
(sorted error > warn > info), each with a tinted Material-icon
chip, the description, a mono meta line (affected count + samples
hint), and a tertiary "Open <Tool> →" link that ``st.switch_page``s
to the relevant tool page.
"""
from src.core.analyze import findings_by_tool # local import to avoid cycle
import html as _html
import hashlib as _hashlib
from src.core.text_clean import hidden_char_css
if header is None:
@@ -2232,68 +2457,128 @@ def render_findings_panel(
st.success(_t("findings.none"))
return
# Inject the hidden-char badge styles once so every sample value below
# can render leading/trailing whitespace and invisibles as visible badges.
# Inject the hidden-char badge styles once so any sample-preview
# surface rendered later can show leading/trailing whitespace and
# invisibles as visible badges. Cheap if already injected.
st.markdown(hidden_char_css() + _SAMPLE_TABLE_CSS, unsafe_allow_html=True)
by_sev: dict[str, int] = {}
# Sort severity counts; worst severity drives the head dot.
by_sev: dict[str, int] = {"error": 0, "warn": 0, "info": 0}
for f in findings:
by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
sev_summary = " · ".join(
_t(
"findings.severity_summary_segment",
icon=_SEVERITY_ICON[s], n=by_sev[s], severity=s,
if by_sev.get("error"):
worst = "error"
elif by_sev.get("warn"):
worst = "warn"
else:
worst = "info"
pill_labels = {
"error": ("error", "errors"),
"warn": ("warning", "warnings"),
"info": ("info", "info"),
}
pills_html = ""
for sev in ("error", "warn", "info"):
n = by_sev.get(sev, 0)
if not n:
continue
singular, plural = pill_labels[sev]
label = singular if n == 1 else plural
pills_html += (
f'<span class="dt-count-pill {sev}">{n} {label}</span>'
)
for s in ("error", "warn", "info") if by_sev.get(s)
head_html = (
'<div class="dt-finding-group-head">'
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>'
'</div>'
)
st.markdown(f"### {header}")
st.caption(sev_summary)
st.markdown(head_html, unsafe_allow_html=True)
grouped = findings_by_tool(findings)
untargeted = [f for f in findings if not f.tool]
# Stable namespace for per-row widget keys: collisions across files
# would otherwise hit when two files surface findings from the
# same tool. SHA-1 the caller's namespace to keep keys identifier-
# safe (filenames may contain spaces / dots / unicode).
ns = _hashlib.sha1(
(key_namespace or "").encode("utf-8"), usedforsecurity=False,
).hexdigest()[:8]
for tool_id in sorted(grouped):
items = grouped[tool_id]
name = tool_display_name(tool_id)
with st.expander(
_t("findings.tool_section_label", tool=name, n=len(items)),
expanded=any(f.severity == "error" for f in items),
# Sort findings: error > warn > info; preserve registry order
# within each severity bucket.
sev_rank = {"error": 0, "warn": 1, "info": 2}
sorted_findings = sorted(
enumerate(findings),
key=lambda iv: (sev_rank.get(iv[1].severity, 99), iv[0]),
)
for i, f in sorted_findings:
_render_finding_row_v2(f, row_key=f"{ns}_{i}")
def _render_finding_row_v2(f, *, row_key: str) -> None:
"""One row inside the per-file findings card.
Layout: severity chip (col 1) · title + meta (col 2) · "Open Tool"
tertiary action (col 3). Title and meta render as raw HTML so the
column name can carry a ``<code>`` chip and counts stay
Geist-Mono-styled.
"""
import html as _html
severity_to_icon = {
"error": "error",
"warn": "warning",
"info": "info",
}
icon_name = severity_to_icon.get(f.severity, "info")
# Title: description + optional column chip.
column_part = ""
if getattr(f, "column", None):
column_part = (
' in <code>' + _html.escape(str(f.column)) + '</code>'
)
title_html = _html.escape(f.description) + column_part
# Meta: row count + samples hint, mono.
meta_parts: list[str] = []
if getattr(f, "count", 0):
n = int(f.count)
meta_parts.append(
f"{n:,} {'row' if n == 1 else 'rows'} affected"
)
if getattr(f, "samples", None):
meta_parts.append(f"{len(f.samples)} sample"
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])
col_icon.markdown(
f'<div class="dt-finding-icon {f.severity}">'
f'<span class="dt-mui">{icon_name}</span>'
'</div>',
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}",
key=f"_finding_open_{row_key}",
type="tertiary",
width="stretch",
):
for f in items:
_render_one_finding(f)
page_slug = _tool_page_slug(tool_id)
if page_slug:
# Render as a primary (red) ``st.button`` rather than the
# subtle ``st.page_link`` we used before — the previous
# rendering blended into the page, making the per-tool
# jump non-obvious. The button triggers ``st.switch_page``
# so navigation is still a soft switch (no full reload).
#
# ``key_namespace`` is hashed into the widget key so the
# home page (which calls this once PER uploaded file)
# doesn't collide on the shared tool_id — two files both
# having Clean Text findings would otherwise produce two
# buttons with the same key and Streamlit refuses.
import hashlib as _hashlib
ns = _hashlib.sha1(
(key_namespace or "").encode("utf-8"),
usedforsecurity=False,
).hexdigest()[:8]
if st.button(
_t("findings.open_tool", tool=name),
key=f"_findings_open_{tool_id}_{ns}",
type="primary",
width="content",
):
st.switch_page(page_slug)
if untargeted:
with st.expander(
_t("findings.other_section_label", n=len(untargeted)),
expanded=False,
):
for f in untargeted:
_render_one_finding(f)
st.switch_page(page_slug)
_PREVIEW_TABLE_CSS = """