feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer

- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
  sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
  wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
  ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
  der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
  loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
  Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
  createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
  gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
  Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
  rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
  Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
  ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
  auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
  fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
  es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
  logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
  Vitest 45 Dateien, 1319 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 20:32:45 +00:00
1 parent 3051b3b3f9
commit 0be5abd954
44 files changed
+1401 -366

No files matched your search

+93 -2
View File
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config';
import type { SavedCard } from '../account/cards';
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
const mocks = vi.hoisted(() => ({
createJWT: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
}));
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
@@ -14,9 +19,22 @@ vi.mock('../account/appwrite', () => ({
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
}));
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
vi.mock('../editor/upload', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/upload')>()),
decodeImage: mocks.decodeImage,
}));
import { ApiError, publishCard } from './api';
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish';
import {
LOGO_MISSING_PUBLISH,
PNG_MAX_BYTES,
normalizeTags,
publishSavedCard,
renderForGallery,
type PublishDeps,
} from './publish';
const card: SavedCard = {
id: 'card1',
@@ -242,6 +260,79 @@ describe('publishSavedCard', () => {
});
});
describe('Logo der Karte beim Veröffentlichen', () => {
const photo = { source: {}, w: 3000, h: 2000 };
const source = new Blob(['src'], { type: 'image/jpeg' });
const withLogo = (logo: string | null): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, logo } });
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
beforeEach(() => {
mocks.decodeImage.mockReset().mockResolvedValue(photo);
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
mocks.exportCard
.mockReset()
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
});
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
const { t, setLang } = await import('../i18n/i18n');
expect(LOGO_MISSING_PUBLISH).toBe(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
try {
setLang('en', { persist: false });
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
} finally {
setLang('de', { persist: false });
}
});
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
const out = await renderForGallery(withLogo('abc123'), source);
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
});
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
const out = await renderForGallery(withLogo(null), source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
expect(out.png.type).toBe('image/png');
});
it('fehlendes Logo (gelöscht oder ausgeblendet): Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
await expect(renderForGallery(withLogo('gone'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.exportCard).not.toHaveBeenCalled();
});
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
const failure = new TypeError('Failed to fetch');
mocks.loadCardAssets.mockRejectedValue(failure);
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
});
it('publishSavedCard: mit fehlendem Logo wird nichts hochgeladen, nichts gelöscht und die API nicht gerufen', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(fake.names()).toEqual(['userId', 'loadImage']);
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
});
});
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });