From a9788ba712a3a9712588c1bb8661c900344e4a98 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 19 May 2026 00:43:42 +0000 Subject: [PATCH] feat(ui): page header + files card + action bar + findings cards (mockup 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ```` column chip) + mono meta line (rows affected, samples captured) + tertiary ``Open →`` 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) --- src/gui/_home.py | 125 ++++++++--- src/gui/components/_legacy.py | 401 +++++++++++++++++++++++++++++----- src/gui/theme.py | 13 ++ src/i18n/packs/en.json | 6 +- src/i18n/packs/es.json | 6 +- 5 files changed, 462 insertions(+), 89 deletions(-) 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( + '
' + '
' + f'

{_html.escape(t("home.title"))}

' + f'

{_html.escape(t("home.caption"))}

' + '
' + '' + '' + '' + '' + '' + f'{privacy_label}' + '' + '
', + unsafe_allow_html=True, + ) st.markdown(f"### {t('upload.heading')}") st.caption(t("upload.intro_multi")) @@ -211,27 +232,59 @@ def _home_page() -> None: # interleaving widget-key updates with state changes. if home_uploads: 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( + '
' + f'

Imported files

' + f'' + '
', + 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, … │ + │ ────────────────────────────────────────────────────── │ + │ ⓘ <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 = """ 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/<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: # 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.gstatic.com" crossorigin> <link href="{_GOOGLE_FONTS_URL}" rel="stylesheet"> +<link href="{_MATERIAL_SYMBOLS_URL}" rel="stylesheet"> <style> :root {{ diff --git a/src/i18n/packs/en.json b/src/i18n/packs/en.json index 521e35e..518c882 100644 --- a/src/i18n/packs/en.json +++ b/src/i18n/packs/en.json @@ -6,7 +6,8 @@ "home": { "page_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_other": "{n} findings" }, @@ -15,7 +16,7 @@ "coming_soon": "Coming Soon" }, "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.", "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", @@ -31,6 +32,7 @@ "intro_multi": "Drop files below. Each one is analyzed locally — nothing leaves this computer.", "uploader_label_multi": "Import CSV, TSV, or Excel files", "clear_results": "Clear results", + "export_report": "Export report", "empty_state": "Import one or more files to begin. Your data never leaves this computer." }, "findings": { diff --git a/src/i18n/packs/es.json b/src/i18n/packs/es.json index 9a7eea7..1272517 100644 --- a/src/i18n/packs/es.json +++ b/src/i18n/packs/es.json @@ -6,7 +6,8 @@ "home": { "page_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_other": "{n} hallazgos" }, @@ -15,7 +16,7 @@ "coming_soon": "Próximamente" }, "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.", "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", @@ -31,6 +32,7 @@ "intro_multi": "Suelta archivos abajo. Cada uno se analiza localmente — nada sale de este equipo.", "uploader_label_multi": "Importa archivos CSV, TSV o Excel", "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." }, "findings": {