feat(card): Netzwerk- und Marken-Logos

18 Netzwerk-Logos, AirCards-Symbol/-Text und Apple als bereinigte SVGs
(Wikimedia Commons, gemeinfrei; abgeleitete Varianten selbst gebaut),
Register marks.ts mit Einfärben (currentColor) und Bild-Lader sowie
festfarbiges Favicon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:42:10 +00:00
1 parent 1a4ed0d595
commit eb6b4ac59d
24 files changed
+318 -4

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import { NETWORK_IDS } from './config';
import favicon from '../../public/favicon.svg?raw';
import {
BRAND_MARKS,
NETWORK_MARKS,
getBrandMark,
getNetworkMark,
loadMarkImage,
svgAspect,
tintSvg,
} from './marks';
const NETWORK_LABELS = [
'Automatisch', 'Blau', 'Marineblau', 'Metallisches Silber',
'Farbe', 'Metallisches Silber', 'Wortmarke', 'Silber + Text', 'Klassisch',
'Farbe', 'Metallisches Silber', 'Weißer Rahmen', 'Englisch',
'Blau', 'Umriss', 'Select',
'JCB', 'Discover',
];
const ALL = [...NETWORK_MARKS, ...BRAND_MARKS];
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple'];
describe('Registers', () => {
it('NETWORK_MARKS folgt NETWORK_IDS', () => {
expect(NETWORK_MARKS.map((m) => m.id)).toEqual([...NETWORK_IDS]);
});
it('Labels stehen im Wortlaut der Vorlage in Reihenfolge', () => {
expect(NETWORK_MARKS.map((m) => m.label)).toEqual(NETWORK_LABELS);
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCards · Symbol', 'AirCards · Text', 'Apple']);
});
it('BRAND_MARKS: Logo-Gruppe, feste Reihenfolge', () => {
expect(BRAND_MARKS.map((m) => m.id)).toEqual(['aircards-symbol', 'aircards-text', 'apple']);
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
});
it('Gruppen der Netzwerke', () => {
const groups = NETWORK_MARKS.map((m) => m.group);
expect(groups).toEqual([
'Visa', 'Visa', 'Visa', 'Visa',
'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard',
'UnionPay', 'UnionPay', 'UnionPay', 'UnionPay',
'American Express', 'American Express', 'American Express',
'JCB', 'Discover',
]);
});
it('getNetworkMark / getBrandMark finden den Eintrag', () => {
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id);
expect(getBrandMark('apple').id).toBe('apple');
expect(getBrandMark('aircards-text').label).toBe('AirCards · Text');
});
});
describe('SVG-Inhalt', () => {
it.each(ALL.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
expect(m.svg.trimStart().startsWith('<svg')).toBe(true);
expect(m.svg).toMatch(/viewBox="[^"]+"/);
expect(m.svg).not.toContain('<image');
expect(m.svg).not.toContain('<script');
const a = svgAspect(m.svg);
expect(Number.isFinite(a)).toBe(true);
expect(a).toBeGreaterThan(0);
});
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
for (const m of ALL) {
const root = /^<svg\b[^>]*>/.exec(m.svg.trimStart())![0];
expect(root, m.id).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(root, m.id).not.toMatch(/\s(width|height)=/);
}
});
it('svgAspect: ohne brauchbare viewBox 1', () => {
expect(svgAspect('<svg></svg>')).toBe(1);
expect(svgAspect('<svg viewBox="0 0 0 10"></svg>')).toBe(1);
});
it('svgAspect liest die viewBox', () => {
expect(svgAspect('<svg viewBox="0 0 200 100"></svg>')).toBeCloseTo(2, 1);
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
});
it('svgAspect: Visa ist ein breites Wortlogo', () => {
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1);
});
it('Einfärbbar genau bei den fünf Tint-Marken', () => {
const tinted = ALL.filter((m) => m.tint).map((m) => m.id).sort();
expect(tinted).toEqual([...TINT_IDS].sort());
});
it('Tint-Marken enthalten currentColor, alle anderen nicht', () => {
for (const m of ALL) {
expect(m.svg.includes('currentColor'), m.id).toBe(m.tint);
}
});
it('tintSvg ersetzt jedes currentColor', () => {
for (const id of TINT_IDS) {
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
const dark = tintSvg(m.svg, 'dark');
expect(dark).toContain('#111111');
expect(dark).not.toContain('currentColor');
const light = tintSvg(m.svg, 'light');
expect(light).toContain('#FFFFFF');
expect(light).not.toContain('currentColor');
}
});
it('tintSvg lässt Marken ohne currentColor unverändert', () => {
const m = getNetworkMark('jcb');
expect(tintSvg(m.svg, 'dark')).toBe(m.svg);
});
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
const seen = new Map<string, string>();
for (const m of ALL) {
for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
const id = hit[1];
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false);
seen.set(id, m.id);
}
}
});
});
describe('maxH', () => {
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
it('109 für Visa, Mastercard-Wortmarken, UnionPay English, Discover', () => {
for (const id of ['visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', 'mc-wordmark', 'mc-silver-text', 'up-english', 'discover']) {
expect(h(id), id).toBe(109);
}
});
it('125 für Symbol-Logos', () => {
for (const id of ['mc-color', 'mc-silver', 'mc-classic', 'up-color', 'up-silver', 'up-white-frame', 'amex-blue', 'amex-outline', 'jcb']) {
expect(h(id), id).toBe(125);
}
});
it('90 für Amex Select, 100 für alle Marken', () => {
expect(h('amex-select')).toBe(90);
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true);
});
});
describe('Favicon', () => {
it('hat feste Farben und kein currentColor', () => {
expect(favicon).not.toContain('currentColor');
expect(favicon).toContain('#E8EEF7');
expect(favicon).toContain('#0F1828');
expect(favicon).toMatch(/viewBox="0 0 64 64"/);
});
});
describe('loadMarkImage', () => {
it('ist eine Funktion', () => {
expect(typeof loadMarkImage).toBe('function');
});
});
+131
View File
@@ -0,0 +1,131 @@
// Logo-Register: SVG-Dateien der Netzwerke und Marken, 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.
import { TONE_COLOR, type BrandId, type DetailsTone, type NetworkId, NETWORK_IDS } from './config';
import visaAuto from '../assets/marks/visa-auto.svg?raw';
import visaBlue from '../assets/marks/visa-blue.svg?raw';
import visaNavy from '../assets/marks/visa-navy.svg?raw';
import visaSilver from '../assets/marks/visa-silver.svg?raw';
import mcColor from '../assets/marks/mc-color.svg?raw';
import mcSilver from '../assets/marks/mc-silver.svg?raw';
import mcWordmark from '../assets/marks/mc-wordmark.svg?raw';
import mcSilverText from '../assets/marks/mc-silver-text.svg?raw';
import mcClassic from '../assets/marks/mc-classic.svg?raw';
import upColor from '../assets/marks/up-color.svg?raw';
import upSilver from '../assets/marks/up-silver.svg?raw';
import upWhiteFrame from '../assets/marks/up-white-frame.svg?raw';
import upEnglish from '../assets/marks/up-english.svg?raw';
import amexBlue from '../assets/marks/amex-blue.svg?raw';
import amexOutline from '../assets/marks/amex-outline.svg?raw';
import amexSelect from '../assets/marks/amex-select.svg?raw';
import jcb from '../assets/marks/jcb.svg?raw';
import discover from '../assets/marks/discover.svg?raw';
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
import aircardsText from '../assets/marks/aircards-text.svg?raw';
import apple from '../assets/marks/apple.svg?raw';
export type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo';
export interface MarkDef {
id: NetworkId | Exclude<BrandId, 'none'>;
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */
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 NetworkEntry = Omit<MarkDef, 'id'>;
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = {
'visa-auto': { group: 'Visa', label: 'Automatisch', svg: visaAuto, tint: true, maxH: 109 },
'visa-blue': { group: 'Visa', label: 'Blau', svg: visaBlue, tint: false, maxH: 109 },
'visa-navy': { group: 'Visa', label: 'Marineblau', svg: visaNavy, tint: false, maxH: 109 },
'visa-silver': { group: 'Visa', label: 'Metallisches Silber', svg: visaSilver, tint: false, maxH: 109 },
'mc-color': { group: 'Mastercard', label: 'Farbe', svg: mcColor, tint: false, maxH: 125 },
'mc-silver': { group: 'Mastercard', label: 'Metallisches Silber', svg: mcSilver, tint: false, maxH: 125 },
'mc-wordmark': { group: 'Mastercard', label: 'Wortmarke', svg: mcWordmark, tint: false, maxH: 109 },
'mc-silver-text': { group: 'Mastercard', label: 'Silber + Text', svg: mcSilverText, tint: false, maxH: 109 },
'mc-classic': { group: 'Mastercard', label: 'Klassisch', svg: mcClassic, tint: false, maxH: 125 },
'up-color': { group: 'UnionPay', label: 'Farbe', svg: upColor, tint: false, maxH: 125 },
'up-silver': { group: 'UnionPay', label: 'Metallisches Silber', svg: upSilver, tint: false, maxH: 125 },
'up-white-frame': { group: 'UnionPay', label: 'Weißer Rahmen', svg: upWhiteFrame, tint: false, maxH: 125 },
'up-english': { group: 'UnionPay', label: 'Englisch', svg: upEnglish, tint: false, maxH: 109 },
'amex-blue': { group: 'American Express', label: 'Blau', svg: amexBlue, tint: false, maxH: 125 },
'amex-outline': { group: 'American Express', label: 'Umriss', svg: amexOutline, tint: true, maxH: 125 },
'amex-select': { group: 'American Express', label: 'Select', svg: amexSelect, tint: false, maxH: 90 },
jcb: { group: 'JCB', label: 'JCB', svg: jcb, tint: false, maxH: 125 },
discover: { group: 'Discover', label: 'Discover', svg: discover, tint: false, maxH: 109 },
};
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, NetworkEntry> = {
'aircards-symbol': { group: 'Logo', label: 'AirCards · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: 'Logo', label: 'AirCards · Text', svg: aircardsText, tint: true, maxH: 100 },
apple: { group: 'Logo', label: 'Apple', svg: apple, tint: true, maxH: 100 },
};
export const NETWORK_MARKS: readonly MarkDef[] = NETWORK_IDS.map((id) => ({ id, ...NETWORK_TABLE[id] }));
export const BRAND_MARKS: readonly MarkDef[] = (
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
).map((id) => ({ id, ...BRAND_TABLE[id] }));
export function getNetworkMark(id: NetworkId): MarkDef {
return NETWORK_MARKS.find((m) => m.id === id) ?? NETWORK_MARKS[0];
}
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
}
/** 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);
if (!hit) return 1;
const parts = hit[1].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 : 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;
}