feat: add tooltip for errors\warnings

This commit is contained in:
2026-10-11 14:06:10 +05:00
parent 3c9175f8a3
commit be9ecd0e8e
9 changed files with 175 additions and 39 deletions
@@ -3,6 +3,7 @@
import { RefreshCw } from "@lucide/svelte"; import { RefreshCw } from "@lucide/svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import Badge from "$lib/components/ui/Badge.svelte"; import Badge from "$lib/components/ui/Badge.svelte";
import DetailBadge from "$lib/components/ui/DetailBadge.svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface Props { interface Props {
@@ -15,11 +16,11 @@
dims?: string; dims?: string;
note?: string; note?: string;
noteTone?: "warning" | "info"; noteTone?: "warning" | "info";
// Runtime warnings (chain/download events): shown as badges next to the // Runtime warnings (chain/source/download events): shown as badges next
// derived result note, but from a different source. // to the derived result note, full message in the "?" popover.
warnings?: string[]; warnings?: { label: string; detail?: string }[];
// Runtime errors: danger badges; detail goes to the native tooltip until // Runtime errors: danger badges; the full message lives in the
// a popover with full text exists. // DetailBadge popover behind the "?" button.
errors?: { label: string; detail?: string }[]; errors?: { label: string; detail?: string }[];
actions?: Snippet; actions?: Snippet;
} }
@@ -61,15 +62,20 @@
<Badge tone={noteTone} label={note} /> <Badge tone={noteTone} label={note} />
{/if} {/if}
{#each errors as error (error.label + (error.detail ?? ""))} {#each errors as error (error.label + (error.detail ?? ""))}
<Badge <DetailBadge
tone="danger" tone="danger"
label={error.label} label={error.label}
title={error.detail} detail={error.detail}
role="alert" role="alert"
/> />
{/each} {/each}
{#each warnings as warning (warning)} {#each warnings as warning (warning.label + (warning.detail ?? ""))}
<Badge tone="warning" label={warning} role="status" /> <DetailBadge
tone="warning"
label={warning.label}
detail={warning.detail}
role="status"
/>
{/each} {/each}
</span> </span>
</figcaption> </figcaption>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import DownloadButton from "$lib/components/ui/DownloadButton.svelte"; import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte"; import UploadButton from "$lib/components/ui/UploadButton.svelte";
import Badge from "$lib/components/ui/Badge.svelte"; import DetailBadge from "$lib/components/ui/DetailBadge.svelte";
import type { OutputMime } from "$lib/core/io"; import type { OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/output-formats"; import { outputFormatByMime } from "$lib/output-formats";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
@@ -18,7 +18,7 @@
limitKb?: number; limitKb?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
// Download-zone feedback: size-limit misses and action failures. // Download-zone feedback: size-limit misses and action failures.
warnings?: string[]; warnings?: { label: string; detail?: string }[];
error?: { label: string; detail?: string } | null; error?: { label: string; detail?: string } | null;
onupload: (file: File) => void; onupload: (file: File) => void;
ondownload: () => void; ondownload: () => void;
@@ -67,14 +67,19 @@
disabled={running} disabled={running}
/> />
{/if} {/if}
{#each warnings as warning (warning)} {#each warnings as warning (warning.label + (warning.detail ?? ""))}
<Badge tone="warning" label={warning} role="status" /> <DetailBadge
tone="warning"
label={warning.label}
detail={warning.detail}
role="status"
/>
{/each} {/each}
{#if error} {#if error}
<Badge <DetailBadge
tone="danger" tone="danger"
label={error.label} label={error.label}
title={error.detail} detail={error.detail}
role="alert" role="alert"
/> />
{/if} {/if}
@@ -13,7 +13,7 @@
resultKind?: ResultKind; resultKind?: ResultKind;
result: PixelImage | null; result: PixelImage | null;
resultNote?: ResultNote | null; resultNote?: ResultNote | null;
warnings?: string[]; warnings?: { label: string; detail?: string }[];
errors?: { label: string; detail?: string }[]; errors?: { label: string; detail?: string }[];
fileResult?: FileResult | null; fileResult?: FileResult | null;
textResult?: string | null; textResult?: string | null;
@@ -15,7 +15,7 @@
textSource?: string; textSource?: string;
running?: boolean; running?: boolean;
fileCount?: number; fileCount?: number;
warnings?: string[]; warnings?: { label: string; detail?: string }[];
errors?: { label: string; detail?: string }[]; errors?: { label: string; detail?: string }[];
ontextinput?: (text: string) => void; ontextinput?: (text: string) => void;
onrendertext?: () => void; onrendertext?: () => void;
@@ -9,9 +9,10 @@
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { import {
chainStepTitle, chainStepTitle,
chainWarningText, chainWarningNotice,
pageDescription, pageDescription,
pageTitle, pageTitle,
type WarningNotice,
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { moveStep, type ChainStep } from "$lib/pipeline.svelte"; import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
@@ -124,20 +125,18 @@
const stepFileSets = $derived(runner.stepFileSets); const stepFileSets = $derived(runner.stepFileSets);
const runWarnings = $derived(runner.runWarnings); const runWarnings = $derived(runner.runWarnings);
// Feedback zones: source tile / step tiles / download button area. // Feedback zones: source tile / step tiles / download button area.
const sourceWarningTexts = $derived( const sourceNotices = $derived(upload.sourceWarnings.map(chainWarningNotice));
upload.sourceWarnings.map(chainWarningText),
);
const warningsByStep = $derived.by(() => { const warningsByStep = $derived.by(() => {
const byStep: string[][] = steps.map(() => []); const byStep: WarningNotice[][] = steps.map(() => []);
for (const w of runWarnings) { for (const w of runWarnings) {
if ("step" in w && w.step >= 1 && w.step <= byStep.length) { if ("step" in w && w.step >= 1 && w.step <= byStep.length) {
byStep[w.step - 1].push(chainWarningText(w)); byStep[w.step - 1].push(chainWarningNotice(w));
} }
} }
return byStep; return byStep;
}); });
const downloadWarningTexts = $derived( const downloadNotices = $derived(
runner.downloadWarnings.map(chainWarningText), runner.downloadWarnings.map(chainWarningNotice),
); );
const stepError = $derived(runner.stepError); const stepError = $derived(runner.stepError);
const sourceError = $derived(runner.sourceError); const sourceError = $derived(runner.sourceError);
@@ -305,7 +304,7 @@
textSource={upload.textSource} textSource={upload.textSource}
running={busy} running={busy}
fileCount={upload.sourceFiles.length} fileCount={upload.sourceFiles.length}
warnings={sourceWarningTexts} warnings={sourceNotices}
errors={errorBadge(sourceError)} errors={errorBadge(sourceError)}
ontextinput={(v) => (upload.textSource = v)} ontextinput={(v) => (upload.textSource = v)}
onrendertext={run} onrendertext={run}
@@ -407,7 +406,7 @@
quality={output.quality} quality={output.quality}
limitKb={output.limitKb} limitKb={output.limitKb}
alphaLoss={output.alphaLoss} alphaLoss={output.alphaLoss}
warnings={downloadWarningTexts} warnings={downloadNotices}
error={actionError error={actionError
? { ? {
label: t("actions.actionFailed"), label: t("actions.actionFailed"),
@@ -0,0 +1,98 @@
<script lang="ts">
import { t } from "$lib/i18n/t";
import Badge from "./Badge.svelte";
interface Props {
tone: "warning" | "danger" | "info";
label: string;
detail?: string;
role?: "alert" | "status";
}
let { tone, label, detail = undefined, role = "status" }: Props = $props();
const id = $props.id();
// Anchor name is per-instance: with a shared name several popovers would
// fight over the same anchor element.
let anchorName = `--badge-${id}`;
let open = $state(false);
</script>
<span class="detail-badge">
<Badge {tone} {label} {role} />
{#if detail}
<button
type="button"
class="more"
style:anchor-name={anchorName}
aria-label={t("ui.detailsAria")}
aria-expanded={open}
aria-controls={id}
popovertarget={id}
>
?
</button>
<span
{id}
class="popover"
role="tooltip"
popover="auto"
style:position-anchor={anchorName}
onbeforetoggle={(e) => (open = e.newState === "open")}
>
{detail}
</span>
{/if}
</span>
<style>
.detail-badge {
display: inline-flex;
align-items: center;
gap: var(--space-s);
}
.more {
display: inline-flex;
align-items: center;
justify-content: center;
/* Мини-размер цели в 24px: попадание пальцем без промаха. */
width: calc(var(--space-xxxl) * 0.66);
height: calc(var(--space-xxxl) * 0.66);
padding: 0;
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-panel);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
font-weight: bold;
line-height: 1;
cursor: pointer;
}
.more:hover,
.more:focus-visible {
color: var(--color-text);
border-color: var(--color-main);
}
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
поверх всего — из коробки, без z-index и ручных подписок. */
.popover {
position: fixed;
margin: 0;
top: anchor(bottom);
left: anchor(left);
translate: 0 var(--space-s);
/* Не уезжает за правый край: доступная ширина считается от якоря. */
width: max-content;
max-width: calc(100vw - anchor(left) - var(--space-xl));
padding: var(--space-m) var(--space-l);
border: var(--size-border-thick) solid var(--color-text-muted);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
font-weight: normal;
text-align: left;
text-transform: none;
white-space: normal;
overflow-wrap: anywhere;
}
</style>
+5
View File
@@ -70,6 +70,10 @@ export const en: Dict = {
warnSizeLimitMiss: warnSizeLimitMiss:
"Could not fit into {limit} KB — downloaded file is {actual} KB", "Could not fit into {limit} KB — downloaded file is {actual} KB",
stepFailed: "Step {n} failed", stepFailed: "Step {n} failed",
warnPartialShort: "Some files failed",
warnFirstOnlyShort: "First file only",
warnSourceSkipShort: "Files skipped",
warnSizeLimitMissShort: "Over size limit",
}, },
savedChains: { savedChains: {
pageTitle: "Saved pipelines — easy-png-tools", pageTitle: "Saved pipelines — easy-png-tools",
@@ -172,6 +176,7 @@ export const en: Dict = {
nothingFound: "Nothing found — try another word.", nothingFound: "Nothing found — try another word.",
}, },
ui: { ui: {
detailsAria: "Show details",
showMask: "Show mask", showMask: "Show mask",
decrease: "Decrease", decrease: "Decrease",
increase: "Increase", increase: "Increase",
+5
View File
@@ -72,6 +72,10 @@ export const ru: Dict = {
warnSizeLimitMiss: warnSizeLimitMiss:
"Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ", "Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ",
stepFailed: "Шаг {n}: ошибка", stepFailed: "Шаг {n}: ошибка",
warnPartialShort: "Часть файлов с ошибкой",
warnFirstOnlyShort: "Только первый файл",
warnSourceSkipShort: "Файлы пропущены",
warnSizeLimitMissShort: "Больше лимита",
}, },
savedChains: { savedChains: {
pageTitle: "Сохранённые пайплайны — easy-png-tools", pageTitle: "Сохранённые пайплайны — easy-png-tools",
@@ -173,6 +177,7 @@ export const ru: Dict = {
nothingFound: "Ничего не найдено — попробуйте другое слово.", nothingFound: "Ничего не найдено — попробуйте другое слово.",
}, },
ui: { ui: {
detailsAria: "Показать подробности",
showMask: "Показать маску", showMask: "Показать маску",
decrease: "Уменьшить", decrease: "Уменьшить",
increase: "Увеличить", increase: "Увеличить",
+26 -8
View File
@@ -68,23 +68,41 @@ export function verdictTone(key: string): "success" | "danger" | "info" {
return "info"; return "info";
} }
/** Human-readable text for a chain run/source warning. */ /** Short label for a warning badge; detail holds the full message (popover). */
export function chainWarningText(w: ChainWarning): string { export interface WarningNotice {
label: string;
detail: string;
}
/** Human-readable badge + popover text for a chain run/source warning. */
export function chainWarningNotice(w: ChainWarning): WarningNotice {
switch (w.kind) { switch (w.kind) {
case "partial": case "partial":
return t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total }); return {
label: t("chain.warnPartialShort"),
detail: t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total }),
};
case "firstOnly": case "firstOnly":
return t("chain.warnFirstOnly", { n: w.step, total: w.total }); return {
label: t("chain.warnFirstOnlyShort"),
detail: t("chain.warnFirstOnly", { n: w.step, total: w.total }),
};
case "sourceSkip": case "sourceSkip":
return t("chain.warnSourceSkip", { return {
label: t("chain.warnSourceSkipShort"),
detail: t("chain.warnSourceSkip", {
skipped: w.skipped, skipped: w.skipped,
total: w.total, total: w.total,
}); }),
};
case "sizeLimitMiss": case "sizeLimitMiss":
return t("chain.warnSizeLimitMiss", { return {
label: t("chain.warnSizeLimitMissShort"),
detail: t("chain.warnSizeLimitMiss", {
limit: w.limitKb, limit: w.limitKb,
actual: w.actualKb, actual: w.actualKb,
}); }),
};
} }
} }