AI

Translation Panel

A document beside its machine translation, aligned segment by segment — hover or focus a pair and both halves light up, with per-segment translated / waiting / failed states, a swappable language header, a formality control and progress over the whole job.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowLeftRight, CircleAlert, Languages, LoaderCircle, RefreshCcw, TriangleAlert } from "lucide-react"

import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type {
  TranslationDocument,
  TranslationFormality,
  TranslationLanguage,
  TranslationPanelStatus,
  TranslationSegment,
  TranslationSegmentKind,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/translation-panel.json

Prompt

Build a React + TypeScript + Tailwind "TranslationPanel" component with zod and
lucide-react, reusing an existing shadcn Button. No other runtime dependency.

Contract
- A zod schema (`translationDocumentSchema` in a sibling contract file) is the
  single source of truth: { id, title?, sourceLanguage, targetLanguage,
  segments } where a language is { code (BCP-47), label, dir?: "ltr"|"rtl" } and
  a segment is { id, source, target?, state, error?, kind? }.
- The unit is the SEGMENT, not the document: a translation job is a fan-out of
  many small requests, and a panel that only knows "the document" cannot say
  which sentence is still waiting or which one the provider refused.
- Segment state is three values — "translated" | "pending" | "error". A queued
  segment is `pending`; from the reader's side queued and in flight are the same
  wait. `target` is optional precisely because the two interesting states have
  no target yet: never send an empty string to mean "not yet", because an empty
  string is a legitimate result and is rendered as one.
- `kind?: "heading" | "paragraph" | "list-item" | "quote"` is typography only —
  it never touches the state machine or the alignment.
- A separate ENVELOPE status — "loading" | "empty" | "error" | "ready" — is the
  panel's own render state. `status="error"` means the job or its fetch died; a
  segment failing is `state: "error"`, and the two read differently on screen. A
  document whose every segment failed is still `ready`.
- Props: document: Document | null; status; formality? / defaultFormality?
  ("default") / onFormalityChange?(formality); onSwapLanguages?({source,
  target}); onRetrySegment?(segmentId); onRetry?; onActiveSegmentChange?(id |
  null); showOrdinals? (true); formatProgress?(translated, total); emptyState?;
  errorMessage?; label? ("Document translation"); className plus the rest of the
  element props, ref forwarded to the <section>.
- Formality is a REQUEST parameter, not a property of the result: changing it
  re-translates, it does not restyle text you already have. Three values:
  "informal" | "default" | "formal".

Behavior
- THE ROW IS THE ALIGNMENT UNIT. Each segment renders as one grid row holding two
  cells, so both halves share the row's height by construction: no scroll
  syncing, no measured offsets, no second pane that drifts once the target runs
  20 % longer than the source (which is what French does to English). Below the
  container breakpoint the same row collapses to one column and the pair simply
  stacks, source above translation.
- Array order IS reading order. Never sort failures to the top: it helps once
  and destroys the only thing a bilingual reader relies on — that paragraph 12 on
  the left is paragraph 12 on the right.
- A PAIR LIGHTS UP TOGETHER, IN TWO INTENSITIES. Hovering one half tints that
  half strongly and its counterpart weakly on the same accent edge; one uniform
  highlight would only say "this row", never "this is the other half of it".
  Focus lights both halves evenly, because focus is on the row, not a side.
  Pointer-leave is handled ONCE on the row, so travelling from one half to the
  other cannot blink the highlight off.
- Keyboard is a grid with focusable ROWS (role="grid" > role="row" >
  role="gridcell", roving tabindex): one tab stop for the whole document, then
  ArrowUp/ArrowDown between segments and Home/End to the ends; Enter on a failed
  row retries it. Two focusable cells per segment would turn a 58-segment
  document into 116 presses of Tab. Row key handling bails out unless
  event.target === event.currentTarget, otherwise Enter on the Retry button
  inside the row fires the request twice.
- RETRY IS ONE-SHOT PER FAILURE. The lock lives in a ref read and written
  synchronously inside the click handler — several clicks dispatched in one task
  all read the same stale state, so a state-only guard would bill you three
  times for the same segment. The lock is keyed to a GENERATION derived from the
  set of currently-failing ids (plus the document id): when that set changes one
  failure episode ended, the generation ticks and the old locks are dead, so a
  segment that fails a SECOND time is retryable again. No cleanup effect, no ref
  written during render — the generation is compared inside the handler.
- A pending segment draws a placeholder shaped by the SOURCE it is translating —
  line count derived from the source length, last-line width from a deterministic
  hash of the segment id (never Math.random, or the row re-shuffles its own width
  on the first client paint). The row it sits in carries aria-busy and an sr-only
  "Translating…".
- AFFORDANCES ONLY WHEN WIRED. The swap button renders only with
  onSwapLanguages, the formality control only with onFormalityChange, per-segment
  Retry only with onRetrySegment. A control that changes nothing downstream is a
  lie with a hover state. Formality is controlled-or-uncontrolled (prop wins when
  defined, no syncing effect).
- Progress is DERIVED, never stored: counts of translated / pending / failed over
  segments.length feed the "N of M segments translated" line, a bar with two
  slices (landed, failed — a failure that only leaves the bar short reads as slow
  rather than broken) and one polite sr-only live region that is mounted for the
  whole ready branch, so arriving segments are announced from an element the
  screen reader is already watching.
- With failures present, a "Next issue" button moves ROVING FOCUS to the next
  failed row, wrapping around — the fast path through a long document that does
  not require re-sorting it.
- The active segment (hover wins over focus) is reported through
  onActiveSegmentChange from an effect guarded by a ref, so an inline arrow
  function in the consumer cannot turn every re-render into a duplicate call, and
  a parent that scrolls its own editor is never told to move mid-render.
- Identity changes reset the panel (adjust state during render, no effect): a new
  document id clears the hovered pair, the focused row and the tab stop, so a
  recycled panel can never highlight the previous document's segment.
- Edge cases that must be handled explicitly: a translated segment whose target
  is empty says so instead of looking like it is still waiting; `status="ready"`
  with zero segments falls through to the empty branch; a failed segment with no
  message still gets a sentence; unbreakable URLs use wrap-anywhere + min-w-0 so
  they wrap inside the column instead of widening the panel.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-muted tints for the active pair, border-l-primary (and /40, /50) for the
  accent edge that links the two halves, bg-primary + text-primary-foreground for
  the checked formality, bg-destructive/60 for the failed slice of the bar,
  text-destructive for a segment's failure. No hard-coded colours anywhere.
- Every cell carries the 2 px accent border at ALL times, transparent until the
  pair lights up: colouring an existing border instead of adding one is what
  keeps the text from shifting sideways under the pointer.
- Layout is driven by a container query (@container + @xl:grid-cols-2), not a
  viewport breakpoint — the same panel is a side sheet in one app and a
  full-width review screen in another, and only its own width can decide whether
  two columns of prose still fit. Pane headers and rows share ONE grid template,
  so the column rule lines up by construction.
- Each pane sets lang and dir from its own language: a screen reader switches
  voice, the browser picks the right quotes and hyphenation, and an RTL target
  mirrors its text, bullet and accent edge while the two-column layout stays put.
  Logical properties throughout (ms-auto, me-1.5, ps-3, text-start).
- Accessibility: the root is a <section aria-label>; the grid names the language
  pair; ordinals are aria-hidden because aria-rowindex already carries them; the
  progress bar is a real role="progressbar" with aria-valuetext; the retry button
  uses aria-disabled, never the native `disabled` (which blurs the element the
  instant it flips and drops focus to <body> mid-action).
- Motion: the placeholder pulse and every colour transition are
  motion-reduce-guarded; nothing about the panel's function depends on them.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Density: the row padding (px-3 py-2.5) and the prose classes per `kind` are the
  two knobs for a compact CAT-tool look versus a reading-width review screen.
  Drop `showOrdinals` for prose, keep it for anything anyone will cite by number.
- Which sub-blocks exist is decided by which callbacks you pass: no
  onSwapLanguages → no swap; no onFormalityChange → no formality control; no
  onRetrySegment → a read-only bilingual view. The header collapses cleanly with
  none of them.
- Wording: `formatProgress(translated, total)` swaps the unit (words, characters,
  a percentage, "42 / 58"); `label` names the region; `emptyState` replaces the
  empty body; `errorMessage` + `onRetry` own the envelope failure.
- The breakpoint: @xl is the point where two columns of prose stop being
  readable. Raise it to @2xl for long-form, lower it to @lg for short subtitle
  segments.
- Formality axis: the three values are a union in the contract — replace them
  with your provider's ("polite", "casual", "business") and the radiogroup
  follows; delete the control entirely for providers that do not support it.
- Pairing colour: the accent edge and the two tints are the only thing carrying
  "these two belong together". Move them to another token pair if primary is
  already spent on something else, but keep the two intensities.

Concepts

  • Row as the alignment unit — the two panes are one grid, and a segment is one row holding two cells. Both halves therefore share a height by construction: no scroll syncing, no measured offsets, nothing to drift once the translation runs 20 % longer than the original. Below the container breakpoint the same row stacks instead of splitting, which is the only readable bilingual layout on a phone.
  • Counterpart highlight — pointing at one half tints it strongly and its partner weakly on the same accent edge. Two intensities is what turns "this row" into "this is the other half of it"; focus lights both evenly, because focus lands on the pair, not on a side. Leaving is handled once on the row, so crossing from source to translation never blinks the link off.
  • Envelope failure vs segment failure — the job dying and one sentence being rate-limited are different events with different repairs. The first is an alert over the whole panel with one retry; the second stays inside its row, keeps the rest of the document readable, and offers a retry that costs one request.
  • One-shot retry, keyed to a failure generation — the guard is a ref read and written inside the click handler, because several clicks in one task all observe the same stale state. It is scoped to the current set of failing ids: when that set changes, the episode ended and the lock expires by itself, so a segment that fails a second time is retryable while a double click is not.
  • Placeholder shaped by the source — a waiting row draws bars derived from the length of the sentence it is translating and a hash of its id, so it holds roughly the height the answer will need and the document below does not jump when the answer lands. Deterministic, so the server and the browser draw the same thing.
  • Affordances only when wired — swap, formality and retry each appear only when a handler exists. The alternative is a control that looks live, changes state, and asks the provider for nothing — which is worse than the missing feature, because it is discovered later.

On This Page