feat(i18n): Konto, Editor und Karte auf Englisch

Task 10: alle sichtbaren Texte von account/, editor/, card/ und der Seite „Passwort neu setzen“ (Texte, aria-label, title, Meldungen,
Rückfragen) gehen durch t()/msg(); en.ts bekommt 87 Einträge.

- account/: Konto-Menü, Login-Dialog (Titel, Reiter, Felder, Meldungen) und errors.ts (errorMessage übersetzt beim Zurückgeben).
  validatePassword nutzt {min}/{max}. Der Login-Dialog wird nur einmal gebaut: die Beschriftung des Schließen-Knopfs wird bei jedem
  Öffnen in der aktuellen Sprache gesetzt. LOGIN_NEEDED und DEFAULT_CARD_NAME bleiben deutsche Konstanten (msg), angezeigt werden sie
  mit t(); der Standardname einer neuen Karte folgt damit der Sprache („My card“).
- editor/: Vorlagen-, Options- und Vorschauspalte, Bild-Upload, Speichern, Download, Rückfragen. Konstanten (upload.ts, download.ts,
  editor-page.ts) bleiben deutsche Strings per msg(); validateImageFile, pngSizeWarning und das Speichern („Speichern fehlgeschlagen –
  {reason}“) liefern den Text schon übersetzt, Fehler tragen weiter die deutsche Konstante (Vergleiche mit READ_MESSAGE/PIXELS_MESSAGE
  gehen weiter) und werden beim Anzeigen mit t(error.message) übersetzt. Zoom „100 %“ / „100%“ und „Bearbeitet: {name}“ mit Platzhaltern.
- card/: Bezeichnungen der Marken (msg) und die Meldungen des Renderers (msg, Anzeige im Editor über t()).
- pages/reset-page.ts, account/login-dialog.ts: Die Meldung „Die Passwörter stimmen nicht überein.“ ist nicht mehr der Schlüssel zum
  Fokus: es zählt die Bedingung selbst, nicht der Vergleich mit dem (nun übersetzten) Text.
- i18n/coverage.test.ts: Heuristik für deutsche Text-Literale außerhalb von t()/msg() (Umlaut/ß, mindestens zwei Wörter, einzelnes
  großgeschriebenes Wort; gelesen wird der Syntaxbaum) für account/, editor/, card/ und pages/reset-page.ts; Ausnahmen mit Grund, jede
  muss noch zutreffen; Scanner mit eigenen Tests. Die Liste der Bereiche ist eine Konstante für Task 11.
- i18n/english-ui.test.ts (neu): Stichproben auf Englisch (Konto-Menü, Login-Dialog, Passwort-Seite, Editor inkl. Fehlerwege) und
  „kein deutscher Wörterbuchtext auf der englischen Seite“.
- test-setup.ts: Wer Module mit vi.resetModules() frisch lädt, bekam eine neue Kopie von i18n.ts mit der Browsersprache (happy-dom: en-US);
  der Browser meldet im Test jetzt Deutsch, damit auch diese Tests deutsch beginnen. main.test.ts: nach dem Wechsel auf Englisch heißt
  der Knopf des Konto-Menüs „Sign in“.
- Tests: Vitest 53 Dateien, 1779 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 23:58:49 +00:00
1 parent a75be2d44c
commit 1ff1873b27
23 files changed
+857 -169

No files matched your search

+30 -25
View File
@@ -1,19 +1,20 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
import './account.css';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors';
export type LoginView = 'login' | 'register' | 'forgot';
const TITLES: Record<LoginView, string> = {
login: 'Anmelden',
register: 'Registrieren',
forgot: 'Passwort vergessen',
login: msg('Anmelden'),
register: msg('Registrieren'),
forgot: msg('Passwort vergessen'),
};
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
@@ -37,7 +38,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const closeButton = el(
'button',
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
{ type: 'button', class: 'auth-close', 'aria-label': t('Schließen') },
el('span', { 'aria-hidden': 'true' }, '×'),
);
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
@@ -135,7 +136,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
'aria-controls': 'auth-panel',
tabindex: selected ? 0 : -1,
},
TITLES[tabView],
t(TITLES[tabView]),
);
tab.addEventListener('click', () => select(tabView));
tab.addEventListener('keydown', (event) => {
@@ -145,7 +146,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
});
return tab;
});
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': t('Konto') }, ...tabs);
}
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
@@ -196,10 +197,10 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
if (forView === 'login') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'current-password');
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'current-password');
const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit(t('Anmelden')), el('p', { class: 'auth-aside' }, forgot));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -214,20 +215,22 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
}
if (forView === 'register') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
const address = email.input.value.trim();
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
// Der Fehler liegt am Feld, das ihn verursacht; verglichen wird nie mit dem (übersetzten) Text.
const mismatch = password.input.value !== repeat.input.value;
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
if (problem) {
showError(panel, problem);
(problem === MISMATCH ? repeat : password).input.focus();
(mismatch ? repeat : password).input.focus();
return;
}
void run(
@@ -239,16 +242,16 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
},
(err) => {
if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton('Anmelden', () => showView('login'));
return linkButton(t('Anmelden'), () => showView('login'));
},
);
});
return panel;
}
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login'));
form.append(email.label, errorEl, okEl, submit(t('Link senden')), el('p', { class: 'auth-aside' }, back));
const panel: Panel = { form, first: email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -256,7 +259,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const address = email.input.value.trim();
void run(panel, 'recover', async () => {
await requestPasswordReset(address);
showOk(panel, RESET_SENT);
showOk(panel, t(RESET_SENT));
});
});
return panel;
@@ -268,7 +271,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim();
view = next;
title.textContent = TITLES[next];
title.textContent = t(TITLES[next]);
const panel = buildPanel(next);
if (next === 'forgot') {
content.replaceChildren(panel.form);
@@ -288,6 +291,8 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
pending = new Promise<boolean>((resolve) => {
settle = resolve;
});
// Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben.
closeButton.setAttribute('aria-label', t('Schließen'));
showView(initial);
try {
dialog.showModal();