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:
125
src/gui/_home.py
125
src/gui/_home.py
@@ -161,9 +161,30 @@ def _home_page() -> None:
|
|||||||
hide_streamlit_chrome()
|
hide_streamlit_chrome()
|
||||||
render_sticky_footer()
|
render_sticky_footer()
|
||||||
|
|
||||||
st.title(t("home.title"))
|
import html as _html
|
||||||
st.caption(t("home.caption"))
|
# Page header — h1 + body subtitle on the left, privacy pill on
|
||||||
st.divider()
|
# 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(
|
||||||
|
'<header class="dt-page-header">'
|
||||||
|
'<div>'
|
||||||
|
f'<h1>{_html.escape(t("home.title"))}</h1>'
|
||||||
|
f'<p class="dt-page-subtitle">{_html.escape(t("home.caption"))}</p>'
|
||||||
|
'</div>'
|
||||||
|
'<span class="dt-privacy-pill">'
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
|
||||||
|
'<rect x="4" y="11" width="16" height="10" rx="2"/>'
|
||||||
|
'<path d="M8 11V7a4 4 0 018 0v4"/>'
|
||||||
|
'</svg>'
|
||||||
|
f'{privacy_label}'
|
||||||
|
'</span>'
|
||||||
|
'</header>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
st.markdown(f"### {t('upload.heading')}")
|
st.markdown(f"### {t('upload.heading')}")
|
||||||
st.caption(t("upload.intro_multi"))
|
st.caption(t("upload.intro_multi"))
|
||||||
@@ -211,27 +232,59 @@ def _home_page() -> None:
|
|||||||
# interleaving widget-key updates with state changes.
|
# interleaving widget-key updates with state changes.
|
||||||
if home_uploads:
|
if home_uploads:
|
||||||
import hashlib
|
import hashlib
|
||||||
st.markdown("**Imported files**")
|
# Files-card head: count + total size (mockup §section-head).
|
||||||
|
total_bytes = sum(meta["size"] for meta in home_uploads.values())
|
||||||
|
if total_bytes >= 1024:
|
||||||
|
total_label = f"{total_bytes / 1024:.1f} KB"
|
||||||
|
else:
|
||||||
|
total_label = f"{total_bytes:,} B"
|
||||||
|
n_files = len(home_uploads)
|
||||||
|
files_word = "file" if n_files == 1 else "files"
|
||||||
|
st.markdown(
|
||||||
|
'<div class="dt-files-section-head">'
|
||||||
|
f'<h3>Imported files</h3>'
|
||||||
|
f'<span class="dt-section-meta">{n_files} {files_word}'
|
||||||
|
f' · {_html.escape(total_label)} total</span>'
|
||||||
|
'</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
to_remove: str | None = None
|
to_remove: str | None = None
|
||||||
for name in list(home_uploads.keys()):
|
# Document-icon SVG used by every file row's icon chip
|
||||||
digest = hashlib.sha1(
|
# (mockup §file-row .file-icon).
|
||||||
name.encode("utf-8"), usedforsecurity=False,
|
_DOC_SVG = (
|
||||||
).hexdigest()[:10]
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
|
||||||
col_file, col_remove = st.columns([8, 1])
|
'<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>'
|
||||||
col_file.markdown(
|
'<path d="M14 2v6h6"/>'
|
||||||
f"📄 `{name}` "
|
'</svg>'
|
||||||
f"<span style='opacity:0.6'>"
|
)
|
||||||
f"({home_uploads[name]['size']:,} bytes)</span>",
|
with st.container(border=True):
|
||||||
unsafe_allow_html=True,
|
for name in list(home_uploads.keys()):
|
||||||
)
|
digest = hashlib.sha1(
|
||||||
if col_remove.button(
|
name.encode("utf-8"), usedforsecurity=False,
|
||||||
"Remove",
|
).hexdigest()[:10]
|
||||||
key=f"_home_remove_{digest}",
|
col_name, col_size, col_x = st.columns([8, 1.6, 0.55])
|
||||||
help=f"Remove {name}",
|
col_name.markdown(
|
||||||
type="secondary",
|
'<div class="dt-file-row">'
|
||||||
width="stretch",
|
f'<span class="dt-file-icon-chip">{_DOC_SVG}</span>'
|
||||||
):
|
f'<span class="dt-file-name">{_html.escape(name)}</span>'
|
||||||
to_remove = name
|
'</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
col_size.markdown(
|
||||||
|
f'<div style="text-align:right;">'
|
||||||
|
f'<span class="dt-file-size">'
|
||||||
|
f'{home_uploads[name]["size"]:,} B'
|
||||||
|
'</span></div>',
|
||||||
|
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:
|
if to_remove is not None:
|
||||||
from src.audit import log_event
|
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]
|
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:
|
with col_run:
|
||||||
run_clicked = st.button(
|
run_clicked = st.button(
|
||||||
t("upload.run_button"),
|
t("upload.run_button"),
|
||||||
@@ -297,6 +352,14 @@ def _home_page() -> None:
|
|||||||
disabled=not pending,
|
disabled=not pending,
|
||||||
width="stretch",
|
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:
|
with col_clear:
|
||||||
clear_clicked = st.button(
|
clear_clicked = st.button(
|
||||||
t("upload.clear_results"),
|
t("upload.clear_results"),
|
||||||
@@ -340,11 +403,19 @@ def _home_page() -> None:
|
|||||||
findings = findings_by_file[name]
|
findings = findings_by_file[name]
|
||||||
with st.container(border=True):
|
with st.container(border=True):
|
||||||
if not findings:
|
if not findings:
|
||||||
st.markdown(f"### 📄 {name}")
|
st.markdown(
|
||||||
st.success(t("findings.none"))
|
'<div class="dt-finding-group-head">'
|
||||||
|
'<span class="dt-severity-dot success"></span>'
|
||||||
|
f'<span class="dt-group-filename">{_html.escape(name)}</span>'
|
||||||
|
'<div class="dt-group-counts">'
|
||||||
|
'<span class="dt-count-pill success">no issues</span>'
|
||||||
|
'</div>'
|
||||||
|
'</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
render_findings_panel(
|
render_findings_panel(
|
||||||
findings,
|
findings,
|
||||||
header=f"📄 {name}",
|
header=name,
|
||||||
key_namespace=name,
|
key_namespace=name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -281,6 +281,28 @@ body, .stApp {
|
|||||||
background: var(--surface-hover) !important;
|
background: var(--surface-hover) !important;
|
||||||
border-color: var(--ink-tertiary) !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. */
|
/* Disabled state — same low-contrast for both kinds. */
|
||||||
[data-testid="stButton"] button:disabled {
|
[data-testid="stButton"] button:disabled {
|
||||||
background: var(--surface-hover) !important;
|
background: var(--surface-hover) !important;
|
||||||
@@ -415,6 +437,191 @@ div[data-testid="stContainer"][data-border="true"] {
|
|||||||
overflow: hidden !important;
|
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 ---------- */
|
/* ---------- Stats overview ---------- */
|
||||||
/* 4-card grid shown above the per-file findings on the home page,
|
/* 4-card grid shown above the per-file findings on the home page,
|
||||||
summarizing the most recent analysis run. Numeric values use the
|
summarizing the most recent analysis run. Numeric values use the
|
||||||
@@ -2216,13 +2423,31 @@ def render_findings_panel(
|
|||||||
header: str | None = None,
|
header: str | None = None,
|
||||||
key_namespace: str = "",
|
key_namespace: str = "",
|
||||||
) -> None:
|
) -> 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
|
Caller is expected to wrap this in ``st.container(border=True)`` so
|
||||||
of the findings underneath. Severity icon + count are shown inline so
|
the head + body share one card edge. Output layout (per mockup
|
||||||
the user can decide which tool to open first.
|
§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
|
from src.core.text_clean import hidden_char_css
|
||||||
|
|
||||||
if header is None:
|
if header is None:
|
||||||
@@ -2232,68 +2457,128 @@ def render_findings_panel(
|
|||||||
st.success(_t("findings.none"))
|
st.success(_t("findings.none"))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Inject the hidden-char badge styles once so every sample value below
|
# Inject the hidden-char badge styles once so any sample-preview
|
||||||
# can render leading/trailing whitespace and invisibles as visible badges.
|
# 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)
|
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:
|
for f in findings:
|
||||||
by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
|
by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
|
||||||
sev_summary = " · ".join(
|
if by_sev.get("error"):
|
||||||
_t(
|
worst = "error"
|
||||||
"findings.severity_summary_segment",
|
elif by_sev.get("warn"):
|
||||||
icon=_SEVERITY_ICON[s], n=by_sev[s], severity=s,
|
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.markdown(head_html, unsafe_allow_html=True)
|
||||||
st.caption(sev_summary)
|
|
||||||
|
|
||||||
grouped = findings_by_tool(findings)
|
# Stable namespace for per-row widget keys: collisions across files
|
||||||
untargeted = [f for f in findings if not f.tool]
|
# 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):
|
# Sort findings: error > warn > info; preserve registry order
|
||||||
items = grouped[tool_id]
|
# within each severity bucket.
|
||||||
name = tool_display_name(tool_id)
|
sev_rank = {"error": 0, "warn": 1, "info": 2}
|
||||||
with st.expander(
|
sorted_findings = sorted(
|
||||||
_t("findings.tool_section_label", tool=name, n=len(items)),
|
enumerate(findings),
|
||||||
expanded=any(f.severity == "error" for f in items),
|
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:
|
st.switch_page(page_slug)
|
||||||
_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)
|
|
||||||
|
|
||||||
|
|
||||||
_PREVIEW_TABLE_CSS = """
|
_PREVIEW_TABLE_CSS = """
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ _GOOGLE_FONTS_URL = (
|
|||||||
"&display=swap"
|
"&display=swap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Streamlit loads Material Symbols Outlined automatically wherever it
|
||||||
|
# uses ``:material/<name>:`` 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:
|
# 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/
|
# h1 32/600/-0.035em, h2 22/600/-0.025em, h3 18/500/-0.018em, h4 15/500/
|
||||||
@@ -37,6 +49,7 @@ _CSS = f"""
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="{_GOOGLE_FONTS_URL}" rel="stylesheet">
|
<link href="{_GOOGLE_FONTS_URL}" rel="stylesheet">
|
||||||
|
<link href="{_MATERIAL_SYMBOLS_URL}" rel="stylesheet">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {{
|
:root {{
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"home": {
|
"home": {
|
||||||
"page_title": "DataTools — Data Cleaning Mastery",
|
"page_title": "DataTools — Data Cleaning Mastery",
|
||||||
"title": "DataTools — Data Cleaning Mastery",
|
"title": "DataTools — Data Cleaning Mastery",
|
||||||
"caption": "A 9-tool suite for cleaning, standardizing, and validating tabular data. Runs 100% locally.",
|
"caption": "A 9-tool suite for cleaning, standardizing, and validating tabular data.",
|
||||||
|
"privacy_pill": "Runs 100% locally",
|
||||||
"findings_badge_one": "{n} finding",
|
"findings_badge_one": "{n} finding",
|
||||||
"findings_badge_other": "{n} findings"
|
"findings_badge_other": "{n} findings"
|
||||||
},
|
},
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"coming_soon": "Coming Soon"
|
"coming_soon": "Coming Soon"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"heading": "📤 Import one or more files to start",
|
"heading": "Import one or more files to start",
|
||||||
"intro": "Optional: scan an imported file for data quality issues and see which tools can fix each one. Skip if you already know what you need.",
|
"intro": "Optional: scan an imported file for data quality issues and see which tools can fix each one. Skip if you already know what you need.",
|
||||||
"limits": "**Up to 1.5 GB.** Formats: CSV, TSV, XLSX, XLS. Delimiters auto-detected: comma, tab, semicolon, pipe. Encodings auto-detected: UTF-8 (with/without BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — and override on the Review page.",
|
"limits": "**Up to 1.5 GB.** Formats: CSV, TSV, XLSX, XLS. Delimiters auto-detected: comma, tab, semicolon, pipe. Encodings auto-detected: UTF-8 (with/without BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — and override on the Review page.",
|
||||||
"uploader_label": "Import CSV or Excel",
|
"uploader_label": "Import CSV or Excel",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"intro_multi": "Drop files below. Each one is analyzed locally — nothing leaves this computer.",
|
"intro_multi": "Drop files below. Each one is analyzed locally — nothing leaves this computer.",
|
||||||
"uploader_label_multi": "Import CSV, TSV, or Excel files",
|
"uploader_label_multi": "Import CSV, TSV, or Excel files",
|
||||||
"clear_results": "Clear results",
|
"clear_results": "Clear results",
|
||||||
|
"export_report": "Export report",
|
||||||
"empty_state": "Import one or more files to begin. Your data never leaves this computer."
|
"empty_state": "Import one or more files to begin. Your data never leaves this computer."
|
||||||
},
|
},
|
||||||
"findings": {
|
"findings": {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"home": {
|
"home": {
|
||||||
"page_title": "DataTools — Maestría en limpieza de datos",
|
"page_title": "DataTools — Maestría en limpieza de datos",
|
||||||
"title": "DataTools — Maestría en limpieza de datos",
|
"title": "DataTools — Maestría en limpieza de datos",
|
||||||
"caption": "Conjunto de 9 herramientas para limpiar, estandarizar y validar datos tabulares. Se ejecuta 100% en local.",
|
"caption": "Conjunto de 9 herramientas para limpiar, estandarizar y validar datos tabulares.",
|
||||||
|
"privacy_pill": "Se ejecuta 100% en local",
|
||||||
"findings_badge_one": "{n} hallazgo",
|
"findings_badge_one": "{n} hallazgo",
|
||||||
"findings_badge_other": "{n} hallazgos"
|
"findings_badge_other": "{n} hallazgos"
|
||||||
},
|
},
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"coming_soon": "Próximamente"
|
"coming_soon": "Próximamente"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"heading": "📤 Importa uno o más archivos para empezar",
|
"heading": "Importa uno o más archivos para empezar",
|
||||||
"intro": "Opcional: analiza un archivo para detectar problemas de calidad de datos y ver qué herramientas pueden corregir cada uno. Sáltalo si ya sabes lo que necesitas.",
|
"intro": "Opcional: analiza un archivo para detectar problemas de calidad de datos y ver qué herramientas pueden corregir cada uno. Sáltalo si ya sabes lo que necesitas.",
|
||||||
"limits": "**Hasta 1,5 GB.** Formatos: CSV, TSV, XLSX, XLS. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificaciones detectadas automáticamente: UTF-8 (con/sin BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — y se pueden sustituir desde la página Revisar.",
|
"limits": "**Hasta 1,5 GB.** Formatos: CSV, TSV, XLSX, XLS. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificaciones detectadas automáticamente: UTF-8 (con/sin BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — y se pueden sustituir desde la página Revisar.",
|
||||||
"uploader_label": "Importa un archivo CSV o Excel",
|
"uploader_label": "Importa un archivo CSV o Excel",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"intro_multi": "Suelta archivos abajo. Cada uno se analiza localmente — nada sale de este equipo.",
|
"intro_multi": "Suelta archivos abajo. Cada uno se analiza localmente — nada sale de este equipo.",
|
||||||
"uploader_label_multi": "Importa archivos CSV, TSV o Excel",
|
"uploader_label_multi": "Importa archivos CSV, TSV o Excel",
|
||||||
"clear_results": "Borrar resultados",
|
"clear_results": "Borrar resultados",
|
||||||
|
"export_report": "Exportar informe",
|
||||||
"empty_state": "Importa uno o más archivos para empezar. Tus datos nunca salen de este equipo."
|
"empty_state": "Importa uno o más archivos para empezar. Tus datos nunca salen de este equipo."
|
||||||
},
|
},
|
||||||
"findings": {
|
"findings": {
|
||||||
|
|||||||
Reference in New Issue
Block a user