Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ test("loc-switcher: sign-in and not-found pages carry the language switcher", as
await evidence.shot("01-login-en");
await chooseLanguage(page, "hu");
await expect(page.getByRole("heading", { level: 1, name: hu.login.title })).toBeVisible();
// "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix.
// "Bejelentkezés a DebateLabba" already names the app, so it gets no suffix.
await expect(page).toHaveTitle(hu.login.title);
await evidence.shot("02-login-hu");

Expand Down
2 changes: 1 addition & 1 deletion agentic/pages.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const PUBLIC_PAGES = ["/", "/login", "/terms", "/privacy", "/expocit/sources"];
const NAMES: Record<Language, string> = { en: "English", es: "Spanish", hu: "Hungarian" };

const EXEMPT =
"Product names such as DebateLab and ExpoCit, AI model names (Claude Sonnet 5), plan names (Pro), debate format codes, language names offered as choices (English, Español, Magyar), and user content do not count. Technical loanwords the app uses on purpose, such as email, chat, prompt and token, count as translated.";
"Product names such as DebateLab and ExpoCit, AI model names (Claude Sonnet 5), plan names (Pro), debate format codes and the competition format names kept in English in every language (Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary), language names offered as choices (English, Español, Magyar), and user content do not count. Technical loanwords the app uses on purpose, such as email, chat, prompt and token, count as translated.";

async function checkPage(browser: Browser, agent: Agent, language: Language) {
await expect(browser.locator("html")).toHaveAttribute("lang", language);
Expand Down
21 changes: 20 additions & 1 deletion docs/standards/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,26 @@ Part of the [coding standards](../../CODING_STANDARDS.md). [docs/architecture.md

- Every user-facing string, including toasts, errors, and accessible names, goes through `t()`. `src/test/i18n-keys.test.ts` fails when a literal key is missing from the catalogs or a `count` call lacks its plural forms. It cannot see dynamic keys (``t(`nav.${key}`)``) or raw values rendered as text, so check those by hand: an English word in a Hungarian screen is the usual sign ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004217), [#43](https://github.com/Programcode/DebateLab/pull/43#discussion_r4048412531)).
- Store a translation key or error kind in state and translate at render time. English strings stored in state stay English after a reload ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641290)).
- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)).
- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). The glossary below is the one name per language for each tool, role, and format.
- A count goes into the string as `count`, so i18next picks the plural form: `t('libraryPage.sourceCount', { count })`, never `` `${n} ${t('sources')}` ``. Format numbers and dates with `src/lib/date-format.ts`, which follows the interface language.
- `src/test/i18n-untranslated.test.ts` fails when a Spanish or Hungarian value is identical to English. When that is intended, such as a product or format name, add it to the test's list under a reason.

### Glossary

| Concept | English | Spanish | Hungarian |
| --- | --- | --- | --- |
| Crossfire (tool and saved round) | Crossfire | Interrogatorio | Keresztkérdezés |
| Sparring (tool and saved round) | Sparring | Sparring | Vitapárbaj |
| Timer | Timer | Cronómetro | Időmérő |
| Case | Case | Caso | Eset (Vitaeset in titles) |
| Ruthless (difficulty, intensity) | Ruthless | Despiadado | Kíméletlen |
| School format | School 4-6-6-3 | Formato escolar 4-6-6-3 | Iskolai 4-6-6-3 |
| School-format roles (the Timer spells out R1 and R2) | Introductor, R1, R2, Conclusor | Introductor, R1, R2, Conclusor | Bevezető, R1, R2, Záró |
| Sides in a Sparring turn | Pro, Con | a favor, en contra | Pro, Kontra |
| Competition formats | Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary | same as English | same as English |
| Load (button) / Loading | Load / Loading | Cargar / Cargando | Megnyitás / Betöltés |
| AI | AI | IA | MI |
| DebateLab with a suffix | | | DebateLabba, DebateLabban, DebateLabhoz, DebateLabot (no hyphen, back vowels) |

## Accessible names

Expand Down
2 changes: 1 addition & 1 deletion e2e/first-run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const copy = {
gateMotion: 'This House would abolish zoos',
},
hu: {
welcome: 'Üdvözlünk a DebateLab-ban!',
welcome: 'Üdvözlünk a DebateLabban!',
firstDebate: 'Itt indíthatod el az első vitádat.',
startDebate: 'Vita indítása',
motionField: 'Tétel',
Expand Down
2 changes: 1 addition & 1 deletion e2e/full-stack/first-run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const copy = {
demoTab: "Demókód",
demoCode: "Demókód",
signIn: "Bejelentkezés demókóddal",
welcome: "Üdvözlünk a DebateLab-ban!",
welcome: "Üdvözlünk a DebateLabban!",
firstDebate: "Itt indíthatod el az első vitádat.",
startDebate: "Vita indítása",
motionField: "Tétel",
Expand Down
8 changes: 4 additions & 4 deletions e2e/hungarian-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ test.describe('Hungarian parity', () => {
await expectHungarianDocument(page);

for (const [path, heading] of [
['/login', 'Bejelentkezés a DebateLab-ba'],
['/login', 'Bejelentkezés a DebateLabba'],
['/terms', 'Felhasználási feltételek'],
['/privacy', 'Adatvédelmi irányelvek'],
['/expocit/sources', 'Források'],
Expand Down Expand Up @@ -76,7 +76,7 @@ test.describe('Hungarian parity', () => {

await expectHungarianDocument(page);
await expect(page.getByRole('heading', { name: 'Felkészülés' })).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLab-ban!', { timeout: 15_000 });
await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLabban!', { timeout: 15_000 });
await expect(page.locator('[data-test-id="button-primary"]')).toContainText('Következő');
await expect(page.locator('[data-test-id="button-skip"]')).toContainText('Túra kihagyása');
await page.locator('[data-test-id="button-skip"]').click();
Expand Down Expand Up @@ -139,7 +139,7 @@ test.describe('Hungarian parity', () => {
await expect(page.getByRole('alert').filter({ hasText: 'Adj meg egy címet.' })).toBeVisible();

await page.goto('/app/flowchart', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Betöltés' }).click();
await page.getByRole('button', { name: 'Megnyitás', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Mentett folyamatábrák' }).getByRole('button', { name: 'Bezárás' })).toBeVisible();
await page.keyboard.press('Escape');
// Deleting a node raises a toast, whose close button is localized too.
Expand All @@ -150,7 +150,7 @@ test.describe('Hungarian parity', () => {
await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás');

await page.goto('/app/timer', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('timer', { name: 'Időmérő kijelző' })).toBeVisible();
await expect(page.getByRole('timer', { name: 'Időmérő-kijelző' })).toBeVisible();
expect(errors, errors.join('\n')).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion e2e/localization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ test('sign-in and not-found pages carry the language switcher', async ({ page })
await shot(page, '01-login-en');
await choose(page, 'hu');
await expect(page.getByRole('heading', { level: 1, name: hu.login.title })).toBeVisible();
// "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix.
// "Bejelentkezés a DebateLabba" already names the app, so it gets no suffix.
await expect(page).toHaveTitle(hu.login.title);
await shot(page, '02-login-hu');

Expand Down
4 changes: 2 additions & 2 deletions src/components/features/ask/AskChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { cn } from '@/lib/utils';
import { useSpeechToText } from '@/hooks/use-speech-to-text';
import { useToast } from '@/hooks/use-toast';
import { validateFileUpload, MAX_FILE_COUNT, formatFileSize } from '@/lib/validation/file';
import { formatEuropeanDate, formatEuropeanTime } from '@/lib/date-format';
import { formatDecimal, formatEuropeanDate, formatEuropeanTime } from '@/lib/date-format';
import { ModelSelector } from '@/components/ModelSelector';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
Expand Down Expand Up @@ -311,7 +311,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { m
parts.push(message.output.provider);
}
if (message.role === 'assistant' && message.output?.durationMs) {
parts.push(`${(message.output.durationMs / 1000).toFixed(1)}s`);
parts.push(t('askChat.elapsed', { seconds: formatDecimal(message.output.durationMs / 1000, 1) }));
}
return parts.join(' • ');
} catch (e) {
Expand Down
6 changes: 3 additions & 3 deletions src/components/features/attack/AttackResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,15 @@ export function AttackResults({ result, discoveredSources, onDiscuss, onPractice
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-destructive" />
<span>{criticalCount} {t('attackResults.critical')}</span>
<span>{t('attackResults.criticalCount', { count: criticalCount })}</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-amber-500" />
<span>{majorCount} {t('attackResults.major')}</span>
<span>{t('attackResults.majorCount', { count: majorCount })}</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-primary" />
<span>{minorCount} {t('attackResults.minor')}</span>
<span>{t('attackResults.minorCount', { count: minorCount })}</span>
</div>
</div>

Expand Down
4 changes: 2 additions & 2 deletions src/components/features/debate/CurrentDebateMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/compon
import { formatRelativeDate } from '@/lib/date-format';
import { cn } from '@/lib/utils';
import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store';
import { useDebateCounts } from './CurrentDebateStrip';
import { debateCountsLabel, useDebateCounts } from './CurrentDebateStrip';
import { navSectionFor } from '@/components/layout/nav-sections';

/**
Expand Down Expand Up @@ -81,7 +81,7 @@ export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' }
{t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })}
</p>
<p className="text-xs text-muted-foreground">
{t('debate.counts', { sources: counts.sources, active: counts.activeSources, outputs: counts.outputs, defaultValue: '{{sources}} sources ({{active}} active) · {{outputs}} outputs' })}
{debateCountsLabel(t, counts)}
</p>
{oneOff && <p className="text-xs font-medium text-primary">{t('debate.oneOffActive', 'One-off mode is on')}</p>}
</div>
Expand Down
11 changes: 11 additions & 0 deletions src/components/features/debate/CurrentDebateStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { shouldPrefillMotion } from '@/lib/debate/motion';
Expand Down Expand Up @@ -30,6 +31,16 @@ export function useDebateCounts(debateId: string | null | undefined) {
return { sources, activeSources, outputs: outputs + sparring };
}

/** "3 sources (2 active) · 1 output", with each count in its own plural form. */
// eslint-disable-next-line react-refresh/only-export-components
export function debateCountsLabel(t: TFunction, counts: { sources: number; activeSources: number; outputs: number }): string {
return t('debate.counts', {
sources: t('debate.sourceCount', { count: counts.sources }),
active: t('debate.activeCount', { count: counts.activeSources }),
outputs: t('debate.outputCount', { count: counts.outputs }),
});
}

/**
* Fills a feature's motion from the Current Debate on open and after a switch,
* without overwriting text the user typed themselves.
Expand Down
4 changes: 2 additions & 2 deletions src/components/features/factoids/FactoidsResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export function FactoidsResults({ result, discoveredSources, onDiscuss, onSave,
{/* Verification Status */}
<SectionCard
title={t('factoidsResults.verificationStatus')}
description={`${verifiedCount} ${t('factoidsResults.verified')}`}
description={t('factoidsResults.verifiedCount', { count: verifiedCount })}
>
<div className="space-y-4">
<div className="flex items-center gap-4">
Expand Down Expand Up @@ -264,7 +264,7 @@ export function FactoidsResults({ result, discoveredSources, onDiscuss, onSave,

{/* Factoids */}
<SectionCard
title={`${t('factoidsResults.found')} ${displayFactoids.length} ${t('factoidsResults.factoids')}`}
title={t('factoidsResults.foundCount', { count: displayFactoids.length })}
description={t('factoidsResults.factoidsDesc')}
>
<div className="space-y-4">
Expand Down
4 changes: 2 additions & 2 deletions src/components/features/generate/GenerateResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Separator } from '@/components/ui/separator';
import { Markdown } from '@/components/ui/markdown';
import type { GenerateOutput, Contention } from '@/lib/ai/schemas/generate';
import { toast } from 'sonner';
import { formatEuropeanDateTime } from '@/lib/date-format';
import { formatDecimal, formatEuropeanDateTime } from '@/lib/date-format';
import { ExportToolbar } from '@/components/features/ExportToolbar';
import { ResultHeader } from '@/components/features/tools/ResultHeader';
import { CopyResultButton, SaveResultAction } from '@/components/features/tools/result-actions';
Expand Down Expand Up @@ -83,7 +83,7 @@ function ArgumentCard({ contention, index, unverified }: { contention: Contentio
function formatDuration(ms: number | undefined, t: (key: string, options?: Record<string, unknown>) => string) {
if (!ms || ms < 0) return null;
if (ms < 1000) return t('generateResults.durationMs', { value: ms });
const value = (ms / 1000).toFixed(ms < 10000 ? 1 : 0);
const value = formatDecimal(ms / 1000, ms < 10000 ? 1 : 0);
return t('generateResults.durationS', { value });
}

Expand Down
45 changes: 34 additions & 11 deletions src/lib/date-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,23 @@ interface LocaleFormatters {
number: Intl.NumberFormat;
}

// Spanish and Hungarian leave four-digit numbers ungrouped by default; group
// them too so a limit reads "1.200" or "1 200", never "1200". The ES2021 lib
// types `useGrouping` as a boolean only.
const ALWAYS_GROUP = { useGrouping: 'always' } as unknown as Intl.NumberFormatOptions;

function createFormatters(language: AppLanguage): LocaleFormatters {
const locale = toIntlLocale(language);
return {
// A month name keeps the date unambiguous: "23 Oct 2026", not "23/10/2026".
date: new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
}),
dateTime: new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
Expand All @@ -31,7 +37,7 @@ function createFormatters(language: AppLanguage): LocaleFormatters {
minute: '2-digit',
hour12: false,
}),
number: new Intl.NumberFormat(locale),
number: new Intl.NumberFormat(locale, ALWAYS_GROUP),
};
}

Expand All @@ -47,6 +53,10 @@ function activeLanguage(): AppLanguage {
return isAppLanguage(i18n.resolvedLanguage) ? i18n.resolvedLanguage : 'en';
}

function notAvailable(language: AppLanguage): string {
return i18n.t('common.notAvailable', { lng: language });
}

function toValidDate(value: Date | number | string): Date | null {
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
// Date-only strings (e.g. "2026-05-20") are parsed as UTC midnight by spec.
Expand All @@ -60,20 +70,20 @@ function toValidDate(value: Date | number | string): Date | null {

export function formatEuropeanDate(
value: Date | number | string,
fallback = 'N/A',
fallback?: string,
language: AppLanguage = activeLanguage(),
): string {
const date = toValidDate(value);
return date ? FORMATTERS[language].date.format(date) : fallback;
return date ? FORMATTERS[language].date.format(date) : fallback ?? notAvailable(language);
}

export function formatEuropeanDateTime(
value: Date | number | string,
fallback = 'N/A',
fallback?: string,
language: AppLanguage = activeLanguage(),
): string {
const date = toValidDate(value);
return date ? FORMATTERS[language].dateTime.format(date) : fallback;
return date ? FORMATTERS[language].dateTime.format(date) : fallback ?? notAvailable(language);
}

export function formatEuropeanTime(
Expand All @@ -89,11 +99,24 @@ export function formatNumber(value: number, language: AppLanguage = activeLangua
return FORMATTERS[language].number.format(value);
}

const DECIMAL_FORMATTERS = new Map<string, Intl.NumberFormat>();

/** A number with exactly `digits` decimals in the language's notation: "1.5" in English, "1,5" in Spanish and Hungarian. */
export function formatDecimal(value: number, digits: number, language: AppLanguage = activeLanguage()): string {
const key = `${language}:${digits}`;
let formatter = DECIMAL_FORMATTERS.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(toIntlLocale(language), { minimumFractionDigits: digits, maximumFractionDigits: digits });
DECIMAL_FORMATTERS.set(key, formatter);
}
return formatter.format(value);
}

export function formatRelativeDate(
value: Date | number | string,
language: AppLanguage = activeLanguage(),
fallback = 'N/A',
fallback?: string,
): string {
const date = toValidDate(value);
return date ? formatDistanceToNow(date, { addSuffix: true, locale: DATE_FNS_LOCALES[language] }) : fallback;
return date ? formatDistanceToNow(date, { addSuffix: true, locale: DATE_FNS_LOCALES[language] }) : fallback ?? notAvailable(language);
}
8 changes: 4 additions & 4 deletions src/lib/pdf-export-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ async function createPDFDocument(): Promise<PDFHelpers> {
doc.setFont(PDF_FONT, 'normal');
doc.setTextColor(...COLORS.secondary);
doc.text(
`${i18n.t('export.generatedBy')} • ${formatEuropeanDate(new Date(), 'N/A', language)} • ${i18n.t('export.page')} ${i} ${i18n.t('export.of')} ${pageCount}`,
`${i18n.t('export.generatedBy')} • ${formatEuropeanDate(new Date(), undefined, language)} • ${i18n.t('export.page')} ${i} ${i18n.t('export.of')} ${pageCount}`,
pageWidth / 2,
pageHeight - 10,
{ align: 'center' }
Expand Down Expand Up @@ -629,9 +629,9 @@ export async function exportFactoidsToPdf(result: FactoidsOutput): Promise<boole
result.factoids.forEach((f, i) => {
const pdf2 = pdf; // Access outer scope
pdf2.addParagraph(`${i + 1}. ${f.shortClaim}`);
pdf2.addParagraph(` ${i18n.t('export.context')}: ${f.context || 'N/A'}`, 5);
pdf2.addParagraph(` ${i18n.t('export.limitations')}: ${f.limitations || 'N/A'}`, 5);
pdf2.addParagraph(` ${i18n.t('export.howToUse')}: ${f.howToUse || 'N/A'}`, 5);
pdf2.addParagraph(` ${i18n.t('export.context')}: ${f.context || i18n.t('common.notAvailable')}`, 5);
pdf2.addParagraph(` ${i18n.t('export.limitations')}: ${f.limitations || i18n.t('common.notAvailable')}`, 5);
pdf2.addParagraph(` ${i18n.t('export.howToUse')}: ${f.howToUse || i18n.t('common.notAvailable')}`, 5);
if (f.citation?.url) {
pdf2.addParagraph(` ${i18n.t('export.source')}: ${f.citation.url}`, 5);
}
Expand Down
Loading
Loading