diff --git a/src/gui/_home.py b/src/gui/_home.py
index b39753b..0f5d0e8 100644
--- a/src/gui/_home.py
+++ b/src/gui/_home.py
@@ -161,9 +161,30 @@ def _home_page() -> None:
hide_streamlit_chrome()
render_sticky_footer()
- st.title(t("home.title"))
- st.caption(t("home.caption"))
- st.divider()
+ import html as _html
+ # Page header β h1 + body subtitle on the left, privacy pill on
+ # the right (mockup Β§page-header). Rendered as a single HTML block
+ # so the title/subtitle/pill share one flex row; ``st.title`` +
+ # ``st.caption`` + ``st.divider`` would stack vertically and lose
+ # the right-aligned pill. Bottom border replaces the explicit
+ # ``st.divider`` that used to sit below the caption.
+ privacy_label = _html.escape(t("home.privacy_pill"))
+ st.markdown(
+ ''
+ '
',
+ unsafe_allow_html=True,
+ )
+
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}` "
- f""
- f"({home_uploads[name]['size']:,} bytes)",
- unsafe_allow_html=True,
- )
- if col_remove.button(
- "Remove",
- key=f"_home_remove_{digest}",
- help=f"Remove {name}",
- type="secondary",
- width="stretch",
- ):
- to_remove = name
+ # Document-icon SVG used by every file row's icon chip
+ # (mockup Β§file-row .file-icon).
+ _DOC_SVG = (
+ ''
+ )
+ with st.container(border=True):
+ for name in list(home_uploads.keys()):
+ digest = hashlib.sha1(
+ name.encode("utf-8"), usedforsecurity=False,
+ ).hexdigest()[:10]
+ col_name, col_size, col_x = st.columns([8, 1.6, 0.55])
+ col_name.markdown(
+ '
'
+ f'{_DOC_SVG}'
+ f'{_html.escape(name)}'
+ '
',
+ unsafe_allow_html=True,
+ )
+ col_size.markdown(
+ f'
'
+ f''
+ f'{home_uploads[name]["size"]:,} B'
+ '
',
+ unsafe_allow_html=True,
+ )
+ if col_x.button(
+ "β",
+ key=f"_home_remove_{digest}",
+ help=f"Remove {name}",
+ type="tertiary",
+ ):
+ to_remove = name
if to_remove is not None:
from src.audit import log_event
@@ -288,7 +341,9 @@ def _home_page() -> None:
pending = [name for name in home_uploads if name not in findings_by_file]
- col_run, col_clear, _ = st.columns([1, 1, 4])
+ # Action bar β Run / Export report (disabled, coming soon) /
+ # Clear results, matching the mockup Β§action-bar.
+ col_run, col_export, col_clear, _ = st.columns([1, 1, 1, 3])
with col_run:
run_clicked = st.button(
t("upload.run_button"),
@@ -297,6 +352,14 @@ def _home_page() -> None:
disabled=not pending,
width="stretch",
)
+ with col_export:
+ st.button(
+ t("upload.export_report"),
+ key="home_export_report",
+ disabled=True,
+ help="Coming soon",
+ width="stretch",
+ )
with col_clear:
clear_clicked = st.button(
t("upload.clear_results"),
@@ -340,11 +403,19 @@ def _home_page() -> None:
findings = findings_by_file[name]
with st.container(border=True):
if not findings:
- st.markdown(f"### π {name}")
- st.success(t("findings.none"))
+ st.markdown(
+ '
'
+ ''
+ f'{_html.escape(name)}'
+ '
'
+ 'no issues'
+ '
'
+ '
',
+ unsafe_allow_html=True,
+ )
else:
render_findings_panel(
findings,
- header=f"π {name}",
+ header=name,
key_namespace=name,
)
diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py
index 68dd18c..8c22c1c 100644
--- a/src/gui/components/_legacy.py
+++ b/src/gui/components/_legacy.py
@@ -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
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
+ β β in `col` Open Tool β β β row
+ β meta: rows, hint, β¦ β
+ β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
+ β β 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 β" 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'{n} {label}'
)
- for s in ("error", "warn", "info") if by_sev.get(s)
+
+ head_html = (
+ '
'
+ f''
+ f'{_html.escape(header)}'
+ f'
{pills_html}
'
+ '
'
)
- 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 ```` 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 ' + _html.escape(str(f.column)) + ''
+ )
+ 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'
'
+ f'{icon_name}'
+ '
',
+ unsafe_allow_html=True,
+ )
+
+ body_html = f'
{title_html}
'
+ if meta_html:
+ body_html += f'
{meta_html}
'
+ 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 = """
diff --git a/src/gui/theme.py b/src/gui/theme.py
index fbf0bd6..b93b7aa 100644
--- a/src/gui/theme.py
+++ b/src/gui/theme.py
@@ -26,6 +26,18 @@ _GOOGLE_FONTS_URL = (
"&display=swap"
)
+# Streamlit loads Material Symbols Outlined automatically wherever it
+# uses ``:material/:`` syntax (sidebar icons). Our custom HTML
+# severity-chip icons reach for the same family, so request it
+# explicitly here β guarantees the font is present before any inline
+# ligature renders, regardless of whether Streamlit has emitted a
+# ``:material/`` token yet.
+_MATERIAL_SYMBOLS_URL = (
+ "https://fonts.googleapis.com/css2"
+ "?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0"
+ "&display=block"
+)
+
# Spec Β§3 + Β§7. Heading rules track the canonical type scale in spec Β§4:
# h1 32/600/-0.035em, h2 22/600/-0.025em, h3 18/500/-0.018em, h4 15/500/
@@ -37,6 +49,7 @@ _CSS = f"""
+