Files
AirCardIOS/web/src/card/marks.ts
T
ClaudeandClaude Opus 5.5 1ff1873b27 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>
2026-10-04 23:58:49 +00:00

94 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Logo-Register: SVG-Dateien der AirCard-IOS-Marken (Logo oben links), Einfärben und Laden als Bild für den Canvas.
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
// Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke.
import { msg } from '../i18n/i18n';
import { TONE_COLOR, type BrandId, type DetailsTone } from './config';
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
import aircardsText from '../assets/marks/aircards-text.svg?raw';
export type MarkGroup = 'Logo';
export interface MarkDef {
id: Exclude<BrandId, 'none'>;
/**
* Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“).
* Beide sind deutsch markiert (`msg`) und werden erst beim Anzeigen mit `t()` übersetzt.
*/
group: MarkGroup;
label: string;
svg: string;
/** true: enthält `currentColor` und folgt der Details-Farbe. */
tint: boolean;
/** Höchsthöhe auf der Karte in px (Breite folgt aus dem Seitenverhältnis der viewBox). */
maxH: number;
}
type MarkEntry = Omit<MarkDef, 'id'>;
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
'aircards-symbol': { group: msg('Logo'), label: msg('AirCard-IOS · Symbol'), svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: msg('Logo'), label: msg('AirCard-IOS · Text'), svg: aircardsText, tint: true, maxH: 100 },
};
export const BRAND_MARKS: readonly MarkDef[] = (
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
).map((id) => ({ id, ...BRAND_TABLE[id] }));
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
}
/** Breite durch Höhe aus dem Wert einer viewBox („x y Breite Höhe“, mit Leerraum oder Kommas); null, wenn er nicht brauchbar ist. */
export function viewBoxAspect(value: string): number | null {
const parts = value.trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : null;
}
/** Breite durch Höhe aus der viewBox; 1, wenn keine brauchbare viewBox da ist. */
export function svgAspect(svg: string): number {
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
return (hit && viewBoxAspect(hit[1])) ?? 1;
}
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
export function tintSvg(svg: string, tone: DetailsTone): string {
return svg.replaceAll('currentColor', TONE_COLOR[tone]);
}
/** Gibt dem Wurzelelement eine eigene Größe (viermal maxH hoch), damit alle Browser scharf auf den Canvas zeichnen. */
function withIntrinsicSize(svg: string, maxH: number): string {
const h = maxH * 4;
const w = Math.round(h * svgAspect(svg) * 100) / 100;
return svg.replace('<svg', `<svg width="${w}" height="${h}"`);
}
const imageCache = new Map<string, Promise<HTMLImageElement>>();
/** Lädt ein Logo (eingefärbt) als Bild für `drawImage`. Je (id, tone) wird nur einmal geladen. */
export function loadMarkImage(def: MarkDef, tone: DetailsTone): Promise<HTMLImageElement> {
const key = `${def.id}:${tone}`;
let pending = imageCache.get(key);
if (!pending) {
const source = withIntrinsicSize(tintSvg(def.svg, tone), def.maxH);
pending = new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error(`Logo konnte nicht geladen werden: ${def.id}`));
};
img.src = url;
});
pending.catch(() => imageCache.delete(key));
imageCache.set(key, pending);
}
return pending;
}