Zum Inhalt springen
UI Kit
Accent

KI-Übersetzung

Mit KI übersetzen und prüfen. Original und Übersetzung nebeneinander mit Spracherkennung und Tauschen, andere Formulierungen je Satz mit markierten Unsicherheiten, ein Glossar fester Begriffe mit der Anrede und ganze Dokumente in mehrere Sprachen. Jede Ansicht sagt, dass eine Maschine übersetzt hat.

4 VariantsEine von 5 Components in KI-Schreiben und Erstellen

Schnell übersetzen, gezielt prüfen

Maschinelle Übersetzung geht schnell, die Arbeit liegt im Prüfen. Diese vier Screens machen das Prüfen leicht, sie markieren unsichere Sätze, halten feste Begriffe jedes Mal gleich und sagen in jeder Ansicht, dass eine Maschine übersetzt hat.

„Nebeneinander“ hat zwei Flächen mit den Sprachen darüber und Tauschen in der Mitte, die Ausgangssprache wird mit ihrer Sicherheit erkannt, Zeichen werden gezählt und die Übersetzung lässt sich mit einem Klick kopieren. „Formulierungen je Satz“ stellt Original und Übersetzung Satz für Satz nebeneinander, Zeigen hebt das Gegenstück hervor, und jeder Satz öffnet andere Formulierungen mit Hinweis, unsichere bleiben markiert, bis du wählst. „Glossar und Anrede“ führt links feste Begriffe, die immer gleich oder gar nicht übersetzt werden, markiert jeden angewendeten Begriff und wechselt zwischen Sie und Du. „Dokumente“ übersetzt ganze Dateien in mehrere Sprachen auf einmal, mit Seiten, Wörtern und Fortschritt je Datei und gezählten Stellen zum Prüfen.

Leg zuerst das Glossar an, Produktnamen und Schlüsselbegriffe, dann beginnt jede Übersetzung einheitlich. Entscheide die Anrede einmal je Sprache und bleib dabei. Lass einen Menschen die markierten Stellen prüfen, bevor etwas live geht.

Variants

NebeneinanderNeu in 1.2

Zwei Flächen mit den Sprachen darüber und Tauschen in der Mitte. Die Ausgangssprache wird mit ihrer Sicherheit erkannt, Zeichen werden gezählt, die Übersetzung blendet ein und lässt sich mit einem Klick kopieren.

Formulierungen je SatzNeu in 1.2

Original und Übersetzung Satz für Satz nebeneinander, Zeigen hebt das Gegenstück hervor. Jeder Satz öffnet andere Formulierungen mit Hinweis, unsichere sind zum Prüfen markiert, bis du wählst.

Glossar und AnredeNeu in 1.2

Links feste Begriffe, die immer gleich übersetzt oder gar nicht übersetzt werden, neue direkt dazu. Die Übersetzung markiert jeden angewendeten Begriff und wechselt zwischen Sie und Du.

DokumenteNeu in 1.2

Ganze Dateien in mehrere Sprachen auf einmal. Jede Datei zeigt Seiten, Wörter und Fortschritt, die nächste startet von selbst, Stellen zum Prüfen werden gezählt und Downloads nennen die Sprachen.

Details

Was KI-Übersetzung mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/ai-translation.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 60 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle AiTranslationProps aus components/ui/ai-translation.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AiTranslationVariant

    side-by-sidesentence-alternativesglossarydocument

  • titleoptional
    string
  • descriptionoptional
    string
  • languagesoptional
    TranslationLanguage[]
  • fromoptional
    string

    Source language code, "auto" detects it

  • tooptional
    string
  • detectedoptional
    { code: string; confidence: number }

    The detected source language with the share it is sure of, in percent

  • sourceoptional
    string

    side-by-side, glossary: source text

  • targetoptional
    string

    side-by-side, glossary: translated text

  • targetInformaloptional
    string

    glossary: the text in the informal form

  • sentencesoptional
    TranslationSentence[]

    sentence-alternatives: the text sentence by sentence

  • glossaryoptional
    GlossaryTerm[]
  • filesoptional
    TranslationFile[]

    document: files and target languages

  • targetsoptional
    string[]
  • maxCharsoptional
    number

    Most characters per text

  • modeloptional
    string

    e.g. "Nova Translate"

  • noteoptional
    string
  • onTranslateoptional
    (text: string, from: string, to: string) => string | Promise<string>

    Connect a real translation. Return the text, or a promise of it. Without it the block shows the target text it was given.

Eingebaute Texte

Diese Texte schreibt KI-Übersetzung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 60 Texte anzeigen
fromEnglisch Translate fromDeutsch Übersetzen aus
toEnglisch Translate intoDeutsch Übersetzen in
detectEnglisch DetectDeutsch Erkennen
detectedEnglisch Detected: {lang}Deutsch Erkannt: {lang}
detectedSureEnglisch {v} sureDeutsch {v} sicher
swapEnglisch Swap languagesDeutsch Sprachen tauschen
sourceEnglisch Text to translateDeutsch Text zum Übersetzen
targetEnglisch TranslationDeutsch Übersetzung
placeholderEnglisch Type or paste textDeutsch Text eingeben oder einfügen
charsEnglisch {n} of {max} charactersDeutsch {n} von {max} Zeichen
translateEnglisch TranslateDeutsch Übersetzen
translatingEnglisch TranslatingDeutsch Wird übersetzt
copyEnglisch Copy translationDeutsch Übersetzung kopieren
copiedEnglisch CopiedDeutsch Kopiert
clearEnglisch Clear textDeutsch Text leeren
emptyEnglisch The translation appears here.Deutsch Hier erscheint die Übersetzung.
machineEnglisch Translated by AI. Check important texts before you publish them.Deutsch Von KI übersetzt. Prüfe wichtige Texte, bevor du sie veröffentlichst.
sentenceHintEnglisch Choose a sentence for other wordingsDeutsch Wähle einen Satz für andere Formulierungen
alternativesEnglisch Other wordingsDeutsch Andere Formulierungen
originalEnglisch In the sourceDeutsch Im Original
useEnglisch Use this wordingDeutsch Diese Formulierung nehmen
currentEnglisch In useDeutsch Verwendet
checkEnglisch Please checkDeutsch Bitte prüfen
checkNEnglisch {n} sentences to checkDeutsch {n} Sätze zum Prüfen
check1Englisch 1 sentence to checkDeutsch 1 Satz zum Prüfen
glossaryEnglisch GlossaryDeutsch Glossar
glossaryHintEnglisch Terms that are always translated the same way, or not at all.Deutsch Begriffe, die immer gleich oder gar nicht übersetzt werden.
termEnglisch TermDeutsch Begriff
termTargetEnglisch TranslationDeutsch Übersetzung
keepEnglisch Keep as isDeutsch Nicht übersetzen
useRuleEnglisch Always asDeutsch Immer als
addEnglisch Add termDeutsch Begriff hinzufügen
removeEnglisch Remove {term}Deutsch {term} entfernen
appliedEnglisch {n} terms appliedDeutsch {n} Begriffe angewendet
applied1Englisch 1 term appliedDeutsch 1 Begriff angewendet
fromGlossaryEnglisch From the glossaryDeutsch Aus dem Glossar
formEnglisch Form of addressDeutsch Anrede
formalEnglisch FormalDeutsch Sie
informalEnglisch InformalDeutsch Du
documentsEnglisch DocumentsDeutsch Dokumente
targetsEnglisch Target languagesDeutsch Zielsprachen
chooseEnglisch Choose filesDeutsch Dateien wählen
dropEnglisch Word, PDF and slides, layout stays as it isDeutsch Word, PDF und Folien, das Layout bleibt erhalten
pagesEnglisch {n} pagesDeutsch {n} Seiten
page1Englisch 1 pageDeutsch 1 Seite
wordsEnglisch {n} wordsDeutsch {n} Wörter
doneEnglisch DoneDeutsch Fertig
translatingFileEnglisch TranslatingDeutsch Wird übersetzt
queuedEnglisch WaitingDeutsch Wartet
reviewEnglisch Ready to checkDeutsch Bereit zum Prüfen
failedEnglisch FailedDeutsch Fehlgeschlagen
flaggedEnglisch {n} passages to checkDeutsch {n} Stellen zum Prüfen
flagged1Englisch 1 passage to checkDeutsch 1 Stelle zum Prüfen
downloadEnglisch Download {name}Deutsch {name} herunterladen
openEnglisch CheckDeutsch Prüfen
retryEnglisch Try againDeutsch Neu versuchen
cancelEnglisch Cancel {name}Deutsch {name} abbrechen
removeLangEnglisch Remove {lang}Deutsch {lang} entfernen
addLangEnglisch Add languageDeutsch Sprache hinzufügen
totalEnglisch {done} of {total} doneDeutsch {done} von {total} fertig

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • KI-Übersetzung reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-busy, aria-checked, aria-controls, aria-expanded, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, radio und radiogroup.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 857 Zeilen
components/ui/ai-translation.tsx
'use client';

import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type AiTranslationVariant = 'side-by-side' | 'sentence-alternatives' | 'glossary' | 'document';

export interface TranslationLanguage {
  code: string;
  label: string;
}

export interface TranslationAlternative {
  id: string;
  text: string;
  /** What makes it different, e.g. "More formal" */
  note?: string;
}

export interface TranslationSentence {
  id: string;
  source: string;
  target: string;
  alternatives?: TranslationAlternative[];
  /** low: shown as "please check" */
  confidence?: 'high' | 'medium' | 'low';
}

export interface GlossaryTerm {
  id: string;
  source: string;
  /** The fixed translation, leave out with rule "keep" */
  target?: string;
  /** use: always this translation, keep: never translate */
  rule: 'use' | 'keep';
}

export interface TranslationFile {
  id: string;
  name: string;
  pages?: number;
  words?: number;
  status: 'done' | 'translating' | 'queued' | 'review' | 'failed';
  /** 0 to 100 while translating */
  progress?: number;
  /** review: passages to check */
  flagged?: number;
  error?: string;
}

export interface AiTranslationProps {
  variant?: AiTranslationVariant;
  title?: string;
  description?: string;
  languages?: TranslationLanguage[];
  /** Source language code, "auto" detects it */
  from?: string;
  to?: string;
  /** The detected source language with the share it is sure of, in percent */
  detected?: { code: string; confidence: number };
  /** side-by-side, glossary: source text */
  source?: string;
  /** side-by-side, glossary: translated text */
  target?: string;
  /** glossary: the text in the informal form */
  targetInformal?: string;
  /** sentence-alternatives: the text sentence by sentence */
  sentences?: TranslationSentence[];
  glossary?: GlossaryTerm[];
  /** document: files and target languages */
  files?: TranslationFile[];
  targets?: string[];
  /** Most characters per text */
  maxChars?: number;
  /** e.g. "Nova Translate" */
  model?: string;
  note?: string;
  /**
   * Connect a real translation. Return the text, or a promise of it. Without
   * it the block shows the target text it was given.
   */
  onTranslate?: (text: string, from: string, to: string) => string | Promise<string>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    from: 'Translate from',
    to: 'Translate into',
    detect: 'Detect',
    detected: 'Detected: {lang}',
    detectedSure: '{v} sure',
    swap: 'Swap languages',
    source: 'Text to translate',
    target: 'Translation',
    placeholder: 'Type or paste text',
    chars: '{n} of {max} characters',
    translate: 'Translate',
    translating: 'Translating',
    copy: 'Copy translation',
    copied: 'Copied',
    clear: 'Clear text',
    empty: 'The translation appears here.',
    machine: 'Translated by AI. Check important texts before you publish them.',
    sentenceHint: 'Choose a sentence for other wordings',
    alternatives: 'Other wordings',
    original: 'In the source',
    use: 'Use this wording',
    current: 'In use',
    check: 'Please check',
    checkN: '{n} sentences to check',
    check1: '1 sentence to check',
    glossary: 'Glossary',
    glossaryHint: 'Terms that are always translated the same way, or not at all.',
    term: 'Term',
    termTarget: 'Translation',
    keep: 'Keep as is',
    useRule: 'Always as',
    add: 'Add term',
    remove: 'Remove {term}',
    applied: '{n} terms applied',
    applied1: '1 term applied',
    fromGlossary: 'From the glossary',
    form: 'Form of address',
    formal: 'Formal',
    informal: 'Informal',
    documents: 'Documents',
    targets: 'Target languages',
    choose: 'Choose files',
    drop: 'Word, PDF and slides, layout stays as it is',
    pages: '{n} pages',
    page1: '1 page',
    words: '{n} words',
    done: 'Done',
    translatingFile: 'Translating',
    queued: 'Waiting',
    review: 'Ready to check',
    failed: 'Failed',
    flagged: '{n} passages to check',
    flagged1: '1 passage to check',
    download: 'Download {name}',
    open: 'Check',
    retry: 'Try again',
    cancel: 'Cancel {name}',
    removeLang: 'Remove {lang}',
    addLang: 'Add language',
    total: '{done} of {total} done',
  },
  de: {
    from: 'Übersetzen aus',
    to: 'Übersetzen in',
    detect: 'Erkennen',
    detected: 'Erkannt: {lang}',
    detectedSure: '{v} sicher',
    swap: 'Sprachen tauschen',
    source: 'Text zum Übersetzen',
    target: 'Übersetzung',
    placeholder: 'Text eingeben oder einfügen',
    chars: '{n} von {max} Zeichen',
    translate: 'Übersetzen',
    translating: 'Wird übersetzt',
    copy: 'Übersetzung kopieren',
    copied: 'Kopiert',
    clear: 'Text leeren',
    empty: 'Hier erscheint die Übersetzung.',
    machine: 'Von KI übersetzt. Prüfe wichtige Texte, bevor du sie veröffentlichst.',
    sentenceHint: 'Wähle einen Satz für andere Formulierungen',
    alternatives: 'Andere Formulierungen',
    original: 'Im Original',
    use: 'Diese Formulierung nehmen',
    current: 'Verwendet',
    check: 'Bitte prüfen',
    checkN: '{n} Sätze zum Prüfen',
    check1: '1 Satz zum Prüfen',
    glossary: 'Glossar',
    glossaryHint: 'Begriffe, die immer gleich oder gar nicht übersetzt werden.',
    term: 'Begriff',
    termTarget: 'Übersetzung',
    keep: 'Nicht übersetzen',
    useRule: 'Immer als',
    add: 'Begriff hinzufügen',
    remove: '{term} entfernen',
    applied: '{n} Begriffe angewendet',
    applied1: '1 Begriff angewendet',
    fromGlossary: 'Aus dem Glossar',
    form: 'Anrede',
    formal: 'Sie',
    informal: 'Du',
    documents: 'Dokumente',
    targets: 'Zielsprachen',
    choose: 'Dateien wählen',
    drop: 'Word, PDF und Folien, das Layout bleibt erhalten',
    pages: '{n} Seiten',
    page1: '1 Seite',
    words: '{n} Wörter',
    done: 'Fertig',
    translatingFile: 'Wird übersetzt',
    queued: 'Wartet',
    review: 'Bereit zum Prüfen',
    failed: 'Fehlgeschlagen',
    flagged: '{n} Stellen zum Prüfen',
    flagged1: '1 Stelle zum Prüfen',
    download: '{name} herunterladen',
    open: 'Prüfen',
    retry: 'Neu versuchen',
    cancel: '{name} abbrechen',
    removeLang: '{lang} entfernen',
    addLang: 'Sprache hinzufügen',
    total: '{done} von {total} fertig',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AiTranslationProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value);
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes atr-up{from{opacity:0;transform:translateY(6px)}}@keyframes atr-in{from{opacity:0;filter:blur(3px)}}@keyframes atr-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}' +
  '@media (prefers-reduced-motion:no-preference){.atr-up{animation:atr-up .4s var(--ease-kit) var(--atr-d,0ms) both}.atr-in{animation:atr-in .5s var(--ease-kit) var(--atr-d,0ms) both}.atr-shimmer{animation:atr-shimmer 1.6s linear infinite}}';

function Style() {
  return (
    <style href="kit-ai-translation" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const btn = 'inline-flex h-11 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3.5 text-[13px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');
const iconBtn = 'grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-9';

function Stage({ title, description, children, className, wide = true }: { title?: string; description?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <Style />
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[52rem]')}>
          {(title || description) && (
            <Reveal className="mb-6 sm:mb-8">
              {title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
              {description && <p className="mt-2 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

function Machine({ t, model, children }: { t: Texts; model?: string; children?: React.ReactNode }) {
  return (
    <p className="mt-4 flex items-start gap-2 px-1 text-[12.5px] leading-snug text-muted-foreground">
      <Icon name="languages" size={14} className="mt-px shrink-0" />
      <span>
        {children ?? t.machine}
        {model && <span className="text-subtle-foreground"> · {model}</span>}
      </span>
    </p>
  );
}

/** A native select that looks like the kit, with a chevron */
function LangSelect({ id, label, value, onChange, options, auto, t }: { id: string; label: string; value: string; onChange: (v: string) => void; options: TranslationLanguage[]; auto?: boolean; t: Texts }) {
  return (
    <span className="relative inline-flex min-w-0">
      <label htmlFor={id} className="sr-only">
        {label}
      </label>
      <select id={id} value={value} onChange={(e) => onChange(e.target.value)} className="h-11 min-w-0 max-w-full cursor-pointer appearance-none truncate rounded-lg bg-transparent py-0 pl-2.5 pr-8 text-[14px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9">
        {auto && <option value="auto">{t.detect}</option>}
        {options.map((o) => (
          <option key={o.code} value={o.code}>
            {o.label}
          </option>
        ))}
      </select>
      <Icon name="chevron-down" size={15} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
    </span>
  );
}

function useCopy() {
  const [copied, setCopied] = useState(false);
  useEffect(() => {
    if (!copied) return;
    const id = window.setTimeout(() => setCopied(false), 1800);
    return () => window.clearTimeout(id);
  }, [copied]);
  return [copied, (text: string) => (navigator.clipboard?.writeText(text).catch(() => undefined), setCopied(true))] as const;
}

const langLabel = (languages: TranslationLanguage[], code?: string) => languages.find((l) => l.code === code)?.label ?? code ?? '';

/* ------------------------------------------------------------------ */
/* side-by-side                                                        */
/* ------------------------------------------------------------------ */

function SideBySide({ t, title, description, languages = [], from: from0 = 'auto', to: to0 = 'en', detected, source = '', target = '', maxChars = 5000, model, note, onTranslate, className }: P) {
  const locale = useLocale();
  const uid = useId();
  const [from, setFrom] = useState(from0);
  const [to, setTo] = useState(to0);
  const [text, setText] = useSynced(source);
  const [out, setOut] = useSynced(target);
  const [busy, setBusy] = useState(false);
  const [stale, setStale] = useState(false);
  const [spin, setSpin] = useState(0);
  const [copied, copy] = useCopy();
  const fromCode = from === 'auto' ? detected?.code ?? '' : from;
  const run = async () => {
    if (!text.trim()) return;
    setBusy(true);
    const next = onTranslate ? await onTranslate(text, fromCode, to) : text === source ? target : out;
    window.setTimeout(() => (setOut(next), setBusy(false), setStale(false)), prefersReducedMotion() ? 0 : 700);
  };
  const swap = () => {
    setSpin((n) => n + 1);
    setFrom(to);
    setTo(fromCode || to);
    setText(out);
    setOut(text);
  };

  return (
    <Stage title={title} description={description} className={className}>
      <Reveal className={cn(card, 'overflow-hidden')}>
        {/* Language bar with swap in the middle */}
        <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-1 border-b border-border px-2 py-1.5">
          <div className="flex min-w-0 items-center gap-2">
            <LangSelect id={`${uid}-f`} label={t.from} value={from} onChange={(v) => (setFrom(v), setStale(true))} options={languages} auto t={t} />
          </div>
          <button type="button" aria-label={t.swap} title={t.swap} disabled={busy} onClick={swap} className={cn(iconBtn, 'border border-border bg-card')}>
            <Icon name="arrows-left-right" size={16} className="transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `rotate(${spin * 180}deg)` }} />
          </button>
          <div className="flex min-w-0 justify-end md:justify-start md:pl-3">
            <LangSelect id={`${uid}-t`} label={t.to} value={to} onChange={(v) => (setTo(v), setStale(true))} options={languages.filter((l) => l.code !== fromCode)} t={t} />
          </div>
        </div>
        <div className="grid grid-cols-1 md:grid-cols-2">
          <div className="relative flex min-h-[15rem] flex-col border-border transition-shadow focus-within:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] max-md:border-b md:border-r">
            {from === 'auto' && detected && (
              <p className="atr-up mx-4 mt-3 inline-flex w-fit items-center gap-1.5 rounded-md bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] px-2 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                <Icon name="sparkle" size={12} />
                {fill(t.detected, { lang: langLabel(languages, detected.code) })}
                <span className="font-normal text-muted-foreground">· {fill(t.detectedSure, { v: formatPercent(detected.confidence, locale) })}</span>
              </p>
            )}
            <label htmlFor={`${uid}-s`} className="sr-only">
              {t.source}
            </label>
            <textarea
              id={`${uid}-s`}
              value={text}
              maxLength={maxChars}
              onChange={(e) => (setText(e.target.value), setStale(true))}
              placeholder={t.placeholder}
              rows={7}
              className="block w-full flex-1 resize-none bg-transparent px-4 py-3 text-[16px] leading-relaxed text-foreground placeholder:text-subtle-foreground focus-visible:outline-none sm:text-[17px]"
            />
            <div className="flex items-center gap-2 px-2 pb-2">
              <button type="button" aria-label={t.clear} title={t.clear} disabled={!text} onClick={() => (setText(''), setStale(true))} className={iconBtn}>
                <Icon name="x" size={16} />
              </button>
              <span className="min-w-0 flex-1 truncate text-[12px] tabular-nums text-subtle-foreground">{fill(t.chars, { n: text.length.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'), max: maxChars.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US') })}</span>
              <button type="button" disabled={busy || !text.trim()} onClick={run} className={cn(stale ? primaryBtn : quietBtn)}>
                <Icon name={busy ? 'loader' : 'languages'} size={15} className={busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
                {busy ? t.translating : t.translate}
              </button>
            </div>
          </div>
          <div className="relative flex min-h-[15rem] flex-col bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--card))]">
            <p className="sr-only" id={`${uid}-o`}>
              {t.target}
            </p>
            <div aria-labelledby={`${uid}-o`} aria-live="polite" aria-busy={busy} className={cn('flex-1 px-4 py-3 text-[16px] leading-relaxed sm:text-[17px]', from === 'auto' && detected && 'md:pt-[3.25rem]', stale && !busy ? 'text-muted-foreground' : 'text-foreground')}>
              {busy ? (
                <span aria-hidden className="block space-y-2.5 pt-1.5">
                  {[92, 78, 86, 54].map((w, i) => (
                    <span key={i} className="atr-shimmer block h-3.5 rounded-md bg-[linear-gradient(90deg,var(--track),color-mix(in_oklab,var(--primary)_20%,var(--track)),var(--track))] bg-[length:200%_100%]" style={{ width: `${w}%` }} />
                  ))}
                </span>
              ) : out ? (
                <span key={out} className="atr-in block whitespace-pre-wrap">
                  {out}
                </span>
              ) : (
                <span className="text-subtle-foreground">{t.empty}</span>
              )}
            </div>
            <div className="flex items-center justify-end gap-1 px-2 pb-2">
              <button type="button" disabled={!out || busy} onClick={() => copy(out)} className={cn(btn, 'text-muted-foreground hover:bg-accent hover:text-foreground')} aria-live="polite">
                <Icon name={copied ? 'check' : 'copy'} size={15} className={copied ? 'text-success-foreground' : undefined} />
                {copied ? t.copied : t.copy}
              </button>
            </div>
          </div>
        </div>
      </Reveal>
      <Machine t={t} model={model}>
        {note}
      </Machine>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* sentence-alternatives                                               */
/* ------------------------------------------------------------------ */

function SentenceAlternatives({ t, title, description, languages = [], from = 'de', to = 'en', sentences: initial = [], model, note, className }: P) {
  const uid = useId();
  const [sentences, setSentences] = useSynced(initial);
  const [hover, setHover] = useState<string | null>(null);
  const [open, setOpen] = useState<string | null>(initial.find((s) => s.confidence === 'low')?.id ?? null);
  const [picked, setPicked] = useState<Record<string, string>>({});
  const toCheck = sentences.filter((s) => s.confidence === 'low' && !picked[s.id]).length;
  const choose = (s: TranslationSentence, alt: TranslationAlternative) => {
    setSentences((list) => list.map((x) => (x.id === s.id ? { ...x, target: alt.text, alternatives: [{ id: `${s.id}-was`, text: s.target }, ...(x.alternatives ?? []).filter((a) => a.id !== alt.id)] } : x)));
    setPicked((p) => ({ ...p, [s.id]: alt.id }));
  };

  return (
    <Stage title={title} description={description} className={className}>
      <Reveal className={cn(card, 'overflow-hidden')}>
        <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
          <p className="flex items-center gap-2 text-[13px] font-medium text-foreground">
            {langLabel(languages, from)}
            <Icon name="arrow-right" size={14} className="text-muted-foreground" />
            {langLabel(languages, to)}
          </p>
          <p className={cn('text-[12.5px]', toCheck ? 'text-warning-foreground' : 'text-success-foreground')} aria-live="polite">
            {toCheck ? (toCheck === 1 ? t.check1 : fill(t.checkN, { n: toCheck })) : <span className="inline-flex items-center gap-1.5"><Icon name="check-circle" size={14} />{t.current}</span>}
          </p>
        </div>
        <div aria-hidden className="hidden grid-cols-2 border-b border-border text-[12px] font-medium text-muted-foreground md:grid">
          <span className="border-r border-border px-4 py-2">{t.original}</span>
          <span className="px-4 py-2">{t.target}</span>
        </div>
        <Stagger as="ol" step={60}>
          {sentences.map((s) => {
            const on = open === s.id;
            const lit = hover === s.id || on;
            const low = s.confidence === 'low' && !picked[s.id];
            return (
              <li key={s.id} className="grid grid-cols-1 border-b border-border last:border-b-0 md:grid-cols-2" onPointerEnter={() => setHover(s.id)} onPointerLeave={() => setHover(null)}>
                <p className={cn('border-border px-4 pb-1 pt-3 text-[14.5px] leading-relaxed transition-colors md:border-r md:pb-3', lit ? 'bg-[color-mix(in_oklab,var(--primary)_7%,transparent)] text-foreground' : 'text-muted-foreground')}>
                  <span className="sr-only">{t.original}: </span>
                  {s.source}
                </p>
                <div className={cn('px-2 pb-2 pt-0 transition-colors md:pt-2', lit && 'bg-[color-mix(in_oklab,var(--primary)_7%,transparent)]')}>
                  <button
                    type="button"
                    aria-expanded={on}
                    aria-controls={`${uid}-${s.id}`}
                    onClick={() => setOpen(on ? null : s.id)}
                    onFocus={() => setHover(s.id)}
                    onBlur={() => setHover(null)}
                    className="group flex min-h-11 w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)]"
                  >
                    <span className={cn('min-w-0 flex-1 text-[14.5px] leading-relaxed text-foreground', low && 'underline decoration-[color-mix(in_oklab,var(--warning)_85%,transparent)] decoration-dotted decoration-2 underline-offset-[5px]')}>
                      <span key={s.target} className="atr-in">
                        {s.target}
                      </span>
                      {low && <span className="ml-2 inline-flex h-5 items-center rounded-[5px] bg-[color-mix(in_oklab,var(--warning)_16%,transparent)] px-1.5 align-[0.1em] text-[11px] font-medium text-warning-foreground no-underline">{t.check}</span>}
                    </span>
                    {s.alternatives?.length ? <Icon name="chevron-down" size={15} className={cn('mt-1 shrink-0 text-subtle-foreground transition-transform duration-300 motion-reduce:transition-none', on && 'rotate-180 text-foreground')} /> : null}
                  </button>
                  {on && s.alternatives?.length ? (
                    <div id={`${uid}-${s.id}`} role="group" aria-label={t.alternatives} className="atr-up mt-1 space-y-1.5 px-2 pb-1">
                      <p className="text-[12px] font-medium text-muted-foreground">{t.alternatives}</p>
                      {s.alternatives.map((a) => (
                        <button key={a.id} type="button" onClick={() => choose(s, a)} className="flex w-full items-start gap-2.5 rounded-lg border border-border bg-card px-3 py-2.5 text-left transition-colors hover:border-border-strong hover:bg-accent">
                          <Icon name="repeat" size={14} className="mt-1 shrink-0 text-muted-foreground" />
                          <span className="min-w-0 flex-1">
                            <span className="block text-[14px] leading-relaxed text-foreground">{a.text}</span>
                            {a.note && <span className="mt-0.5 block text-[12px] text-muted-foreground">{a.note}</span>}
                          </span>
                          <span className="sr-only">{t.use}</span>
                        </button>
                      ))}
                    </div>
                  ) : null}
                </div>
              </li>
            );
          })}
        </Stagger>
      </Reveal>
      <Machine t={t} model={model}>
        {note}
      </Machine>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* glossary                                                            */
/* ------------------------------------------------------------------ */

const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

/** The text with every glossary term in it marked */
function WithTerms({ text, terms, t }: { text: string; terms: GlossaryTerm[]; t: Texts }) {
  const words = terms.map((g) => (g.rule === 'keep' ? g.source : g.target ?? '')).filter(Boolean);
  if (!words.length) return <>{text}</>;
  const re = new RegExp(`(${words.sort((a, b) => b.length - a.length).map(escape).join('|')})`, 'g');
  return (
    <>
      {text.split(re).map((part, i) =>
        words.includes(part) ? (
          <mark key={i} title={t.fromGlossary} className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-foreground shadow-[inset_0_-1.5px_0_color-mix(in_oklab,var(--primary)_60%,transparent)]">
            {part}
          </mark>
        ) : (
          <Fragment key={i}>{part}</Fragment>
        ),
      )}
    </>
  );
}

function Glossary({ t, title, description, languages = [], from = 'de', to = 'en', source = '', target = '', targetInformal, glossary: initial = [], model, note, className }: P) {
  const uid = useId();
  const [terms, setTerms] = useSynced(initial);
  const [form, setForm] = useState<'formal' | 'informal'>('formal');
  const [src, setSrc] = useState('');
  const [dst, setDst] = useState('');
  const [rule, setRule] = useState<'use' | 'keep'>('use');
  const text = form === 'informal' && targetInformal ? targetInformal : target;
  const applied = terms.filter((g) => text.includes(g.rule === 'keep' ? g.source : g.target ?? '\u0000')).length;
  const add = () => {
    if (!src.trim() || (rule === 'use' && !dst.trim())) return;
    setTerms((list) => [...list, { id: `g-${list.length}-${src}`, source: src.trim(), target: rule === 'use' ? dst.trim() : undefined, rule }]);
    setSrc('');
    setDst('');
  };
  const forms = [
    { id: 'formal' as const, label: t.formal },
    { id: 'informal' as const, label: t.informal },
  ];

  return (
    <Stage title={title} description={description} className={className}>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start lg:gap-5">
        <Reveal className={cn(card, 'p-4')}>
          <p className="flex items-center gap-1.5 text-[13px] font-medium text-foreground">
            <Icon name="book" size={15} />
            {t.glossary}
          </p>
          <p className="mt-1 text-[12.5px] leading-snug text-muted-foreground">{t.glossaryHint}</p>
          <ul className="mt-3 divide-y divide-border rounded-xl border border-border">
            {terms.map((g) => (
              <li key={g.id} className="atr-up flex items-center gap-2 py-1.5 pl-3 pr-1">
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-[13.5px] font-medium text-foreground">{g.source}</span>
                  <span className="flex items-center gap-1 text-[12px] text-muted-foreground">
                    {g.rule === 'keep' ? (
                      <>
                        <Icon name="lock" size={11} />
                        {t.keep}
                      </>
                    ) : (
                      <>
                        <Icon name="arrow-right" size={11} />
                        <span className="truncate">{g.target}</span>
                      </>
                    )}
                  </span>
                </span>
                <button type="button" aria-label={fill(t.remove, { term: g.source })} onClick={() => setTerms((list) => list.filter((x) => x.id !== g.id))} className={iconBtn}>
                  <Icon name="x" size={14} />
                </button>
              </li>
            ))}
          </ul>
          <form
            className="mt-3 space-y-2"
            onSubmit={(e) => {
              e.preventDefault();
              add();
            }}
          >
            <div className="grid grid-cols-2 gap-2">
              <label className="min-w-0">
                <span className="mb-1 block text-[12px] text-muted-foreground">{t.term}</span>
                <input value={src} onChange={(e) => setSrc(e.target.value)} className="h-11 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-[14px] text-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] sm:h-9" />
              </label>
              <label className="min-w-0">
                <span className="mb-1 block text-[12px] text-muted-foreground">{t.termTarget}</span>
                <input value={rule === 'keep' ? src : dst} disabled={rule === 'keep'} onChange={(e) => setDst(e.target.value)} className="h-11 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-[14px] text-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] disabled:opacity-60 sm:h-9" />
              </label>
            </div>
            <div className="flex flex-wrap items-center gap-2">
              <label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[12.5px] font-medium text-foreground sm:min-h-9">
                <input type="checkbox" checked={rule === 'keep'} onChange={(e) => setRule(e.target.checked ? 'keep' : 'use')} className="size-4 accent-[var(--primary)]" />
                {t.keep}
              </label>
              <span className="flex-1" />
              <button type="submit" disabled={!src.trim() || (rule === 'use' && !dst.trim())} className={quietBtn}>
                <Icon name="plus" size={15} />
                {t.add}
              </button>
            </div>
          </form>
        </Reveal>

        <Reveal delay={100} className={cn(card, 'overflow-hidden')}>
          <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2.5">
            <p className="flex items-center gap-2 text-[13px] font-medium text-foreground">
              {langLabel(languages, from)}
              <Icon name="arrow-right" size={14} className="text-muted-foreground" />
              {langLabel(languages, to)}
            </p>
            {targetInformal && (
              <div role="radiogroup" aria-label={t.form} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1">
                {forms.map((f) => (
                  <button key={f.id} type="button" role="radio" data-f={f.id} aria-checked={form === f.id} tabIndex={form === f.id ? 0 : -1} onClick={() => setForm(f.id)} onKeyDown={(e) => ['ArrowLeft', 'ArrowRight'].includes(e.key) && (e.preventDefault(), setForm(f.id === 'formal' ? 'informal' : 'formal'), (e.currentTarget.parentElement?.querySelector(`[data-f="${f.id === 'formal' ? 'informal' : 'formal'}"]`) as HTMLElement | null)?.focus())} className={cn('inline-flex h-11 min-w-11 items-center justify-center rounded-md px-3 text-[12.5px] font-medium transition-colors sm:h-8', form === f.id ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
                    {f.label}
                  </button>
                ))}
              </div>
            )}
          </div>
          <div className="grid grid-cols-1 md:grid-cols-2">
            <div className="border-border px-4 py-4 max-md:border-b md:border-r">
              <p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.original}</p>
              <p className="whitespace-pre-wrap text-[14.5px] leading-relaxed text-muted-foreground">{source}</p>
            </div>
            <div className="px-4 py-4">
              <p className="mb-2 text-[12px] font-medium text-muted-foreground" id={`${uid}-o`}>
                {t.target}
              </p>
              <p key={`${form}-${terms.length}`} aria-labelledby={`${uid}-o`} className="atr-in whitespace-pre-wrap text-[14.5px] leading-relaxed text-foreground">
                <WithTerms text={text} terms={terms} t={t} />
              </p>
            </div>
          </div>
          <p className="flex items-center gap-1.5 border-t border-border px-4 py-2.5 text-[12.5px] text-muted-foreground" aria-live="polite">
            <span aria-hidden className="size-2.5 rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_35%,transparent)]" />
            {applied === 1 ? t.applied1 : fill(t.applied, { n: applied })}
          </p>
        </Reveal>
      </div>
      <Machine t={t} model={model}>
        {note}
      </Machine>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* document                                                            */
/* ------------------------------------------------------------------ */

const FILE_TONE: Record<TranslationFile['status'], { text: string; dot: string }> = {
  done: { text: 'text-success-foreground', dot: 'bg-success' },
  translating: { text: 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', dot: 'bg-primary' },
  queued: { text: 'text-muted-foreground', dot: 'bg-muted-foreground' },
  review: { text: 'text-warning-foreground', dot: 'bg-warning' },
  failed: { text: 'text-destructive', dot: 'bg-destructive' },
};

function DocumentTranslation({ t, title, description, languages = [], files: initial = [], targets: targets0 = [], model, note, className }: P) {
  const locale = useLocale();
  const uid = useId();
  const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US');
  const [files, setFiles] = useSynced(initial);
  const [targets, setTargets] = useState(targets0);
  const [ref, inView] = useInView<HTMLDivElement>();
  const input = useRef<HTMLInputElement>(null);
  const running = files.some((f) => f.status === 'translating');
  const queued = files.some((f) => f.status === 'queued');
  useEffect(() => {
    if (!inView || prefersReducedMotion() || (!running && !queued)) return;
    const id = window.setInterval(() => {
      setFiles((cur) => {
        let next = cur.map((f) => (f.status === 'translating' ? { ...f, progress: Math.min(100, (f.progress ?? 0) + 1) } : f));
        const done = next.findIndex((f) => f.status === 'translating' && (f.progress ?? 0) >= 100);
        if (done >= 0) next = next.map((f, i) => (i === done ? { ...f, status: f.flagged ? ('review' as const) : ('done' as const), progress: undefined } : f));
        if (!next.some((f) => f.status === 'translating')) {
          const q = next.findIndex((f) => f.status === 'queued');
          if (q >= 0) next = next.map((f, i) => (i === q ? { ...f, status: 'translating' as const, progress: 0 } : f));
        }
        return next;
      });
    }, 140);
    return () => window.clearInterval(id);
  }, [inView, running, queued, setFiles]);
  const statusLabel = (f: TranslationFile) => (f.status === 'translating' ? t.translatingFile : t[f.status]);
  const finished = files.filter((f) => f.status === 'done' || f.status === 'review').length;
  const free = languages.filter((l) => !targets.includes(l.code));

  return (
    <Stage title={title} description={description} className={className} wide={false}>
      <div ref={ref}>
        <Reveal className={cn(card, 'p-4 sm:p-5')}>
          <div className="flex flex-wrap items-center justify-between gap-3">
            <p className="text-[13px] font-medium text-foreground">{t.targets}</p>
            <p className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
              {fill(t.total, { done: finished, total: files.length })}
            </p>
          </div>
          <ul className="mt-2.5 flex flex-wrap items-center gap-1.5">
            {targets.map((code) => (
              <li key={code} className="atr-up inline-flex h-11 items-center gap-1 rounded-lg border border-border bg-surface pl-3 pr-1 text-[13px] font-medium text-foreground sm:h-9">
                {langLabel(languages, code)}
                <button type="button" aria-label={fill(t.removeLang, { lang: langLabel(languages, code) })} disabled={targets.length < 2} onClick={() => setTargets((x) => x.filter((c) => c !== code))} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-7">
                  <Icon name="x" size={13} />
                </button>
              </li>
            ))}
            {free.length > 0 && (
              <li className="relative">
                <label htmlFor={`${uid}-add`} className="sr-only">
                  {t.addLang}
                </label>
                <select id={`${uid}-add`} value="" onChange={(e) => e.target.value && setTargets((x) => [...x, e.target.value])} className="h-11 cursor-pointer appearance-none rounded-lg border border-dashed border-border-strong bg-transparent pl-8 pr-3 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-9">
                  <option value="">{t.addLang}</option>
                  {free.map((l) => (
                    <option key={l.code} value={l.code}>
                      {l.label}
                    </option>
                  ))}
                </select>
                <Icon name="plus" size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
              </li>
            )}
          </ul>

          <div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-dashed border-border-strong p-3">
            <span aria-hidden className="grid size-10 place-items-center rounded-xl bg-surface-2 text-muted-foreground">
              <Icon name="cloud-upload" size={18} />
            </span>
            <p className="min-w-0 flex-1 text-[13px] leading-snug text-muted-foreground">{t.drop}</p>
            <input ref={input} type="file" multiple accept=".doc,.docx,.pdf,.ppt,.pptx,.odt" className="sr-only" tabIndex={-1} onChange={(e) => {
              const list = Array.from(e.target.files ?? []);
              setFiles((cur) => [...cur, ...list.map((f, i) => ({ id: `new-${cur.length + i}`, name: f.name, status: 'queued' as const, words: Math.round(f.size / 7) }))]);
              e.target.value = '';
            }} />
            <button type="button" onClick={() => input.current?.click()} className={cn(quietBtn, 'max-sm:w-full')}>
              <Icon name="paperclip" size={15} />
              {t.choose}
            </button>
          </div>

          <ul aria-label={t.documents} className="mt-4 divide-y divide-border">
            {files.map((f) => {
              const tone = FILE_TONE[f.status];
              return (
                <li key={f.id} className={cn('flex items-center gap-x-3 gap-y-2 py-3 sm:flex-nowrap', f.status === 'translating' || f.status === 'queued' ? 'flex-nowrap' : 'flex-wrap')}>
                  <span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                    <Icon name={(f.name.endsWith('.pptx') ? 'presentation' : 'file') as IconName} size={17} />
                  </span>
                  <div className="min-w-0 flex-1">
                    <p className="truncate text-[13.5px] font-medium text-foreground">{f.name}</p>
                    <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[12px] text-muted-foreground">
                      <span className={cn('inline-flex items-center gap-1.5 font-medium', tone.text)}>
                        <span aria-hidden className={cn('size-1.5 rounded-full', tone.dot, f.status === 'translating' && 'animate-kit-pulse motion-reduce:animate-none')} />
                        {statusLabel(f)}
                        {f.status === 'translating' && <span className="tabular-nums text-muted-foreground">{formatPercent(Math.round(f.progress ?? 0), locale)}</span>}
                      </span>
                      {f.pages ? <span className="tabular-nums">· {f.pages === 1 ? t.page1 : fill(t.pages, { n: f.pages })}</span> : null}
                      {f.words ? <span className="tabular-nums max-sm:hidden">· {fill(t.words, { n: nf.format(f.words) })}</span> : null}
                    </p>
                    {f.status === 'translating' && (
                      <span aria-hidden className="mt-2 block h-1 overflow-hidden rounded-full bg-track">
                        <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-200" style={{ transform: `scaleX(${(f.progress ?? 0) / 100})` }} />
                      </span>
                    )}
                    {f.status === 'review' && f.flagged ? <p className="mt-1 text-[12px] text-warning-foreground">{f.flagged === 1 ? t.flagged1 : fill(t.flagged, { n: f.flagged })}</p> : null}
                    {f.status === 'failed' && f.error ? <p className="mt-1 text-[12px] leading-snug text-destructive">{f.error}</p> : null}
                  </div>
                  <div className={cn('flex shrink-0 items-center gap-1.5', f.status !== 'translating' && f.status !== 'queued' && 'max-sm:w-full max-sm:pl-[3.25rem]')}>
                    {f.status === 'review' && (
                      <button type="button" className={cn(quietBtn, 'max-sm:flex-1')}>
                        <Icon name="eye" size={15} />
                        {t.open}
                      </button>
                    )}
                    {(f.status === 'done' || f.status === 'review') && (
                      <button type="button" aria-label={fill(t.download, { name: f.name })} className={cn(f.status === 'done' ? primaryBtn : quietBtn, 'max-sm:flex-1')}>
                        <Icon name="download" size={15} />
                        <span className="tabular-nums">{targets.map((c) => c.toUpperCase()).join(' · ')}</span>
                      </button>
                    )}
                    {f.status === 'failed' && (
                      <button type="button" onClick={() => setFiles((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: 'queued', error: undefined } : x)))} className={cn(quietBtn, 'max-sm:flex-1')}>
                        <Icon name="refresh" size={15} />
                        {t.retry}
                      </button>
                    )}
                    {(f.status === 'translating' || f.status === 'queued') && (
                      <button type="button" aria-label={fill(t.cancel, { name: f.name })} onClick={() => setFiles((cur) => cur.filter((x) => x.id !== f.id))} className={iconBtn}>
                        <Icon name="x" size={15} />
                      </button>
                    )}
                  </div>
                </li>
              );
            })}
          </ul>
        </Reveal>
      </div>
      <Machine t={t} model={model}>
        {note}
      </Machine>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * AI translation: source and target side by side with language detection
 * and swap, every sentence with other wordings to choose and the ones to
 * check marked, a glossary of fixed terms with the form of address, and whole
 * documents in several languages with progress and review. Always says that a
 * machine translated and that a person should check.
 */
export function AiTranslation({ variant = 'side-by-side', labels, ...props }: AiTranslationProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  switch (variant) {
    case 'sentence-alternatives':
      return <SentenceAlternatives {...p} />;
    case 'glossary':
      return <Glossary {...p} />;
    case 'document':
      return <DocumentTranslation {...p} />;
    default:
      return <SideBySide {...p} />;
  }
}

Häufige Fragen

Wie installiere ich KI-Übersetzung?

Mit aiw add @ai-website/ai-translation. Der Befehl legt die Datei components/ui/ai-translation.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat KI-Übersetzung?

4 Varianten, Nebeneinander, Formulierungen je Satz, Glossar und Anrede und Dokumente. Du wählst sie mit der Prop variant, zum Beispiel variant="sentence-alternatives".

Kann ich die Texte von KI-Übersetzung übersetzen?

Ja. KI-Übersetzung bringt 60 eigene Texte auf Deutsch und Englisch mit, etwa „Übersetzen aus“, „Übersetzen in“ und „Erkennen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent KI-Übersetzung einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Übersetzung mit aiw_components und installieren sie mit aiw_add @ai-website/ai-translation. Nenn im Prompt die Variante, etwa variant="side-by-side", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert