Skip to content
UI Kit
Accent color

AI translation UI

Translating with AI and checking it. Source and target side by side with detection and swap, other wordings per sentence with the uncertain ones marked, a glossary of fixed terms and the form of address, and whole documents in several languages. Every view says that a machine translated.

4 variantsOne of 5 components in AI writing and creation

Translate fast, check where it counts

Machine translation is fast, the work lies in checking it. These four screens make that check easy, they mark uncertain sentences, keep fixed terms the same every time and say in every view that a machine translated the text.

Side by side has two panes with the languages above and swap in the middle, the source language is detected with how sure it is, characters are counted and the translation copies with one click. Wordings per sentence aligns source and translation sentence by sentence, pointing at one lights up its partner, and every sentence opens other wordings with a note, uncertain ones stay marked until you choose. Glossary and address keeps fixed terms on the left, always translated the same way or kept as they are, marks every term it applied and switches between formal and informal address. Documents translates whole files into several languages at once, with pages, words and progress per file and passages to check counted.

Build the glossary first, product names and key terms, then every translation starts consistent. Pick the form of address once per language and keep it. Let a person review the marked passages before anything goes live.

Variants

Side by sideNew in 1.2

Two panes with the languages above and swap in the middle. The source language is detected with how sure it is, characters are counted, the translation fades in and copies with one click.

Wordings per sentenceNew in 1.2

Source and translation aligned sentence by sentence, pointing at one lights up its partner. Every sentence opens other wordings with a note, uncertain ones are marked to check until you choose.

Glossary and addressNew in 1.2

Fixed terms on the left, always translated the same way or kept as they are, new ones added in place. The translation marks every term it applied and switches between formal and informal address.

DocumentsNew in 1.2

Whole files into several languages at once. Each file shows pages, words and progress, the next starts on its own, passages to check are counted and downloads name the languages.

Details

What AI translation brings along, read from the registry and the source.

Area
App
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/ai-translation.tsx
Brings along
utilskiticonrevealmotion
npm packages
None
Languages
English and German, 60 texts of its own
Studio
Adjust in code

Props

The AiTranslationProps interface from components/ui/ai-translation.tsx, with the comments from the code.

  • 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.

Built-in texts

AI translation writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 60 texts
fromEnglish Translate fromGerman Übersetzen aus
toEnglish Translate intoGerman Übersetzen in
detectEnglish DetectGerman Erkennen
detectedEnglish Detected: {lang}German Erkannt: {lang}
detectedSureEnglish {v} sureGerman {v} sicher
swapEnglish Swap languagesGerman Sprachen tauschen
sourceEnglish Text to translateGerman Text zum Übersetzen
targetEnglish TranslationGerman Übersetzung
placeholderEnglish Type or paste textGerman Text eingeben oder einfügen
charsEnglish {n} of {max} charactersGerman {n} von {max} Zeichen
translateEnglish TranslateGerman Übersetzen
translatingEnglish TranslatingGerman Wird übersetzt
copyEnglish Copy translationGerman Übersetzung kopieren
copiedEnglish CopiedGerman Kopiert
clearEnglish Clear textGerman Text leeren
emptyEnglish The translation appears here.German Hier erscheint die Übersetzung.
machineEnglish Translated by AI. Check important texts before you publish them.German Von KI übersetzt. Prüfe wichtige Texte, bevor du sie veröffentlichst.
sentenceHintEnglish Choose a sentence for other wordingsGerman Wähle einen Satz für andere Formulierungen
alternativesEnglish Other wordingsGerman Andere Formulierungen
originalEnglish In the sourceGerman Im Original
useEnglish Use this wordingGerman Diese Formulierung nehmen
currentEnglish In useGerman Verwendet
checkEnglish Please checkGerman Bitte prüfen
checkNEnglish {n} sentences to checkGerman {n} Sätze zum Prüfen
check1English 1 sentence to checkGerman 1 Satz zum Prüfen
glossaryEnglish GlossaryGerman Glossar
glossaryHintEnglish Terms that are always translated the same way, or not at all.German Begriffe, die immer gleich oder gar nicht übersetzt werden.
termEnglish TermGerman Begriff
termTargetEnglish TranslationGerman Übersetzung
keepEnglish Keep as isGerman Nicht übersetzen
useRuleEnglish Always asGerman Immer als
addEnglish Add termGerman Begriff hinzufügen
removeEnglish Remove {term}German {term} entfernen
appliedEnglish {n} terms appliedGerman {n} Begriffe angewendet
applied1English 1 term appliedGerman 1 Begriff angewendet
fromGlossaryEnglish From the glossaryGerman Aus dem Glossar
formEnglish Form of addressGerman Anrede
formalEnglish FormalGerman Sie
informalEnglish InformalGerman Du
documentsEnglish DocumentsGerman Dokumente
targetsEnglish Target languagesGerman Zielsprachen
chooseEnglish Choose filesGerman Dateien wählen
dropEnglish Word, PDF and slides, layout stays as it isGerman Word, PDF und Folien, das Layout bleibt erhalten
pagesEnglish {n} pagesGerman {n} Seiten
page1English 1 pageGerman 1 Seite
wordsEnglish {n} wordsGerman {n} Wörter
doneEnglish DoneGerman Fertig
translatingFileEnglish TranslatingGerman Wird übersetzt
queuedEnglish WaitingGerman Wartet
reviewEnglish Ready to checkGerman Bereit zum Prüfen
failedEnglish FailedGerman Fehlgeschlagen
flaggedEnglish {n} passages to checkGerman {n} Stellen zum Prüfen
flagged1English 1 passage to checkGerman 1 Stelle zum Prüfen
downloadEnglish Download {name}German {name} herunterladen
openEnglish CheckGerman Prüfen
retryEnglish Try againGerman Neu versuchen
cancelEnglish Cancel {name}German {name} abbrechen
removeLangEnglish Remove {lang}German {lang} entfernen
addLangEnglish Add languageGerman Sprache hinzufügen
totalEnglish {done} of {total} doneGerman {done} von {total} fertig

Accessibility

Read from the source of the component.

  • AI translation handles the keys Arrow left, Arrow right and Space itself.
  • Sets aria-busy, aria-checked, aria-controls, aria-expanded, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
  • Uses the roles group, radio and radiogroup.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 857 lines
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} />;
  }
}

Frequent questions

How do I install AI translation?

With aiw add @ai-website/ai-translation. The command puts components/ui/ai-translation.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.

Which variants does AI translation have?

4 variants, Side by side, Wordings per sentence, Glossary and address and Documents. You pick one with the variant prop, for example variant="sentence-alternatives".

Can I translate the texts of AI translation?

Yes. AI translation carries 60 texts of its own in English and German, such as “Translate from”, “Translate into” and “Detect”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with AI translation?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI translation with aiw_components and install it with aiw_add @ai-website/ai-translation. Name the variant in your prompt, for example variant="side-by-side", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard