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'{n_files} {files_word}' + f' Β· {_html.escape(total_label)} total' + '
', + 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": {