From eb6b4ac59d87b171f2e9f69599d82047cacfda1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:42:10 +0000 Subject: [PATCH] feat(card): Netzwerk- und Marken-Logos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/public/favicon.svg | 5 +- web/src/assets/marks/aircards-symbol.svg | 1 + web/src/assets/marks/aircards-text.svg | 1 + web/src/assets/marks/amex-blue.svg | 1 + web/src/assets/marks/amex-outline.svg | 1 + web/src/assets/marks/amex-select.svg | 1 + web/src/assets/marks/apple.svg | 1 + web/src/assets/marks/discover.svg | 1 + web/src/assets/marks/jcb.svg | 1 + web/src/assets/marks/mc-classic.svg | 1 + web/src/assets/marks/mc-color.svg | 1 + web/src/assets/marks/mc-silver-text.svg | 1 + web/src/assets/marks/mc-silver.svg | 1 + web/src/assets/marks/mc-wordmark.svg | 1 + web/src/assets/marks/up-color.svg | 1 + web/src/assets/marks/up-english.svg | 1 + web/src/assets/marks/up-silver.svg | 1 + web/src/assets/marks/up-white-frame.svg | 1 + web/src/assets/marks/visa-auto.svg | 1 + web/src/assets/marks/visa-blue.svg | 1 + web/src/assets/marks/visa-navy.svg | 1 + web/src/assets/marks/visa-silver.svg | 1 + web/src/card/marks.test.ts | 165 +++++++++++++++++++++++ web/src/card/marks.ts | 131 ++++++++++++++++++ 24 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 web/src/assets/marks/aircards-symbol.svg create mode 100644 web/src/assets/marks/aircards-text.svg create mode 100644 web/src/assets/marks/amex-blue.svg create mode 100644 web/src/assets/marks/amex-outline.svg create mode 100644 web/src/assets/marks/amex-select.svg create mode 100644 web/src/assets/marks/apple.svg create mode 100644 web/src/assets/marks/discover.svg create mode 100644 web/src/assets/marks/jcb.svg create mode 100644 web/src/assets/marks/mc-classic.svg create mode 100644 web/src/assets/marks/mc-color.svg create mode 100644 web/src/assets/marks/mc-silver-text.svg create mode 100644 web/src/assets/marks/mc-silver.svg create mode 100644 web/src/assets/marks/mc-wordmark.svg create mode 100644 web/src/assets/marks/up-color.svg create mode 100644 web/src/assets/marks/up-english.svg create mode 100644 web/src/assets/marks/up-silver.svg create mode 100644 web/src/assets/marks/up-white-frame.svg create mode 100644 web/src/assets/marks/visa-auto.svg create mode 100644 web/src/assets/marks/visa-blue.svg create mode 100644 web/src/assets/marks/visa-navy.svg create mode 100644 web/src/assets/marks/visa-silver.svg create mode 100644 web/src/card/marks.test.ts create mode 100644 web/src/card/marks.ts diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 5214238..6b6c70f 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,4 +1 @@ - - - - + diff --git a/web/src/assets/marks/aircards-symbol.svg b/web/src/assets/marks/aircards-symbol.svg new file mode 100644 index 0000000..bad8629 --- /dev/null +++ b/web/src/assets/marks/aircards-symbol.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/aircards-text.svg b/web/src/assets/marks/aircards-text.svg new file mode 100644 index 0000000..85b8427 --- /dev/null +++ b/web/src/assets/marks/aircards-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/amex-blue.svg b/web/src/assets/marks/amex-blue.svg new file mode 100644 index 0000000..ccb37e6 --- /dev/null +++ b/web/src/assets/marks/amex-blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/amex-outline.svg b/web/src/assets/marks/amex-outline.svg new file mode 100644 index 0000000..236dd40 --- /dev/null +++ b/web/src/assets/marks/amex-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/amex-select.svg b/web/src/assets/marks/amex-select.svg new file mode 100644 index 0000000..ccb37e6 --- /dev/null +++ b/web/src/assets/marks/amex-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/apple.svg b/web/src/assets/marks/apple.svg new file mode 100644 index 0000000..31b057a --- /dev/null +++ b/web/src/assets/marks/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/discover.svg b/web/src/assets/marks/discover.svg new file mode 100644 index 0000000..d55863a --- /dev/null +++ b/web/src/assets/marks/discover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/jcb.svg b/web/src/assets/marks/jcb.svg new file mode 100644 index 0000000..133310f --- /dev/null +++ b/web/src/assets/marks/jcb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/mc-classic.svg b/web/src/assets/marks/mc-classic.svg new file mode 100644 index 0000000..37a8330 --- /dev/null +++ b/web/src/assets/marks/mc-classic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/mc-color.svg b/web/src/assets/marks/mc-color.svg new file mode 100644 index 0000000..1aef221 --- /dev/null +++ b/web/src/assets/marks/mc-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/mc-silver-text.svg b/web/src/assets/marks/mc-silver-text.svg new file mode 100644 index 0000000..4aa4250 --- /dev/null +++ b/web/src/assets/marks/mc-silver-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/mc-silver.svg b/web/src/assets/marks/mc-silver.svg new file mode 100644 index 0000000..fb19c12 --- /dev/null +++ b/web/src/assets/marks/mc-silver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/mc-wordmark.svg b/web/src/assets/marks/mc-wordmark.svg new file mode 100644 index 0000000..c24ddcc --- /dev/null +++ b/web/src/assets/marks/mc-wordmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/up-color.svg b/web/src/assets/marks/up-color.svg new file mode 100644 index 0000000..8d51b91 --- /dev/null +++ b/web/src/assets/marks/up-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/up-english.svg b/web/src/assets/marks/up-english.svg new file mode 100644 index 0000000..8c92134 --- /dev/null +++ b/web/src/assets/marks/up-english.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/up-silver.svg b/web/src/assets/marks/up-silver.svg new file mode 100644 index 0000000..9d9dd36 --- /dev/null +++ b/web/src/assets/marks/up-silver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/up-white-frame.svg b/web/src/assets/marks/up-white-frame.svg new file mode 100644 index 0000000..4660669 --- /dev/null +++ b/web/src/assets/marks/up-white-frame.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/visa-auto.svg b/web/src/assets/marks/visa-auto.svg new file mode 100644 index 0000000..1f64047 --- /dev/null +++ b/web/src/assets/marks/visa-auto.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/visa-blue.svg b/web/src/assets/marks/visa-blue.svg new file mode 100644 index 0000000..9fe1811 --- /dev/null +++ b/web/src/assets/marks/visa-blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/visa-navy.svg b/web/src/assets/marks/visa-navy.svg new file mode 100644 index 0000000..739c9bb --- /dev/null +++ b/web/src/assets/marks/visa-navy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/marks/visa-silver.svg b/web/src/assets/marks/visa-silver.svg new file mode 100644 index 0000000..673abb0 --- /dev/null +++ b/web/src/assets/marks/visa-silver.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/card/marks.test.ts b/web/src/card/marks.test.ts new file mode 100644 index 0000000..651af51 --- /dev/null +++ b/web/src/card/marks.test.ts @@ -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(' { + for (const m of ALL) { + const root = /^]*>/.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('')).toBe(1); + expect(svgAspect('')).toBe(1); + }); + + it('svgAspect liest die viewBox', () => { + expect(svgAspect('')).toBeCloseTo(2, 1); + expect(svgAspect('')).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(); + 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'); + }); +}); diff --git a/web/src/card/marks.ts b/web/src/card/marks.ts new file mode 100644 index 0000000..d5377b1 --- /dev/null +++ b/web/src/card/marks.ts @@ -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; + /** 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; + +const NETWORK_TABLE: Record = { + '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, 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[] +).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): 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('>(); + +/** 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 { + const key = `${def.id}:${tone}`; + let pending = imageCache.get(key); + if (!pending) { + const source = withIntrinsicSize(tintSvg(def.svg, tone), def.maxH); + pending = new Promise((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; +}