feat: add Russian localization and independent STT languages
CI / Typecheck, tests, lint (push) Canceled after 0s
CI / Standalone (mac-arm64) (push) Canceled after 0s
CI / Standalone (linux-x64) (push) Canceled after 0s
CI / Standalone (windows-x64) (push) Canceled after 0s

This commit is contained in:
2026-10-25 17:18:44 +03:00
parent b277ee3e0f
commit a50ca4b867
93 changed files with 3261 additions and 904 deletions
+16 -15
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { DownloadIcon } from "lucide-react";
@@ -100,12 +102,12 @@ export function DemoOutputCard({
messageId?: string;
initialInverted?: boolean;
}) {
const { t } = useI18n();
const result = output as DemoResult | undefined;
if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
Demo completed, but the result could not be displayed.
</div>
{t("Demo completed, but the result could not be displayed.")}</div>
);
}
const failed = result.html.length === 0;
@@ -113,10 +115,9 @@ export function DemoOutputCard({
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="demo-card-failed">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Interactive demo
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
{t("Interactive demo")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? "The demo could not be built."}</p>
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? t("The demo could not be built.")}</p>
</div>
);
}
@@ -136,6 +137,7 @@ function DemoFrame({
messageId?: string;
initialInverted?: boolean;
}) {
const { t } = useI18n();
const actions = useContext(ChatActionsContext);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [state, setState] = useState<DemoBridgeState>(INITIAL_DEMO_BRIDGE_STATE);
@@ -177,22 +179,21 @@ function DemoFrame({
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
interactive demo
</span>
{t("interactive demo")}</span>
<span className="truncate text-xs font-medium tracking-wide text-foreground">{title}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Toggle
checked={inverted}
onChange={toggleInverted}
label="Invert demo brightness"
title="Invert colors"
label={t("Invert demo brightness")}
title={t("Invert colors")}
discIcon={<InvertDiscIcon className="size-3" />}
/>
<button
type="button"
aria-label="Download demo as HTML"
title="Download as HTML"
aria-label={t("Download demo as HTML")}
title={t("Download as HTML")}
onClick={() => downloadFile(`${slugFilename(title)}.html`, html, "text/html")}
data-testid="demo-download"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
@@ -205,7 +206,7 @@ function DemoFrame({
<DemoErrorChip
title={title}
message={state.error}
onSend={actions ? () => actions.sendText(`The demo "${title}" threw an error: ${state.error}`) : undefined}
onSend={actions ? () => actions.sendText(t("The demo \"{v0}\" threw an error: {v1}", { v0: title, v1: state.error })) : undefined}
/>
)}
<iframe
@@ -245,12 +246,13 @@ export function DemoErrorChip({
message: string;
onSend?: () => void;
}) {
const { t } = useI18n();
return (
<div
className="mt-2 flex flex-wrap items-center gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-500"
data-testid="demo-error-chip"
>
<span className="font-medium">This demo threw an error</span>
<span className="font-medium">{t("This demo threw an error")}</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={message}>
{message}
</span>
@@ -261,8 +263,7 @@ export function DemoErrorChip({
className="rounded-md border border-red-500/40 px-2 py-1 text-xs font-medium hover:bg-red-500/15"
data-testid="demo-error-send"
>
Send to teacher
</button>
{t("Send to teacher")}</button>
)}
</div>
);
+16 -12
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useContext } from "react";
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
@@ -27,12 +29,13 @@ const KIND_LABEL: Record<JobKind, string> = {
};
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
const { t } = useI18n();
const jobs = useContext(JobsContext);
const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null;
const label = KIND_LABEL[kind];
if (!job || job.status === "running") {
return <DispatchActivityCard job={job} label={label} />;
return <DispatchActivityCard job={job} label={t(label)} />;
}
// BYOK (owner, 2026-09-22): a job refused for missing models is INFO,
// not scary-error — gray glyph + "needs model setup" + the deep-link
@@ -41,10 +44,10 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
// whose key is gone reads as the key being needed — the deep-link
// lands on the SetupDialog's first-key stage on its own.
if (isModelsRefusal(job.error)) {
return <DispatchRefusedCard label={label} line="needs model setup" button="Set up models" />;
return <DispatchRefusedCard label={t(label)} line={t("needs model setup")} button={t("Set up models")} />;
}
if (isProviderKeyRefusal(job.error)) {
return <DispatchRefusedCard label={label} line="needs a provider key" button="Add a key" />;
return <DispatchRefusedCard label={t(label)} line={t("needs a provider key")} button={t("Add a key")} />;
}
const failed = job.status === "error";
return (
@@ -53,14 +56,15 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
data-testid="dispatch-settled"
>
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
<span className="font-medium uppercase tracking-wide">{label}</span>
<span>{failed ? "failed" : "delivered"}</span>
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
<span>{failed ? t("failed") : t("delivered")}</span>
</div>
);
}
/** The refused card: quiet info tone — nothing red, nothing "failed". */
function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) {
const { t } = useI18n();
const ctx = useContext(ChatActionsContext);
return (
<div
@@ -68,7 +72,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
data-testid="dispatch-refused"
>
<SettingsIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">{label}</span>
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
<span>{line}</span>
{ctx && (
<button
@@ -85,6 +89,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
}
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
const { t } = useI18n();
// Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
// so no SSR/hydration mismatch.
const elapsed =
@@ -99,19 +104,18 @@ function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: st
the Braille spinner replaces the pulsing dot — one indicator
everywhere. This is the ONLY dispatch-card change; the card's
shape is untouched. */}
<BrailleSpinner label={`${label} running`} />
{label}
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
<BrailleSpinner label={t("{v0} running", { v0: label })} />
{t(label)}
{elapsed !== null && <span className="font-normal">· {t("{v0}s", { v0: elapsed })}</span>}
</div>
{job?.activity && (
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
{job.activity}
{t(job.activity)}
</div>
)}
{!job && (
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
Dispatched…
</div>
{t("Dispatched…")}</div>
)}
</div>
);
+1 -1
View File
@@ -13,7 +13,7 @@ export function slugFilename(title: string): string {
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return slug || "demo";
+13 -13
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { DispatchCard } from "@/components/artifacts/dispatch-card";
import { MessageResponse } from "@/components/ai-elements/message";
@@ -35,15 +37,15 @@ export function PdfJobCard({ part }: ArtifactPartProps) {
* the same expand-on-demand idiom as research.
*/
export function PdfBriefCard({ output }: { output: unknown }) {
const { t } = useI18n();
const result = output as PdfResult | undefined;
if (!result || typeof result.brief !== "string") {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
PDF reading completed, but the result could not be displayed.
</div>
{t("PDF reading completed, but the result could not be displayed.")}</div>
);
}
const failed = result.brief.startsWith("PDF failed");
const failed = result.brief.startsWith(t("PDF failed"));
// Read = extract/render output entered the loop; consulted = every page
// incl. search hits (the full audit set — the brief's citations may point
// there). The ?? fallbacks tolerate pre-split persisted records.
@@ -52,20 +54,19 @@ export function PdfBriefCard({ output }: { output: unknown }) {
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="pdf-brief-card">
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
PDF brief
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
{t("PDF brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
</div>
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
<MessageResponse>{result.brief}</MessageResponse>
</div>
{!failed && readList.length > 0 && (
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
Pages read: {readList.join(", ")}
{t("Pages read:")}{" "}{readList.join(", ")}
</div>
)}
{!failed && consultedList.length > readList.length && (
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
Pages consulted: {consultedList.join(", ")}
{t("Pages consulted:")}{" "}{consultedList.join(", ")}
</div>
)}
</div>
@@ -77,27 +78,26 @@ export function PdfBriefCard({ output }: { output: unknown }) {
* the steer card (sub-agent result delivery) renders the same artifact.
*/
export function ResearchBriefCard({ output }: { output: unknown }) {
const { t } = useI18n();
const result = output as ResearchResult | undefined;
if (!result || typeof result.brief !== "string") {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
Research completed, but the result could not be displayed.
</div>
{t("Research completed, but the result could not be displayed.")}</div>
);
}
const failed = result.brief.startsWith("Research failed");
const failed = result.brief.startsWith(t("Research failed"));
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="research-brief-card">
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Research brief
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
{t("Research brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
</div>
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
<MessageResponse>{result.brief}</MessageResponse>
</div>
{result.sources.length > 0 && (
<div className="mt-3 border-t pt-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Sources</div>
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Sources")}</div>
<ul className="flex flex-col gap-0.5 text-xs">
{result.sources.map((s) => (
<li key={s.url} className="truncate">
+5 -2
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { ChevronRight } from "lucide-react";
import { BrailleSpinner } from "@/components/braille-spinner";
@@ -28,6 +30,7 @@ interface StreamingToolPart {
}
export function LiveToolCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
const p = part as StreamingToolPart;
const tool = p.type.replace(/^tool-/, "");
const inp = (p.input ?? {}) as Record<string, unknown>;
@@ -47,7 +50,7 @@ export function LiveToolCard({ part }: ArtifactPartProps) {
className="group my-1 w-full max-w-2xl select-none"
>
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs text-muted-foreground [&::-webkit-details-marker]:hidden">
<BrailleSpinner label={`${toolActivityLabel(tool)} — live`} />
<BrailleSpinner label={t("{v0} — live", { v0: toolActivityLabel(tool) })} />
<Shimmer duration={1} className="text-xs">
{toolActivityLabel(tool)}
</Shimmer>
@@ -55,7 +58,7 @@ export function LiveToolCard({ part }: ArtifactPartProps) {
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
</summary>
<div className="mt-2 rounded-xl border bg-card p-3 text-xs">
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">Input (streaming)</div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">{t("Input (streaming)")}</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
{hasInput ? JSON.stringify(p.input, null, 2) : "…"}
</pre>
+7 -6
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { ClipboardCheckIcon } from "lucide-react";
import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
@@ -22,6 +24,7 @@ import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
* the UI wave; this card just guarantees the data is never invisible.
*/
export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) {
const { t } = useI18n();
const output = part.output as ProbeDoneResult | undefined;
const state = typeof part.state === "string" ? part.state : "";
@@ -43,14 +46,12 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn
<div data-testid="probe-done-card" className="my-3 rounded-xl border bg-card/60 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<ClipboardCheckIcon className="size-3.5 text-primary" />
Probe concluded — your assessment
</div>
{t("Probe concluded — your assessment")}</div>
{output && !output.ok ? (
<p className="text-sm text-muted-foreground">The prober retried: {output.error}</p>
<p className="text-sm text-muted-foreground">{t("The prober retried:")}{" "}{output.error}</p>
) : state === "output-error" ? (
<p className="text-sm text-muted-foreground">
The assessment was malformed — the prober retried with a corrected one.
</p>
{t("The assessment was malformed — the prober retried with a corrected one.")}</p>
) : strands ? (
<div className="space-y-2">
{strands.map((s, i) => (
@@ -64,7 +65,7 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Writing the assessment…</p>
<p className="text-sm text-muted-foreground">{t("Writing the assessment…")}</p>
)}
</div>
);
+17 -12
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { CheckIcon, CircleX } from "lucide-react";
import type { PdfResult, ResearchResult, SubagentArtifactData } from "@/src/core";
@@ -26,6 +28,7 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
* render; only a failure note does.
*/
export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
const { t } = useI18n();
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
const data = artifactPart?.data as SubagentArtifactData | undefined;
// The abort steer (2026-09 dock semantics): its message-type part is
@@ -38,8 +41,7 @@ export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
if (aborted?.data) return <AbortedBadge kind={aborted.data.kind ?? "job"} />;
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
A sub-agent finished, but its result could not be displayed.
</div>
{t("A sub-agent finished, but its result could not be displayed.")}</div>
);
}
switch (data.kind) {
@@ -66,11 +68,12 @@ export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
* teacher's wake turn acknowledges the abort in the lesson itself,
* this just marks what happened. */
function AbortedBadge({ kind }: { kind: string }) {
const { t } = useI18n();
return (
<div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground">
<CircleX className="size-3.5" />
<span className="font-medium uppercase tracking-wide">{kind}</span>
<span>aborted by the learner</span>
<span>{t("aborted by the learner")}</span>
</div>
);
}
@@ -80,6 +83,7 @@ function AbortedBadge({ kind }: { kind: string }) {
* the badge marks delivery, the collapsed details carries the full text
* + consulted pages. */
function PdfCompleteBadge({ result }: { result: PdfResult }) {
const { t } = useI18n();
// The honest count is READ pages (extract/render — sources v1 split);
// the ?? is runtime tolerance for pre-split persisted steers.
const readPages = result.pagesRead ?? result.pagesConsulted;
@@ -88,14 +92,14 @@ function PdfCompleteBadge({ result }: { result: PdfResult }) {
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
<CheckIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">PDF</span>
<span>reading complete</span>
<span>{t("reading complete")}</span>
{readPages.length > 0 && (
<span>
· read {readPages.length} page{readPages.length === 1 ? "" : "s"}
{t("· read {count} pages", { count: readPages.length })}
</span>
)}
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">{t("view brief")}</span>
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
</summary>
<div className="mt-2">
<PdfBriefCard output={result} />
@@ -110,15 +114,16 @@ function PdfCompleteBadge({ result }: { result: PdfResult }) {
* affordance): full markdown + sources, one click away, no extra fetch —
* the artifact part in the record is the single source of truth. */
function ResearchCompleteBadge({ result }: { result: ResearchResult }) {
const { t } = useI18n();
return (
<details data-testid="research-complete-badge" className="group w-full text-xs text-muted-foreground">
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
<CheckIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">Research</span>
<span>complete</span>
{result.sources.length > 0 && <span>· {result.sources.length} sources</span>}
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
<span className="font-medium uppercase tracking-wide">{t("Research")}</span>
<span>{t("complete")}</span>
{result.sources.length > 0 && <span>· {t("{count} sources", { count: result.sources.length })}</span>}
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">{t("view brief")}</span>
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
</summary>
<div className="mt-2">
<ResearchBriefCard output={result} />
+8 -8
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { ChevronRight } from "lucide-react";
import type { ArtifactPartProps } from "@/src/core";
@@ -62,6 +64,7 @@ function outputText(output: unknown): string | null {
}
export function ToolCallCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
const p = part as ToolCallPart;
const tool = p.type.replace(/^tool-/, "");
const arg = headlineArg(tool, p.input);
@@ -84,8 +87,7 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
{p.input != null && (
<div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Input
</div>
{t("Input")}</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
{JSON.stringify(p.input, null, 2)}
</pre>
@@ -94,24 +96,22 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
{typeof p.errorText === "string" ? (
<div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Error
</div>
{t("Error")}</div>
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500">
{p.errorText}
{t(p.errorText)}
</pre>
</div>
) : out != null ? (
<div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Output
</div>
{t("Output")}</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
{out}
</pre>
</div>
) : (
p.state !== "output-available" && (
<div className="text-muted-foreground italic">running…</div>
<div className="text-muted-foreground italic">{t("running…")}</div>
)
)}
</div>
+13 -12
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useContext, useState } from "react";
import { ChatActionsContext } from "@/components/chat";
@@ -61,6 +63,7 @@ export function VisualOutputCard({
messageId?: string;
initialInverted?: boolean;
}) {
const { t } = useI18n();
const result = output as VisualResult | undefined;
const overlay = useSessionMermaid();
const actions = useContext(ChatActionsContext);
@@ -83,8 +86,7 @@ export function VisualOutputCard({
if (!result || typeof result.svg !== "string") {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
Figure completed, but the result could not be displayed.
</div>
{t("Figure completed, but the result could not be displayed.")}</div>
);
}
const failed = result.svg.length === 0;
@@ -92,10 +94,9 @@ export function VisualOutputCard({
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card-failed">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Figure
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
{t("Figure")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? "The figure could not be drawn."}</p>
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? t("The figure could not be drawn.")}</p>
</div>
);
}
@@ -130,21 +131,21 @@ export function VisualOutputCard({
data-testid="visual-svg"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Figure")}</div>
<div className="flex items-center gap-1.5">
{/* The invert affordance (owner round, 2026-10-07): the disc IS
the half-filled contrast circle; the tooltip names it. */}
<Toggle
checked={inverted}
onChange={toggleInverted}
label="Invert figure brightness"
title="Invert colors"
label={t("Invert figure brightness")}
title={t("Invert colors")}
discIcon={<InvertDiscIcon className="size-3" />}
/>
<button
type="button"
aria-label="Download figure as SVG"
title="Download as SVG"
aria-label={t("Download figure as SVG")}
title={t("Download as SVG")}
onClick={() => downloadFile("figure.svg", result.svg, "image/svg+xml")}
data-testid="visual-download"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
@@ -154,8 +155,8 @@ export function VisualOutputCard({
{overlay && (
<button
type="button"
aria-label="Open figure fullscreen"
title="Open fullscreen"
aria-label={t("Open figure fullscreen")}
title={t("Open fullscreen")}
onClick={() => overlay.openFigure(result.svg, inverted)}
data-testid="visual-open"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"