Skip to content

Docs

Guides

Labels and translations

Every word the components show or announce is a label: translate them all with one provider, or change a few, with a complete Portuguese example.

Every word the components show or announce, the visible text and what screen readers hear alike, comes from one object of labels. Translate it once and pass it to SignoffLabelsProvider, or pass part of it to a component's labels prop. There are 207 of them, 140 fixed strings and 67 functions, in twelve sections: one per component, one for useApprovalPolicy, and two shared ones, format for numbers and common.

Translate everything

Labels hold functions, which cannot go from a Server Component to a Client Component, so render the provider from a module with 'use client', and keep the object stable: at module scope, as here, or memoized.

app/providers.tsx
'use client';

import type { ReactNode } from 'react';
import { SignoffLabelsProvider } from 'signoff-ui';
import { pt } from './labels-pt';

export function Providers({ children }: { children: ReactNode }) {
  return <SignoffLabelsProvider labels={pt}>{children}</SignoffLabelsProvider>;
}

Type the translation as SignoffLabels, as the Portuguese below is: a label it misses, or one a new release adds, is then a type error rather than English on the page. Set lang on the page, or on the element that holds the components when the rest of the page is in another language (<div lang="pt-PT">), so screen readers pronounce the words in the right language.

Change a few words

A partial object is enough: anything it leaves out stays English.

<SignoffLabelsProvider labels={{ diffReview: { title: 'Agent changes' }, approvalCard: { approve: 'Run it' } }}>
  <AgentRun />
</SignoffLabelsProvider>

<ApprovalCard {...props} labels={{ approvalCard: { deny: 'Not now' } }} />

Providers nest, the inner one winning key by key, and a component's own labels win over both, for it and for what it renders: AgentMessage's apply to its timeline, approval cards and markdown too. Props that already took text, such as title, submitLabel, approveLabel, denyLabel and reasonPlaceholder, still win over the labels. For a hook or markup of your own, useSignoffLabels() returns the labels in effect, and mergeLabels(defaultLabels, ...overrides) builds the same merged object anywhere, the server included.

Strings and functions

Fixed text is a string. Text built from values (counts, file paths, line numbers, tool names) is a function, so a translation can get its plurals, its gender agreement and its word order right:

files: (count) => `${count} ${count === 1 ? 'ficheiro' : 'ficheiros'}`,
decided: (kind, n, total, decision, remaining) => ...,

A few return parts, where the components style or hide one of them: moreAfter, moreBefore and showAll return { visible, hidden }, what the button shows and what only screen readers hear after it; commentHeading returns { hidden, visible }; rememberedFor returns { before, after }, the words around the tool's name. format holds how numbers read: durations, the spoken durations of announcements, token counts, cost and percentages. The English ones are fixed and locale-stable; a translation can use Intl.NumberFormat, as the Portuguese does.

What they do not cover

  • Words for the agent. reviewToolOutput's summary, toToolApproval's reasons and the result objects' fields are read by the model, not by a person, and stay English. A deny rule's reason is the exception, as the approval card shows it: it is approvalPolicy.ruleDenial, or the rule's own reason.
  • deriveAgentState's detail while the model writes: pass writingResponse: labels.agentStatus.writingResponse, since the helper runs outside any provider.
  • Keys. J, K, A, R, Y, N and the rest are the same in every language, and so are their aria-keyshortcuts. The hints that name them are labels.
  • Your content. Tool names, titles, descriptions, tool output and what ToolMeta renders are yours. A tool's displayed name defaults to its humanized name (run_command reads "Run command"), so give ToolMeta a label in your language.
  • Right-to-left layouts have not been tested yet.

How it's tested

A test renders every component through the states it has (pending, edited, decided, compared, commented on, streamed, stopped) with each label replaced by a marker and the content in Cyrillic, and fails on any English left on the page, in text, accessible names, titles or placeholders. It also fails on a label no state shows, so the states cover every label. A second test reads the components' source and fails on English written into it: text between tags, a string where a name or a placeholder goes, or one handed to the live region. The Portuguese below is the one the tests render, and its type checks that it is complete.

Portuguese

Every label, in European Portuguese.

labels-pt.ts
import type { SignoffLabels } from 'signoff-ui';

// Every label in European Portuguese. Typed as `SignoffLabels`, so a missing one fails to compile.
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
const decimal = new Intl.NumberFormat('pt-PT', { maximumFractionDigits: 1 });
const compact = new Intl.NumberFormat('pt-PT', { notation: 'compact', maximumFractionDigits: 1 });
const usd = new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'USD', maximumSignificantDigits: 3 });
const valid = (n: number | undefined | null): n is number => n != null && Number.isFinite(n) && n >= 0;
const lines = (r: { side: 'new' | 'old'; startLine: number; endLine: number }) =>
  `${r.startLine === r.endLine ? `a linha ${r.startLine}` : `as linhas ${r.startLine} a ${r.endLine}`}${r.side === 'old' ? ' do original' : ''}`;

export const pt: SignoffLabels = {
  format: {
    duration: (ms) => {
      if (!valid(ms)) return '–';
      if (Math.round(ms) < 1000) return `${Math.round(ms)} ms`;
      const seconds = Math.round(ms / 1000);
      if (ms < 60_000) return `${decimal.format(ms / 1000)} s`;
      return `${Math.floor(seconds / 60)} min ${String(seconds % 60).padStart(2, '0')} s`;
    },
    durationLong: (ms) => {
      if (!valid(ms)) return 'duração desconhecida';
      if (Math.round(ms) < 1000) return `${Math.round(ms)} milissegundos`;
      if (ms < 60_000) return `${decimal.format(ms / 1000)} segundos`;
      const seconds = Math.round(ms / 1000);
      const [m, s] = [Math.floor(seconds / 60), seconds % 60];
      return `${m} ${plural(m, 'minuto', 'minutos')} e ${s} ${plural(s, 'segundo', 'segundos')}`;
    },
    tokens: (n) => (n == null || !Number.isFinite(n) ? '–' : compact.format(n)),
    cost: (value) => (value == null || !Number.isFinite(value) ? '–' : usd.format(value)),
    percent: (fraction) => new Intl.NumberFormat('pt-PT', { style: 'percent' }).format(fraction),
  },
  common: {
    copied: 'Copiado',
    copyThe: (what) => `Copiar ${what.toLowerCase()}`,
    showAllLines: (count) => `Mostrar as ${count} linhas`,
    showLess: 'Mostrar menos',
    opensInNewTab: '(abre num novo separador)',
    modKey: (mac) => (mac ? '⌘' : 'Ctrl'),
  },
  agentStatus: {
    states: {
      idle: 'Inativo',
      thinking: 'A pensar',
      working: 'A trabalhar',
      'awaiting-approval': 'À espera de aprovação',
      done: 'Concluído',
      stopped: 'Parado',
      error: 'Erro',
    },
    spoken: (state, detail) => (detail ? `${state}: ${detail}` : state),
    writingResponse: 'A escrever a resposta',
  },
  toolCallTimeline: {
    list: 'Chamadas de ferramentas',
    phases: {
      streaming: 'A preparar',
      running: 'A executar',
      'awaiting-approval': 'Precisa de aprovação',
      success: 'Concluída',
      error: 'Falhou',
      denied: 'Recusada',
    },
    stopped: 'Interrompida',
    finished: (name, duration) => `${name} terminou em ${duration}`,
    failed: (name, error) => `${name} falhou: ${error ?? 'erro desconhecido'}`,
    blockedByPolicy: (name) => `${name} foi bloqueada pela política`,
    denied: (name) => `${name} foi recusada`,
    input: 'Entrada',
    inputStreaming: 'Entrada (a chegar)',
    output: 'Saída',
    outputPartial: 'Saída (parcial)',
    error: 'Erro',
    deniedBy: (automatic) => (automatic ? 'Bloqueada pela política' : 'Recusada pelo utilizador'),
    waitingForInput: 'À espera da entrada…',
  },
  approvalCard: {
    required: 'Aprovação necessária',
    risk: { low: 'Risco baixo', medium: 'Risco médio', high: 'Risco alto', critical: 'Crítico' },
    command: 'Comando',
    arguments: 'Argumentos',
    approve: 'Aprovar',
    session: 'Nesta sessão',
    always: 'Sempre',
    deny: 'Recusar',
    alwaysDeny: 'Recusar sempre',
    sendDenial: 'Enviar recusa',
    confirm: 'Confirmar aprovação',
    denyWithFeedback: 'Recusar com comentário',
    reasonLabel: 'Diga ao agente porquê (opcional)',
    reasonPlaceholder: 'O que deve o agente fazer em vez disto?',
    approveHint: 'aprovar',
    keyboardHint: (offered, mod) =>
      `Teclado: prima ${[
        offered.approve && 'Y para aprovar',
        offered.session && 'S para aprovar nesta sessão',
        offered.always && 'A para aprovar sempre',
        offered.deny && 'N para recusar',
        offered.alwaysDeny && 'Shift N para recusar sempre',
      ]
        .filter(Boolean)
        .join(', ')}, ou ${mod} Enter para aprovar.`,
    resolved: {
      'allow-once': 'Aprovado',
      'allow-session': 'Aprovado nesta sessão',
      'allow-always': 'Aprovado sempre',
      'deny-once': 'Recusado',
      'deny-always': 'Recusado sempre',
    },
    approved: 'Aprovado',
    denied: 'Recusado',
    autoApproved: 'Aprovado automaticamente',
    blockedByPolicy: 'Bloqueado pela política',
    allowedByRule: 'Permitido pela sua regra',
    deniedByRule: 'Recusado pela sua regra',
    describeRule: (rule) => {
      const args = Object.entries(rule.args ?? {});
      const tool = rule.tool === '*' ? 'qualquer ferramenta' : rule.tool.replace(/\\(.)/g, '$1');
      return args.length === 0
        ? `${tool}, quaisquer argumentos`
        : `${tool} com ${args.map(([name, pattern]) => `${name} ${pattern}`).join(', ')}`;
    },
    critical: 'Ação crítica. Prima aprovar outra vez para confirmar.',
    invalidJson: 'Os argumentos não são JSON válido.',
    editArguments: 'Editar argumentos',
    doneEditing: 'Concluir edição',
    undoEdits: 'Desfazer edições',
    edited: 'Editado: ao aprovar, corre com os novos argumentos.',
    argumentsJson: 'Argumentos, em JSON',
    notValidJson: (error) => `JSON inválido: ${error}`,
    ruleLegend: 'Chamadas abrangidas por uma decisão duradoura',
    rememberedFor: (any) => ({
      before: 'Fica memorizado para ',
      after: any ? ' com quaisquer argumentos.' : ' quando:',
    }),
    argumentMatches: (name) => `${name} corresponde a`,
    anyArguments: 'Quaisquer argumentos',
    globHelp: '* corresponde a qualquer texto exceto operadores da shell (; & | > < e acentos graves); ** a tudo.',
    batch: {
      group: 'Aprovações pendentes',
      waiting: (count) => `${count} aprovações à espera.`,
      approveAll: 'Aprovar todas',
      denyAll: 'Recusar todas',
      confirm: 'Confirmar aprovar todas',
      critical: 'Uma delas é crítica. Prima Aprovar todas outra vez para confirmar.',
      answered: (count, approved) => `${count} aprovações ${approved ? 'aprovadas' : 'recusadas'}.`,
    },
  },
  diffReview: {
    title: 'Rever alterações',
    layout: 'Disposição do diff',
    views: { unified: 'Unificado', split: 'Lado a lado' },
    files: (count) => `${count} ${plural(count, 'ficheiro', 'ficheiros')}`,
    hunks: (count, comparing) => `${count} ${plural(count, 'bloco', 'blocos')}${comparing ? ' até agora' : ''}`,
    status: {
      modified: { letter: 'M', name: 'Modificado' },
      added: { letter: 'A', name: 'Adicionado' },
      deleted: { letter: 'E', name: 'Eliminado' },
      renamed: { letter: 'R', name: 'Renomeado' },
    },
    navigator: 'Ficheiros nesta revisão',
    navigatorState: (decided, total, viewed) => `, ${decided} de ${total} decididos${viewed ? ', visto' : ''}`,
    comparingShort: 'a comparar…',
    renamedTo: 'para',
    toggleFile: (collapsed, path) => `${collapsed ? 'Mostrar' : 'Ocultar'} ${path}`,
    viewed: 'Visto',
    rejectFile: 'Rejeitar ficheiro',
    acceptFile: 'Aceitar ficheiro',
    comparing: 'A comparar alterações…',
    fallback:
      'Demasiadas alterações para comparar linha a linha: as linhas alteradas aparecem como um só bloco que as substitui.',
    noChanges: 'Sem alterações de texto.',
    wholeFile: (status, binary, oldPath) =>
      binary
        ? 'Ficheiro binário, não mostrado.'
        : status === 'renamed'
          ? `Renomeado de ${oldPath}, sem alterações.`
          : status === 'deleted'
            ? 'Ficheiro vazio, eliminado.'
            : 'Novo ficheiro vazio.',
    accept: 'Aceitar',
    reject: 'Rejeitar',
    itemName: (kind, n) => `${kind === 'hunk' ? 'bloco' : 'alteração'} ${n}`,
    acceptItem: (item) => `Aceitar ${item}`,
    rejectItem: (item) => `Rejeitar ${item}`,
    resetItem: (item) => `Repor ${item}`,
    commentOn: (what) => `Comentar ${what}`,
    badge: { accepted: 'aceite', rejected: 'rejeitado' },
    hunk: (n, total, path, from, to, decision) =>
      `Bloco ${n} de ${total}, ${path}, linhas ${from} a ${to}, ${
        decision === 'pending' ? 'por rever' : decision === 'accepted' ? 'aceite' : 'rejeitado'
      }`,
    fileItem: (n, total, path, what, decision) =>
      `Alteração ${n} de ${total}, ${path}, ${what}, ${
        decision === 'pending' ? 'por rever' : decision === 'accepted' ? 'aceite' : 'rejeitada'
      }`,
    describeFile: (status, binary, oldPath) =>
      binary
        ? 'ficheiro binário'
        : status === 'renamed'
          ? `renomeado de ${oldPath}`
          : status === 'deleted'
            ? 'ficheiro vazio eliminado'
            : 'novo ficheiro vazio',
    hunkCode: (n, path) => `Código do bloco ${n}, ${path}`,
    added: 'Adicionada: ',
    removed: 'Removida: ',
    decided: (kind, n, total, decision, remaining) => {
      const hunk = kind === 'hunk';
      const word =
        decision === 'pending'
          ? hunk
            ? 'reposto'
            : 'reposta'
          : decision === 'accepted'
            ? 'aceite'
            : hunk
              ? 'rejeitado'
              : 'rejeitada';
      return `${hunk ? 'Bloco' : 'Alteração'} ${n} de ${total} ${word}. ${
        remaining === 0 ? 'Todos os blocos revistos.' : `${remaining} por rever.`
      }`;
    },
    decidedAll: (total, decision) => `Os ${total} blocos foram ${decision === 'accepted' ? 'aceites' : 'rejeitados'}.`,
    decidedFile: (count, path, decision) =>
      count === 1
        ? `O bloco de ${path} foi ${decision === 'accepted' ? 'aceite' : 'rejeitado'}.`
        : `Os ${count} blocos de ${path} foram ${decision === 'accepted' ? 'aceites' : 'rejeitados'}.`,
    viewedAnnouncement: (path, viewed, total) => `${path} visto. ${viewed} de ${total} ficheiros vistos.`,
    notViewedAnnouncement: (path) => `${path} deixou de estar visto.`,
    lines,
    selected: (line, range) =>
      `${line}. ${range.startLine === range.endLine ? `Linha ${range.startLine} selecionada` : `Linhas ${range.startLine} a ${range.endLine} selecionadas`}${range.side === 'old' ? ' no original' : ''}.`,
    where: (comment) =>
      comment.target === 'file'
        ? 'o ficheiro'
        : comment.target === 'hunk'
          ? 'o bloco'
          : lines({ side: 'new', startLine: comment.startLine ?? 0, endLine: comment.endLine ?? 0 }),
    commentHeading: (where) => ({ hidden: 'Comentário ', visible: `Sobre ${where}` }),
    commentField: (where) => `Comentário sobre ${where}, para o agente`,
    commentPlaceholder: 'O que deve mudar aqui?',
    editComment: (where) => `Editar comentário sobre ${where}`,
    deleteComment: (where) => `Eliminar comentário sobre ${where}`,
    edit: 'Editar',
    delete: 'Eliminar',
    cancel: 'Cancelar',
    saveComment: 'Guardar comentário',
    addComment: 'Comentar',
    commentAdded: (where, path) => `Comentário adicionado sobre ${where} de ${path}.`,
    commentUpdated: 'Comentário atualizado.',
    commentDeleted: 'Comentário eliminado.',
    unchangedLines: (count) => `${count} ${plural(count, 'linha inalterada', 'linhas inalteradas')}`,
    moreAfter: (count, hunk) => ({ visible: `mais ${count}`, hidden: ` linhas inalteradas depois do bloco ${hunk}` }),
    moreBefore: (count, hunk) => ({ visible: `mais ${count}`, hidden: ` linhas inalteradas antes do bloco ${hunk}` }),
    showAll: (count) => ({ visible: 'Mostrar todas', hidden: ` as ${count} linhas inalteradas` }),
    expanded: (count) => `A mostrar mais ${count} ${plural(count, 'linha inalterada', 'linhas inalteradas')}.`,
    reviewed: 'revistos',
    filesViewed: (viewed, total) => `${viewed} de ${total} ficheiros vistos`,
    comments: (count) => `${count} ${plural(count, 'comentário', 'comentários')}`,
    comparingFiles: 'a comparar ficheiros…',
    skipped: 'os blocos por rever ficam de fora',
    apply: (accepted, total) => (accepted > 0 ? `Aplicar ${accepted} de ${total}` : 'Aplicar alterações'),
    acceptAll: 'Aceitar tudo',
    rejectAll: 'Rejeitar tudo',
    keyHints: { move: 'mover', accept: 'aceitar', reject: 'rejeitar', comment: 'comentar', apply: 'aplicar' },
    keyboardHelp: (mod) =>
      `Teclado: J ou K para mudar de bloco, Shift J ou Shift K para mudar de ficheiro, A para aceitar, R para rejeitar, U para repor, Alt A ou Alt R para o ficheiro inteiro, Shift A ou Shift R para todos os blocos, Shift com a seta para cima ou para baixo para selecionar linhas, C para as comentar ou comentar o bloco, E para mostrar mais linhas inalteradas, V para marcar o ficheiro como visto, ${mod} Enter para aplicar.`,
  },
  markdown: {
    code: (language) => (language && language !== 'text' ? `Código, ${language}` : 'Código'),
    codeBadge: 'código',
    copyCode: 'Copiar código',
    table: 'Tabela',
    image: 'Imagem:',
    imageFallback: 'imagem',
    citation: (n) => `Fonte ${n}`,
  },
  reasoning: {
    thinking: 'A pensar',
    thoughtFor: (duration) => `Pensou durante ${duration}`,
    reasoning: 'Raciocínio',
  },
  runMeter: {
    title: 'Execução',
    metrics: (live) => `Métricas da execução${live ? ' (ao vivo)' : ''}`,
    summary: ({ input, output, cost, ttft, total }) =>
      [
        `${input} tokens de entrada`,
        `${output} tokens de saída`,
        cost !== undefined ? `custo estimado ${cost}` : undefined,
        ttft !== undefined ? `tempo até ao primeiro token ${ttft}` : undefined,
        total !== undefined ? `total ${total}` : undefined,
      ]
        .filter(Boolean)
        .join(', '),
    inputTokens: 'Tokens de entrada',
    outputTokens: 'Tokens de saída',
    estimatedCost: 'Custo estimado',
    timeToFirstToken: 'Tempo até ao primeiro token',
    totalTime: 'Tempo total',
    ttft: 'TTFT',
    live: 'Ao vivo',
    tokens: 'Tokens',
    estCost: 'Custo est.',
    breakdown: 'Detalhe dos tokens',
    split: (input, output) => `Entrada ${input} · Saída ${output}`,
    input: 'Entrada',
    output: 'Saída',
    cached: (n) => `(${n} em cache)`,
    reasoningTokens: (n) => `(${n} de raciocínio)`,
    total: 'Total',
    cacheHit: 'Acertos na cache',
    costSplit: (input, output) => `${input} entrada · ${output} saída`,
    rates: (input, output) => `US$ ${input}/${output} por 1M`,
  },
  approvalPolicy: { ruleDenial: 'Recusado por uma regra' },
  sources: { label: 'Fontes', document: 'Documento' },
  agentMessage: { attachedImage: 'Imagem anexada' },
};