Files
AirCardIOS/docs/superpowers/plans/2026-10-04-aircard-logos-sprache.md
T
2026-10-04 18:03:45 +00:00

49 KiB
Raw Blame History

AirCard-IOS Community-Logos, Englisch und GitHub-Link – Implementierungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Eingebaute Marken-Logos durch Community-Logos (Hochladen, Herzen, Melden) ersetzen, die Seite zweisprachig (DE/EN) machen und GitHub-Link + Sprachknopf in die Kopfzeile setzen.

Architecture: Der Vite-+-TS-Client bekommt ein kleines i18n-Modul im gettext-Stil (t('Deutscher Text'), Wörterbuch Deutsch → Englisch) und neue Module unter web/src/logos/. Gelesen wird direkt aus Appwrite (öffentliche Zeilen), geschrieben nur über den Node-Dienst aircard-api, der ein neues Modul deploy/api/logos.js bekommt; gemeinsame Helfer wandern nach deploy/api/common.js. Das Schema wächst über das bestehende Setup-Skript.

Tech Stack: wie bisher (TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom, appwrite 22.4.1), Node 22 nur mit Built-ins für aircard-api, Appwrite 1.8.1 TablesDB.

Spec: docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md (Commit 067e428). Basis: die Website- und Community-Specs im selben Ordner.

Global Constraints

  • Arbeitskopie /home/webklar/apps/aircard-ios.org/dev, Branch main, direkt committen, nicht pushen (Controller pusht).
  • Keine neuen npm-Abhängigkeiten; deploy/api/ bleibt ohne Abhängigkeiten. Versionen unverändert.
  • Kommentare und Bezeichner im Stil des Bestands (deutsche Kommentare, gleiche Idiome, el()/icon() aus editor/dom.ts).
  • Ab Task 1 gehen alle neuen sichtbaren Texte (Text, aria-label, title, Platzhalter, Meldungen) durch t(); Konstanten werden mit msg() markiert und beim Anzeigen mit t() übersetzt. Jeder neue Text bekommt seinen Eintrag in web/src/i18n/en.ts (kurz, freundlich, US-Englisch). Deutscher Wortlaut aus der Spec.
  • In Tests ist die Sprache Deutsch (web/src/test-setup.ts); bestehende Tests bleiben unverändert grün, außer wo der Task sie ausdrücklich anpasst.
  • Appwrite: Projekt 6ac2130a00289343d6d5, Datenbank aircard; neue Tabellen logos, logo_likes, logo_reports; neuer Bucket logo-images; Bestehendes unverändert.
  • Logo-Zeilenrechte: sichtbar genau ['read("any")'], ausgeblendet genau ['read("user:<owner>")','read("label:admin")']. Logo-Dateirechte: beim Hochladen ['read("any")','delete("user:<owner>")'], nach dem Anlegen genau ['read("any")'], ausgeblendet ['read("user:<owner>")','read("label:admin")']. Like-Zeilen: ID hash32("<logoId>|<userId>"), Rechte ['read("user:<userId>")'].
  • Grenzen: 30 Logos je Konto; Logo-PNG höchstens 1 MiB (1048576 Byte), 8 × 8 bis 720 × 250 px; Name ≤ 40, Anzeigename ≤ 30 Zeichen; Seiten zu 24; Ausblenden ab 3 Meldern; Rate-Limit 10 Meldungen/Stunde je Anschluss, gemeinsam für Karten und Logos.
  • localStorage: aircard.lang (de|en), aircard.displayName; jeder Zugriff in try/catch.
  • GitHub-Ziel https://github.com/Mak5er/AirCard-iOS (Konstante GITHUB_URL in web/src/config.ts).
  • Tests: cd web && npx vitest run, npx tsc --noEmit, npm run build; node --test "deploy/api/*.test.js" (Glob in Anführungszeichen); node --test web/scripts/appwrite-setup.test.mjs.
  • Keine Secrets in Ausgaben, Logs, Commits; .env nie öffnen (Ausnahme: Controller-Betrieb in Task 12).
  • Commits enden mit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Subagents: sonnet für Umsetzung und Review, haiku für kleine Nachprüfungen; nie Subagents aus Subagents.

Review Focus

  • Fremde oder falsche Datei beim Hochladen: fileId einer fremden Logo-Datei, einer Datei aus gallery-images oder einer schon veröffentlichten Logo-Datei an /api/logos/hochladen → 403, keine Zeile, Datei unverändert. Test in Task 3.
  • Gespeicherte Karte mit gelöschtem/ausgeblendetem Logo: Öffnen zeichnet die Karte ohne Logo, zeigt „Das gewählte Logo ist nicht mehr verfügbar.“, kein Fehler-Loop, Download funktioniert; Veröffentlichen bricht mit dem Text aus Spec § 2 ab. Tests in Task 5.
  • Sprachwechsel mit ungespeichertem Editor-Entwurf: Bild, Ausschnitt und Einstellungen bleiben nach dem Umschalten erhalten, die Seite wird neu aufgebaut, nicht neu geladen. Test in Task 1 (Router baut neu auf, Store bleibt) und Live in Task 12.
  • Herz doppelt geklickt / Gast bricht Login ab: Solange eine Herz-Anfrage für ein Logo läuft, sind weitere Klicks auf dieses Herz wirkungslos; bricht ein Gast den Login ab, geht keine Anfrage raus und das Herz bleibt aus, ohne Fehlertext. Tests in Task 8.
  • SVG ohne Größe oder mit riesiger viewBox: Ohne eigene Größe gilt 1000 × 1000; gerastert wird höchstens mit 2048 px an der längsten Seite, nie größer; Skripte im SVG laufen nie (Laden nur als Bild). Test in Task 6 (svgRasterSize).

Task 1: i18n-Kern, Kopfzeile (GitHub + Sprache), Fußzeile, Sprachwechsel ohne Neuladen

Files:

  • Create: web/src/i18n/i18n.ts, web/src/i18n/en.ts, web/src/i18n/i18n.test.ts, web/src/i18n/coverage.test.ts, web/src/test-setup.ts, web/src/shell/footer.ts, web/src/shell/footer.test.ts
  • Modify: web/vite.config.ts (test.setupFiles: ['src/test-setup.ts']), web/index.html, web/src/config.ts, web/src/editor/dom.ts (Icons github, globe), web/src/shell/header.ts, web/src/shell/shell.css, web/src/shell/donate-banner.ts, web/src/pages/router.ts, web/src/main.ts, web/src/branding.test.ts (falls es den Fußzeilentext oder <title> prüft)
  • Test: web/src/shell/header.test.ts, web/src/pages/router.test.ts

Interfaces:

  • Produces:

    // web/src/i18n/i18n.ts
    export type Lang = 'de' | 'en';
    export const LANG_KEY = 'aircard.lang';
    export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang;
    export function currentLang(): Lang;            // beim ersten Aufruf: initialLang(localStorage, navigator.languages bzw. [navigator.language])
    export function setLang(lang: Lang, opts?: { persist?: boolean }): void; // persist Standard true; setzt <html lang>; meldet nur bei Wechsel
    export function onLangChange(fn: (lang: Lang) => void): () => void;
    export function t(text: string, params?: Readonly<Record<string, string | number>>): string;
    export function msg<T extends string>(text: T): T; // Identität, markiert Konstanten für den Abdeckungstest
    // web/src/i18n/en.ts
    export const EN: Readonly<Record<string, string>>;   // Deutsch → Englisch
    // web/src/config.ts
    export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS';
    // web/src/pages/router.ts
    export function rerender(): void;                // räumt die aktuelle Seite auf und baut sie neu (ohne Scrollen); ohne Router: nichts
    // web/src/shell/footer.ts
    export function mountFooter(footer: HTMLElement): void; // ersetzt den Inhalt durch <p> mit dem Fußzeilentext der aktuellen Sprache
    
  • Step 1: Tests schreiben (i18n.test.ts, footer.test.ts, Ergänzungen in header.test.ts und router.test.ts):

    • initialLang(null, ['de-DE','en']) → 'de'; (null, ['en-US','de']) → 'en'; ('en', ['de-DE']) → 'en'; ('xx', ['de-AT']) → 'de'; (null, []) → 'en'.
    • setLang('en'): t('Gestalten') → 'Create'; unbekannter Text bleibt deutsch; t('Nichts gefunden für „{q}“', { q: 'x' }) ersetzt den Platzhalter in beiden Sprachen; unbekannter Platzhalter bleibt {name} stehen.
    • setLang setzt document.documentElement.lang, schreibt mit persist: false nichts in localStorage, ruft Listener genau einmal je echtem Wechsel.
    • Fußzeile: deutsch der Spec-Text (§ 7); nach setLang('en') und erneutem mountFooter der englische.
    • Kopfzeile: Link mit href = GITHUB_URL, target="_blank", rel="noopener noreferrer", title „AirCard-iOS auf GitHub“; Sprachknopf zeigt bei Deutsch „EN“ mit aria-label „Switch to English“ und lang="en", Klick → currentLang() === 'en'; danach (neu gemountet) „DE“ mit „Auf Deutsch umschalten“; das Menü enthält einen zweiten GitHub-Link (Klasse nav-github).
    • Router: rerender() ruft das Aufräumen der aktuellen Seite und dann render mit derselben Route erneut auf; scrollTo wird nicht aufgerufen; nach setLang('en') liefert titleFor({id:'gestalten',params:{}}) „Create – AirCard-IOS“.
  • Step 2: cd web && npx vitest run src/i18n src/shell src/pages/router.test.ts → FAIL (Module fehlen).

  • Step 3: Umsetzen.

    • t(): Basistext = bei en EN[text] ?? text, sonst text; dann {name} durch String(params[name]) ersetzen, wenn vorhanden.
    • coverage.test.ts (Muster wie single-sources.test.ts): sammelt in allen Nicht-Test-.ts unter src/ die String-Literale aus t(…)/msg(…) ('…', "…", `…` ohne ${) und prüft: (a) jedes hat einen Eintrag in EN, (b) jeder EN-Eintrag wird benutzt, (c) Platzhalter {…} in Schlüssel und Übersetzung sind dieselbe Menge, (d) kein t(/msg( mit Template-Literal, das ${ enthält.
    • test-setup.ts: beforeEach(() => setLang('de', { persist: false })).
    • index.html: <title>AirCard-IOS – Card designs for Apple Wallet</title>, <meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG.">; im bestehenden Inline-Skript zusätzlich lang setzen (gleiche Regel wie initialLang, Kommentar verweist darauf); <footer id="app-footer"></footer> leer (Text kommt aus mountFooter).
    • Icons in dom.ts (Lucide, Strich): github: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>', globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'.
    • Kopfzeile: Reihenfolge in .header-tools: GitHub (a.header-github mit Icon + span.header-github-text „GitHub“), Sprachknopf (button.header-btn.lang-btn), Spenden, Hell/Dunkel, Menü. CSS: zwischen 600 und 1179 px .header-github-text ausblenden (wie .header-donate-text); unter 600 px .header-github ausblenden und .nav-github im Menü zeigen (sonst .nav-github ausgeblendet). Alle übrigen Kopfzeilen- und Banner-Texte durch t()/msg().
    • Router: ROUTE_TITLES-Werte mit msg(), titleFor liefert t(…); rerender() ruft die interne renderCurrent auf.
    • main.ts: beim Start setLang(currentLang(), { persist: false }) und client.setLocale(lang); onLangChange(lang => …): client.setLocale(lang), Kopfzeile aufräumen und neu mounten, mountDonateBanner erneut, Konto-Menü dispose() + neu mounten (+ ready(), wenn der Anmeldestatus schon geholt war), mountFooter, dann rerender(). Der Editor-Store bleibt dasselbe Objekt.
    • Englische Einträge für alle neuen t()/msg()-Texte in en.ts (Seitentitel: „Card designs for Apple Wallet“, „Create“, „My cards“, „Community“, „Set new password“).
  • Step 4: cd web && npx vitest run && npx tsc --noEmit && npm run build → alles grün.

  • Step 5: Commit feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile.

Task 2: Schema für Logos (Setup-Skript)

Files:

  • Modify: web/scripts/appwrite-setup.mjs (DESIRED, Kopfkommentar), web/scripts/appwrite-setup.test.mjs

Interfaces:

  • Produces: DESIRED.tables zusätzlich (in dieser Reihenfolge nach reports) und DESIRED.buckets zusätzlich:

    { id: 'logos', name: 'Logos', permissions: [], rowSecurity: true,
      columns: [str('name', 40, true), str('displayName', 30, false), str('fileId', 36, true), str('ownerId', 36, true),
        { key: 'mono', type: 'boolean', required: false, default: false },
        { key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 },
        { key: 'hidden', type: 'boolean', required: false, default: false },
        { key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 }],
      indexes: [{ key: 'owner', type: 'key', columns: ['ownerId'] }, { key: 'hidden', type: 'key', columns: ['hidden'] },
        { key: 'likes', type: 'key', columns: ['likeCount'] }] },
    { id: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true,
      columns: [str('logoId', 36, true), str('userId', 36, true)],
      indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }] },
    { id: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false,
      columns: [str('logoId', 36, true),
        { key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
        str('reporterHash', 64, true)],
      indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }] },
    // Bucket
    { id: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 1048576,
      allowedFileExtensions: ['png'], compression: 'none', antivirus: false },
    
  • Step 1: Tests nach dem Muster der bestehenden: DESIRED enthält die drei Tabellen und den Bucket mit genau diesen Werten; ein Lauf gegen den Test-Server legt sie samt Indizes an (Mehrspalten-Index user_logo mit columns: ['userId','logoId'] im Request); --check meldet FEHLT für jedes fehlende neue Objekt und Exit 1; bestehende Tabellen bleiben unberührt.

  • Step 2: node --test web/scripts/appwrite-setup.test.mjs → FAIL.

  • Step 3: DESIRED ergänzen; Kopfkommentar nennt die neuen Objekte. Falls der Index-Request heute nur eine Spalte unterstützt, auf Listen erweitern.

  • Step 4: node --test web/scripts/appwrite-setup.test.mjs → PASS.

  • Step 5: Commit feat(appwrite): Schema für Community-Logos, Herzen und Logo-Meldungen.

Task 3: API – Fehlercodes, gemeinsame Helfer, Logo-Endpunkte

Files:

  • Create: deploy/api/common.js (aus app.js herausgelöst: MSG, HttpError, isId, isPlainObject, parseJsonObject, readImageSize, hash32, hmacHex, reporterHash, reporterNetwork, normalizeIp, clientIp, CONTROL_CHARACTERS, ID_PATTERN, isDuplicate, visibleRowPermissions, hiddenRowPermissions, uploadFilePermissions, visibleFilePermissions, hiddenFilePermissions, REASONS), deploy/api/logos.js, deploy/api/logos.test.js
  • Modify: deploy/api/app.js (importiert aus common.js, exportiert weiter alles wie bisher, hängt die Logo-Routen an, sendet code), deploy/api/appwrite.js (+ countRows), deploy/api/fake-appwrite.js (Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket logo-images, total in Listen-Antworten), deploy/api/app.test.js, deploy/api/appwrite.test.js, deploy/api/README.md, deploy/README.md (Betrieb kopiert fünf Dateien: appwrite.js, common.js, app.js, logos.js, server.js)

Interfaces:

  • Produces:

    // common.js
    class HttpError extends Error { constructor(status, message, headers = {}, code = codeFor(status, message)) } // .status .headers .code
    function codeFor(status, message) // Schlüssel aus MSG mit genau diesem Text, sonst status 400 → 'badRequest', sonst 'error'
    // appwrite.js
    countRows(table, queries = []) // GET rows mit queries + limit(1), liefert body.total (Zahl), sonst AppwriteError(502, 'invalid_response')
    // logos.js
    function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) // → Array<[routeKey, {name, json: true, run}]>
    function logoLikeId(logoId, userId) // hash32(`${logoId}|${userId}`)
    function logoReportId(logoId, reporter) // hash32(`logo|${logoId}|${reporter}`)
    function validateLogoUpload(body) // {ok:true, data:{name, displayName, mono, fileId}} | {ok:false, message}
    
  • Neue MSG-Einträge (Schlüssel = späterer code, Texte genau so): logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.', badLogoId: 'Ungültige Logo-ID.', logoNameMissing: 'Bitte gib einen Namen an.', logoFileType: 'Das Logo muss eine PNG-Datei sein.', logoFileSize: 'Das Logo ist größer als 1 MB.', logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', logoTaken: 'Diese Datei wird schon für ein Logo verwendet.', tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', badHeart: 'Ungültige Angabe zum Herz.', badMono: 'Ungültige Angabe zu „einfarbig“.'. Alle MSG-Texte bleiben paarweise verschieden.

  • Routen: POST /api/logos/hochladen, POST /api/logos/herz, POST /api/logos/melden, POST /api/logos/loeschen, POST /api/admin/logo-sichtbarkeit. Verhalten, Statuscodes, Reihenfolge der Schritte und Rücknahme genau wie Spec § 9 und die bestehenden Karten-Endpunkte (publish, reportCard, withdraw, setVisibility sind das Vorbild). Sperren: logo-owner:<userId> beim Hochladen, logo:<id> für alles je Logo. Gelöscht werden Herzen und Meldungen in Runden (listRows mit equal logoId, limit 100, höchstens 50 Runden). likeCount und reportCount immer aus countRows.

  • Step 1: Tests schreiben (logos.test.js im Stil von app.test.js; dort zusätzlich die code-Fälle):

    • Hochladen: gültig → 201 {id: fileId}, Zeile sichtbar mit likeCount 0, Datei-Rechte danach genau ['read("any")']; fremde Datei → 403 code:'foreignFile'; Datei aus gallery-images → 403; Datei ohne read("any") → 403 filesTaken; WebP → 400 logoFileType; PNG 721 × 100 oder 7 × 7 → 400 logoFormat; > 1 MiB → 400 logoFileSize; dieselbe Datei zweimal → 409 logoTaken; 31. Logo → 429 tooManyLogos; scheitert das Setzen der Dateirechte → Zeile gelöscht, Datei hat wieder delete("user:<ich>"); ohne JWT → 401 unauthorized; Name leer → 400 logoNameMissing; mono: 'ja' → 400 badMono.
    • Herz: an → {herz:true, likeCount:1}, nochmal an → weiter 1; aus → 0, nochmal aus → 0; zwei Nutzer → 2; ausgeblendetes oder fehlendes Logo → 404 logoGone; herz kein Boolean → 400 badHeart; Like-Zeile hat Rechte ['read("user:<id>")'] und ID logoLikeId.
    • Melden: Dedup je Netz ({schonGemeldet:true}), 3 Netze → hidden: true, Zeile/Datei mit ausgeblendeten Rechten; Rate-Limit greift auch, wenn 6 Karten- und 5 Logo-Meldungen vom selben Anschluss kommen (11. → 429).
    • Löschen: Hochlader → 200, Datei, Herzen, Meldungen, Zeile weg; Admin → 200; Fremder → 403 forbidden; fehlt → 404 logoGone.
    • Admin-Sichtbarkeit: ausblenden/einblenden (Einblenden: Meldungen weg, reportCount 0, Rechte sichtbar); Nicht-Admin → 403 adminOnly.
    • Bestehende Endpunkte: jede Fehlerantwort hat code (z. B. /api/melden mit unbekannter ID → {error:'Diese Karte ist nicht (mehr) verfügbar.', code:'cardGone'}); MSG-Texte sind eindeutig.
    • countRows in appwrite.test.js: liefert total, wirft bei fehlendem total.
  • Step 2: node --test "deploy/api/*.test.js" → FAIL.

  • Step 3: Umsetzen: common.js herauslösen (reine Verschiebung, Verhalten gleich), handle() sendet {error: err.message, code: err.code} (Appwrite-Fehler: code unavailable bzw. internal), Logo-Modul, Fake-Erweiterung, Doku (Endpunkt-Tabelle in api/README.md, Kopierbefehl mit fünf Dateien in deploy/README.md).

  • Step 4: node --test "deploy/api/*.test.js" → PASS (alte und neue Tests).

  • Step 5: Commit feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer.

Task 4: Client-Datenschicht für Logos und übersetzbare API-Fehler

Files:

  • Create: web/src/logos/logos.ts, web/src/logos/logos.test.ts, web/src/logos/logo-api.ts, web/src/logos/logo-api.test.ts, web/src/i18n/api-messages.ts, web/src/i18n/api-messages.test.ts
  • Modify: web/src/account/appwrite.ts (IDS), web/src/community/api.ts (+ Test)

Interfaces:

  • Consumes: t, msg, currentLang (Task 1); API-Routen und code (Task 3); fetchFile, ID_PATTERN aus account/cards.ts; apiPost, ApiError, isDefinitiveFailure aus community/api.ts.

  • Produces:

    // account/appwrite.ts – IDS zusätzlich
    logos: 'logos', logoLikes: 'logo_likes', logoReports: 'logo_reports', logoBucket: 'logo-images'
    // logos/logos.ts
    export interface CommunityLogo { id: string; name: string; displayName: string; fileId: string; ownerId: string;
      mono: boolean; likeCount: number; hidden: boolean; reportCount: number; createdAt: string }
    export type LogoSort = 'popular' | 'new';
    export type LogoFilter = 'all' | 'reported' | 'hidden';
    export interface LogoListOptions { sort: LogoSort; filter?: LogoFilter; cursor?: string }
    export interface LogoReport { id: string; logoId: string; reason: string; createdAt: string }
    export const LOGO_PAGE_SIZE = 24;
    export function toLogo(row: Record<string, unknown>): CommunityLogo;
    export function logoQueries(opts: LogoListOptions): string[];
    export function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }>;
    export function getLogo(id: string): Promise<CommunityLogo | null>;       // ungültige ID oder 404 → null
    export function myHearts(userId: string, logoIds: readonly string[]): Promise<Set<string>>;
    export function listLogoReports(logoId: string): Promise<LogoReport[]>;   // neueste zuerst, limit 100
    export function loadLogoBlob(logo: CommunityLogo): Promise<Blob>;         // fetchFile(IDS.logoBucket, logo.fileId)
    // logos/logo-api.ts
    export interface LogoUploadInput { name: string; displayName: string; mono: boolean; fileId: string }
    export function uploadLogo(input: LogoUploadInput): Promise<{ id: string }>;
    export function setHeart(id: string, herz: boolean): Promise<{ herz: boolean; likeCount: number }>;
    export function reportLogo(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
    export function deleteLogo(id: string): Promise<void>;
    export function setLogoVisibility(id: string, visible: boolean): Promise<void>; // Body {id, sichtbar}
    // community/api.ts
    class ApiError { constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null); readonly serverCode: string | null }
    export function apiErrorText(err: ApiError): string; // = apiMessage(err.serverCode, err.code, err.message)
    // i18n/api-messages.ts
    export const API_MESSAGES: Readonly<Record<string, string>>; // code → msg('deutscher Servertext'); dazu Client-Codes 'generic', 'jwtUnsupported'
    export function apiMessage(serverCode: string | null, status: number, serverText: string): string;
    
  • Step 1: Tests schreiben:

    • logoQueries({sort:'popular'}) = [equal('hidden',false), orderDesc('likeCount'), orderDesc('$createdAt'), limit(24)] (als Query-Strings des SDK); 'new' ohne likeCount-Sortierung; filter:'reported' → greaterThan('reportCount',0), 'hidden' → equal('hidden',true); mit cursor zusätzlich cursorAfter.
    • toLogo macht aus kaputten Feldern leere Werte, likeCount/reportCount < 0 oder kein Zahlwert → 0, mono/hidden nur bei true.
    • listLogos: nextCursor nur bei voller Seite; getLogo('kaputt id') → null ohne Anfrage, 404 → null, 500 → wirft.
    • myHearts('u', []) → leere Menge ohne Anfrage; sonst Abfrage equal('userId','u'), equal('logoId',[…]), limit(100).
    • logo-api: richtige Pfade und Bodies (/api/logos/herz mit {id, herz}, /api/admin/logo-sichtbarkeit mit {id, sichtbar}); Antwort ohne id bei uploadLogo → ApiError 503, nicht eindeutig.
    • ApiError.serverCode wird aus {error, code} gelesen; Client-Fehler tragen unavailable, generic, jwtUnsupported, alreadyPublished, filesTaken, tooManyPublications passend.
    • apiMessage: bekannter Code → t(Text) (deutsch und englisch); unbekannter Code deutsch → Servertext; unbekannter Code englisch → „Invalid request.“ (400), „You are not allowed to do that.“ (403), „Not found.“ (404), sonst „Something went wrong. Please try again.“
    • Gleichlauf: api-messages.test.ts lädt MSG aus deploy/api/common.js (createRequire(import.meta.url)) und prüft für jeden Schlüssel API_MESSAGES[key] === MSG[key].
  • Step 2: cd web && npx vitest run src/logos src/i18n src/community/api.test.ts → FAIL.

  • Step 3: Umsetzen nach dem Muster von community/gallery.ts und community/api.ts; en.ts um alle API_MESSAGES-Texte ergänzen.

  • Step 4: cd web && npx vitest run && npx tsc --noEmit → grün.

  • Step 5: Commit feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler.

Files:

  • Create: web/src/logos/logo-asset.ts, web/src/logos/logo-asset.test.ts
  • Modify: web/src/card/config.ts, web/src/card/marks.ts, web/src/card/geometry.ts, web/src/card/render.ts (+ ihre Tests), web/src/editor/options-panel.ts, web/src/editor/editor-page.ts (+ Test), web/src/community/publish.ts (+ Test), web/src/branding.test.ts, web/src/single-sources.test.ts (falls betroffen)
  • Delete: in web/src/assets/marks/ alle SVGs außer aircards-symbol.svg und aircards-text.svg (19 Dateien, git rm)

Interfaces:

  • Consumes: getLogo, loadLogoBlob, CommunityLogo (Task 4); t, msg (Task 1).

  • Produces:

    // card/config.ts
    export type BrandId = 'aircards-symbol' | 'aircards-text' | 'none';
    export interface CardConfig { v: 1; template: TemplateId; logo: string | null; details: DetailsTone; brand: BrandId; crop: Crop }
    // card/geometry.ts (ersetzt fitNetwork und Layout.network)
    export const LOGO_MAX_H = 125;
    export function fitLogo(aspect: number, maxH?: number): Box; // rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch
    // Layout.logo: boolean   (image: false; minimal, classic, signature: true)
    // card/render.ts
    export interface LogoAsset { source: CanvasImageSource; w: number; h: number }
    export interface CardAssets { image: CardImage | null; logo: LogoAsset | null; brand: CanvasImageSource | null }
    export interface LoadedAssets extends CardAssets { logoMissing: boolean }
    export function loadCardAssets(config: CardConfig, image: CardImage | null,
      loadLogo?: (id: string, tone: DetailsTone) => Promise<LogoAsset | null>): Promise<LoadedAssets>; // Standard: loadLogoAsset
    // logos/logo-asset.ts
    export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
    export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null>;
    export function rememberLogo(logo: CommunityLogo): void; // legt die Zeile in den Cache (spart getLogo)
    export function forgetLogo(id: string): void;            // nach Löschen/Ausblenden: Zeile und Bilder aus dem Cache
    // editor/options-panel.ts
    export interface OptionsHandlers { onDetails(tone: DetailsTone): void; onBrand(id: BrandId): void }
    export interface OptionsPanel { el: HTMLElement; select(config: CardConfig): void; logoSlot: HTMLElement } // logoSlot: leerer Platz oben, Task 8 füllt ihn
    // community/publish.ts
    export const LOGO_MISSING_PUBLISH = msg('Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.');
    
  • Step 1: Tests schreiben:

    • parseConfig('{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}') → logo: null, brand: 'aircards-symbol', Rest übernommen; logo: 'abc123' bleibt; logo: 'kaputt id'/Zahl → null; serializeConfig schreibt genau {v,template,logo,details,brand,crop} in dieser Reihenfolge.
    • fitLogo(2, 125) → {x: 1230, y: 788, w: 250, h: 125}; fitLogo(10, 125) → Breite 360.
    • Renderer: mit assets.logo (w 200, h 100) und Vorlage classic wird drawImage(source, fitLogo(2).x, …) aufgerufen; mit image nicht; ohne assets.logo nicht.
    • loadCardAssets: logo: null → logoMissing false ohne Loader-Aufruf; Loader liefert null → logo null, logoMissing true; Loader wirft → loadCardAssets wirft.
    • loadLogoAsset (Fakes für getLogo, loadLogoBlob, Bilddekodierung und Canvas über Parameter oder Modul-Mocks): fehlende Zeile oder 404-Datei → null; einfarbig → Canvas mit globalCompositeOperation 'source-in' und fillStyle #FFFFFF (light) bzw. #111111 (dark); gleiche (id, tone) lädt nur einmal; nach forgetLogo wird neu geladen; ein fehlgeschlagener Ladevorgang bleibt nicht im Cache.
    • Editor: gespeicherte Karte mit Logo, Loader liefert null → Vorschau-Warnung LOGO_MISSING, Karte wird gezeichnet; Wechsel auf ein vorhandenes Logo → Warnung weg; kein LOGO_ERROR bei fehlendem Logo (nur bei echtem Ladefehler).
    • Veröffentlichen: render mit logoMissing → Fehler mit Text LOGO_MISSING_PUBLISH, keine Datei hochgeladen.
    • Options-Panel: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit drei Kacheln (Symbol, Text, Kein Logo), kein Apple.
  • Step 2: cd web && npx vitest run src/card src/logos src/editor src/community/publish.test.ts → FAIL.

  • Step 3: Umsetzen. logoKey im Editor = `${c.logo ?? ''}|${c.details}|${c.brand}`; der Logo-Zustand hält zusätzlich missing. Bilddekodierung in logo-asset.ts: createImageBitmap(blob), sonst Image über Object-URL; einfarbig = eigenes Canvas in Bildgröße, Bild zeichnen, source-in, mit TONE_COLOR[tone] füllen. logo-asset.ts importiert LogoAsset nur als import type aus card/render.ts (kein Laufzeit-Kreis). marks.ts behält nur die AirCard-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Neue Texte in en.ts.

  • Step 4: cd web && npx vitest run && npx tsc --noEmit && npm run build → grün; grep -rn "visa\|mastercard\|unionpay\|amex\|discover\|jcb\|apple.svg" web/src findet nur noch Kommentare/Tests ohne Dateibezug oder nichts.

  • Step 5: Commit feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer.

Task 6: Logo verarbeiten und hochladen (Ablauf ohne Oberfläche)

Files:

  • Create: web/src/logos/prepare-logo.ts, web/src/logos/prepare-logo.test.ts, web/src/logos/upload-logo.ts, web/src/logos/upload-logo.test.ts

Interfaces:

  • Consumes: uploadLogo, LogoUploadInput, CommunityLogo (Task 4); isDefinitiveFailure aus community/api.ts; storage, IDS aus account/appwrite.ts; requireUserId aus account/cards.ts.

  • Produces:

    // logos/prepare-logo.ts
    export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg';
    export const LOGO_MAX_W = 720, LOGO_MAX_H = 250, LOGO_MIN = 8, ALPHA_EMPTY = 8, SVG_FALLBACK_SIZE = 1000, SVG_RASTER_MAX = 2048;
    export const LOGO_SOURCE_MAX_BYTES = 5_242_880, LOGO_SOURCE_MAX_PIXELS = 40_000_000;
    export interface PreparedLogo { png: Blob; width: number; height: number; hasTransparency: boolean }
    export function validateLogoFile(f: { type: string; size: number; name: string }): { ok: true } | { ok: false; message: string };
    export function trimBox(data: Uint8ClampedArray, w: number, h: number, threshold?: number): { x: number; y: number; w: number; h: number } | null;
    export function fitWithin(w: number, h: number, maxW: number, maxH: number): { w: number; h: number }; // nie > 1, gerundet, mind. 1
    export function svgRasterSize(naturalW: number, naturalH: number): { w: number; h: number }; // 0 → 1000×1000; längste Seite ≤ 2048
    export function hasTransparency(data: Uint8ClampedArray): boolean;
    export function prepareLogo(file: File): Promise<PreparedLogo>; // wirft Error mit übersetztem Text (Spec § 4/§ 11)
    // logos/upload-logo.ts
    export interface LogoForm { name: string; displayName: string; mono: boolean }
    export interface UploadLogoDeps { userId(): string; createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
      deleteFile(fileId: string): Promise<void>; uploadLogo(input: LogoUploadInput): Promise<{ id: string }> }
    export function uploadPreparedLogo(prepared: PreparedLogo, form: LogoForm, deps?: UploadLogoDeps): Promise<CommunityLogo>;
    
  • Step 1: Tests schreiben:

    • validateLogoFile: image/gif → „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“; leerer Typ + Name logo.SVG → ok; 5 242 881 Byte → „Die Datei ist größer als 5 MB.“
    • trimBox: 4 × 3-Bild nur mit einem Pixel (2,1) Alpha 9 → {x:2,y:1,w:1,h:1}; Alpha ≤ 8 überall → null; volles Bild → ganze Fläche.
    • fitWithin(1440, 500, 720, 250) → {w:720,h:250}; (300, 100, 720, 250) → unverändert; (2000, 20, 720, 250) → {w:720,h:7}.
    • svgRasterSize(0, 0) → {w:1000,h:1000}; (100000, 50000) → {w:2048,h:1024}; (300, 100) → 2048 × 683 (gerundet).
    • hasTransparency: alles 255 → false; ein Alpha 254 → true.
    • uploadPreparedLogo: createFile mit Dateiname logo.png, Typ image/png, Rechten ['read("any")','delete("user:u1")']; uploadLogo bekommt getrimmten Namen/Anzeigenamen, mono nur true, wenn hasTransparency; Erfolg → CommunityLogo mit id, fileId = id, ownerId u1, likeCount 0, keine Löschung; eindeutige Ablehnung (403) → Datei gelöscht, Fehler weitergeworfen; offener Ausgang (503) → Datei bleibt, Fehler weitergeworfen; Fehler beim Löschen überdeckt nie den eigentlichen Fehler.
  • Step 2: cd web && npx vitest run src/logos/prepare-logo.test.ts src/logos/upload-logo.test.ts → FAIL.

  • Step 3: Umsetzen. prepareLogo: Rasterbilder mit createImageBitmap(file) (Maße vor dem Zeichnen gegen LOGO_SOURCE_MAX_PIXELS prüfen, sonst „Das Bild ist zu groß (max. 40 Megapixel).“), SVG über Image aus Object-URL (nur als Bild, Größe aus svgRasterSize); zeichnen, getImageData, trimBox (null → „Das Logo ist leer.“), ausschneiden, fitWithin(…, 720, 250), unter 8 × 8 → „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“, toBlob('image/png'), hasTransparency auf den Endpixeln; nicht lesbar → „Das Logo konnte nicht gelesen werden.“ Neue Texte in en.ts. (prepareLogo selbst prüft Task 12 live; die reinen Helfer tragen die Tests.)

  • Step 4: cd web && npx vitest run && npx tsc --noEmit → grün.

  • Step 5: Commit feat(logos): Logo zuschneiden, verkleinern und hochladen.

Task 7: Dialog „Logo hochladen“

Files:

  • Create: web/src/logos/logo-dialog.ts, web/src/logos/logo-dialog.test.ts, web/src/logos/logos.css, web/src/account/display-name.ts, web/src/account/display-name.test.ts
  • Modify: web/src/community/publish-dialog.ts (+ Test: Vorbelegung und Speichern des Anzeigenamens)

Interfaces:

  • Consumes: prepareLogo, PreparedLogo, validateLogoFile, LOGO_ACCEPT, uploadPreparedLogo, LogoForm (Task 6); CommunityLogo (Task 4); apiErrorText, ApiError (Task 4); errorMessage aus account/errors.ts.

  • Produces:

    // account/display-name.ts
    export const DISPLAY_NAME_KEY = 'aircard.displayName';
    export function rememberedDisplayName(): string;          // '' bei Fehler/leer
    export function rememberDisplayName(name: string): void;  // leer → Schlüssel entfernen
    // logos/logo-dialog.ts
    export interface LogoDialogOptions { signal?: AbortSignal;
      prepare?: (file: File) => Promise<PreparedLogo>; upload?: (p: PreparedLogo, form: LogoForm) => Promise<CommunityLogo> }
    export function validateLogoForm(v: { hasFile: boolean; name: string; displayName: string; consent: boolean }):
      { field: 'file' | 'name' | 'displayName' | 'consent'; message: string } | null;
    export function logoUploadDialog(opts?: LogoDialogOptions): Promise<CommunityLogo | null>; // null = abgebrochen
    
  • Step 1: Tests schreiben:

    • validateLogoForm: ohne Datei → file „Bitte wähle eine Datei.“; Name leer → „Bitte gib einen Namen an.“; 41 Zeichen → „Der Name ist zu lang (höchstens 40 Zeichen).“; Steuerzeichen → „Der Name enthält ungültige Zeichen.“; Anzeigename 31 → „Der Anzeigename ist zu lang (höchstens 30 Zeichen).“; ohne Häkchen → „Bitte bestätige, dass du das Logo teilen darfst.“
    • Dialog: Datei wählen ruft prepare, zeigt zwei Vorschauen (dunkel/hell); Name wird mit dem Dateinamen ohne Endung vorbelegt (gekürzt auf 40); Anzeigename aus rememberedDisplayName(); „Einfarbig“ ist deaktiviert, wenn hasTransparency false; Absenden mit gültigen Werten ruft upload genau einmal (Doppelklick sendet nicht zweimal), speichert den Anzeigenamen und löst mit dem Logo auf; Fehler → „Hochladen fehlgeschlagen – “ (bei ApiError über apiErrorText), Dialog bleibt offen; während des Sendens sind Abbrechen/Esc wirkungslos; signal schließt immer (→ null).
    • Veröffentlichen-Dialog: Anzeigename vorbelegt aus aircard.displayName, nach Erfolg gespeichert.
  • Step 2: cd web && npx vitest run src/logos/logo-dialog.test.ts src/account/display-name.test.ts src/community/publish-dialog.test.ts → FAIL.

  • Step 3: Umsetzen nach dem Muster von publish-dialog.ts (eigenes <dialog>, showModal, Fokus zurück an den Auslöser, Texte nur als Text). Vorschau: PNG als Object-URL in <img>; bei „Einfarbig“ stattdessen ein Element mit mask-image/-webkit-mask-image auf dieselbe URL und Hintergrundfarbe Weiß (dunkle Fläche) bzw. #111111 (helle Fläche). Object-URLs beim Schließen freigeben. Pflicht-Häkchen-Text und alle Texte aus Spec § 4/§ 11; neue Texte in en.ts.

  • Step 4: cd web && npx vitest run && npx tsc --noEmit → grün.

  • Step 5: Commit feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename.

Task 8: Editor – Bereich „Logo unten rechts“ mit Raster, Sortierung, Auswahl und Herzen

Files:

  • Create: web/src/logos/logo-picker.ts, web/src/logos/logo-picker.test.ts
  • Modify: web/src/logos/logos.css, web/src/editor/dom.ts (Icons heart, plus), web/src/editor/editor-page.ts (+ Test), web/src/editor/options-panel.ts (nur falls logoSlot angepasst werden muss)

Interfaces:

  • Consumes: Tasks 4–7 (listLogos, getLogo, myHearts, loadLogoBlob, setHeart, rememberLogo, logoUploadDialog, CommunityLogo, LogoSort, LogoFilter); currentUser, currentUserId, isAdmin, onAuthChange aus account/auth.ts; requireLogin aus account/login-dialog.ts.

  • Produces:

    export interface LogoPickerDeps { listLogos; getLogo; myHearts; setHeart; loadLogoBlob;   // Typen wie in Task 4
      userId(): string | null; isAdmin(): boolean; requireLogin(): Promise<boolean>;
      onAuthChange(fn: () => void): () => void; uploadDialog(): Promise<CommunityLogo | null> }
    export interface LogoPicker {
      el: HTMLElement;
      select(logoId: string | null): void;  // markiert die Auswahl, holt ein nicht geladenes Logo per getLogo und heftet es an
      /** Die Zeile des gewählten Logos, sobald bekannt (für die Infozeile in Task 9); null = kein Logo oder unbekannt. */
      selected(): CommunityLogo | null;
      dispose(): void;
    }
    export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps?: LogoPickerDeps): LogoPicker;
    
  • Icons: heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>', plus: '<path d="M5 12h14"/><path d="M12 5v14"/>'.

  • Step 1: Tests schreiben (alles mit Fake-Deps):

    • Aufbau: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“, Satz „Logos werden von der Community hochgeladen.“, Umschalter „Beliebt“ (gedrückt) / „Neu“; erste Kachel „Kein Logo“; je Logo eine Kachel mit Name, „von “ bzw. „von Anonym“ und Herz-Knopf „“ (eigener button, nicht im Auswahlknopf).
    • Sortierung: Klick auf „Neu“ lädt mit sort:'new' neu; Admin sieht zusätzlich „Gemeldet“/„Ausgeblendet“ (filter), Nicht-Admin nicht.
    • „Mehr laden“ erscheint nur bei nextCursor und hängt die nächste Seite an.
    • Auswahl: Klick auf eine Kachel ruft onSelect(id); „Kein Logo“ → onSelect(null); select('x') mit nicht geladenem Logo → getLogo('x') und Kachel direkt nach „Kein Logo“; aria-pressed folgt der Auswahl.
    • Herzen: angemeldet → Klick schaltet sofort (aria-pressed, Zahl ± 1), ruft setHeart(id, true), übernimmt likeCount der Antwort; Fehler → Rücksprung + Text im Statusbereich (role="alert"); zweiter Klick während laufender Anfrage → kein zweiter Aufruf; Gast → requireLogin(); false → kein setHeart, Herz aus, kein Fehlertext; true → setHeart(id, true); nach dem Laden einer Seite (angemeldet) myHearts mit genau den IDs der Seite; Abmelden → alle Herzen aus.
    • Leer → „Noch keine Community-Logos – lade das erste hoch.“; Ladefehler → „Die Logos konnten nicht geladen werden.“ + „Nochmal versuchen“ (lädt neu).
    • Hochladen: Gast → zuerst requireLogin(); danach uploadDialog(); Ergebnis-Logo steht als erste Logo-Kachel, rememberLogo und onSelect(id) werden gerufen.
    • Editor: Picker sitzt in options.logoSlot; Auswahl setzt config.logo; sync ruft picker.select(config.logo); Seite verlassen ruft dispose.
  • Step 2: cd web && npx vitest run src/logos/logo-picker.test.ts src/editor/editor-page.test.ts → FAIL.

  • Step 3: Umsetzen. Kachelbild: Object-URL aus loadLogoBlob; mehrfarbig <img>, einfarbig Element mit mask-image in Weiß; Bildfläche immer auf dunklem Kartengrund #1b2738 (beide Themes, wie bisher .mark-img im hellen Theme). Raster 2 Spalten, unter 900 px repeat(auto-fill, minmax(140px, 1fr)). Herz gefüllt, wenn gedrückt (fill: currentColor). Object-URLs bei dispose freigeben. Alle Texte mit t(), Einträge in en.ts.

  • Step 4: cd web && npx vitest run && npx tsc --noEmit && npm run build → grün.

  • Step 5: Commit feat(editor): Community-Logos unten rechts mit Herzen und Sortierung.

Task 9: Editor – Infozeile, Melden, Löschen und Admin für Logos

Files:

  • Modify: web/src/logos/logo-picker.ts (+ Test), web/src/logos/logos.css, web/src/community/report-dialog.ts (+ Test)

Interfaces:

  • Consumes: reportLogo, deleteLogo, setLogoVisibility, listLogoReports (Task 4); forgetLogo (Task 5); confirmDialog aus editor/confirm-dialog.ts; apiErrorText (Task 4).

  • Produces:

    // community/report-dialog.ts – Erweiterung, Standard bleibt die Karte
    export interface ReportDialogOptions { send; cardTitle?; signal?; subject?: 'card' | 'logo' }
    // logo: Gründe ohne „Echte Kartendaten“; Danke-Text bei Duplikat „Danke, du hast dieses Logo schon gemeldet.“; Einleitung nennt „dieses Logo“
    // LogoPickerDeps zusätzlich: reportLogo, deleteLogo, setLogoVisibility, listLogoReports, confirm(opts): Promise<boolean>, reportDialog(opts): Promise<ReportOutcome>
    
  • Step 1: Tests schreiben:

    • Infozeile für das gewählte Logo: „ · von · ♥ “ und „Melden“ (auch für Gäste); ohne Auswahl leer; gewähltes Logo nicht auffindbar → „Das gewählte Logo ist nicht mehr verfügbar.“
    • Melden öffnet den Dialog mit subject:'logo' und sendet reportLogo(id, reason); der Logo-Dialog zeigt keine Option „Echte Kartendaten“.
    • Hochlader (ownerId === userId()): „Löschen“ → Rückfrage; bestätigt → deleteLogo(id), forgetLogo(id), Kachel weg, onSelect(null); abgelehnt → nichts; Fehler → Text im Statusbereich.
    • Admin: „ Meldungen“ (listLogoReports, Gründe als Text), „Ausblenden“ bzw. bei hidden „Einblenden“ → setLogoVisibility(id, false|true) und Anzeige aktualisiert, „Löschen“ wie oben; Nicht-Admin und Nicht-Hochlader sehen weder Löschen noch Admin-Knöpfe.
    • Karten-Meldedialog unverändert (bestehende Tests grün).
  • Step 2: cd web && npx vitest run src/logos/logo-picker.test.ts src/community/report-dialog.test.ts → FAIL.

  • Step 3: Umsetzen; Texte mit t(), Einträge in en.ts.

  • Step 4: cd web && npx vitest run && npx tsc --noEmit → grün.

  • Step 5: Commit feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen.

Task 10: Übersetzung des Bestands, Teil A – Konto, Editor, Karte, Passwort-Seite

Files:

  • Modify: web/src/account/*.ts (ohne Tests), web/src/editor/*.ts (ohne Tests), web/src/card/*.ts (ohne Tests), web/src/pages/reset-page.ts, web/src/i18n/en.ts; Tests nur, wo ein Test einen jetzt mit msg() markierten Export anders erwartet

Interfaces:

  • Consumes: t, msg (Task 1). Exportierte Text-Konstanten bleiben deutsche Strings mit gleichem Namen (jetzt msg('…')), damit Vergleiche und Tests weiter funktionieren; übersetzt wird an der Stelle der Anzeige mit t(KONSTANTE).

  • Step 1: Test schreiben in web/src/i18n/coverage.test.ts: Für die Dateien dieses Tasks (account/, editor/, card/, pages/reset-page.ts) steht kein deutsches Text-Literal mehr außerhalb von t()/msg() – Heuristik: String-Literale mit Umlaut/ß oder mit mindestens zwei Wörtern aus Kleinbuchstaben, ausgenommen Kommentare, Import-Pfade, CSS-Klassen, data-/aria--Attributnamen, Appwrite-Fehlertypen und console.*-Aufrufe; eine kleine Ausnahmeliste im Test ist erlaubt (je Eintrag mit Grund).

  • Step 2: cd web && npx vitest run src/i18n/coverage.test.ts → FAIL (zeigt die noch offenen Texte).

  • Step 3: Alle sichtbaren Texte der genannten Dateien durch t()/msg(); englische Einträge in en.ts. Seitentitel/aria-label/Platzhalter/title eingeschlossen. Fehlermeldungen in errors.ts werden beim Zurückgeben übersetzt.

  • Step 4: cd web && npx vitest run && npx tsc --noEmit && npm run build → grün.

  • Step 5: Commit feat(i18n): Konto, Editor und Karte auf Englisch.

Task 11: Übersetzung des Bestands, Teil B – Community, Startseite, Detailseite, Meine Karten, API-Fehler

Files:

  • Modify: web/src/community/*.ts (ohne Tests), web/src/pages/home-page.ts, web/src/pages/community-page.ts, web/src/pages/cards-page.ts, web/src/pages/format.ts (+ format.test.ts), web/src/i18n/en.ts, web/src/i18n/coverage.test.ts (Heuristik aus Task 10 auf diese Dateien und shell/, logos/ ausweiten)

Interfaces:

  • Consumes: t, msg, currentLang (Task 1); apiErrorText (Task 4).

  • Produces: formatDate(iso) – Deutsch dd.mm.yyyy (wie bisher), Englisch Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) („Oct 4, 2026“); reportsLabel(count) übersetzt („1 report“, „3 reports“).

  • Step 1: Tests schreiben: formatDate('2026-10-04T12:00:00Z') → 04.10.2026 (de) und Oct 4, 2026 (en); reportsLabel(1)/(3) in beiden Sprachen; Anzeige eines ApiError mit serverCode:'alreadyPublished' im Veröffentlichen-Dialog bei Englisch → „Publishing failed – This card is already published.“ (Wortlaut aus en.ts); Heuristik-Test aus Task 10 deckt jetzt auch diese Dateien ab.

  • Step 2: cd web && npx vitest run src/pages/format.test.ts src/i18n src/community → FAIL.

  • Step 3: Alle sichtbaren Texte durch t()/msg(); jede Anzeige eines ApiError-Textes (err.message) durch apiErrorText(err) ersetzen (Logik, die err.message vergleicht, bleibt beim deutschen Text); englische Einträge in en.ts.

  • Step 4: cd web && npx vitest run && npx tsc --noEmit && npm run build → grün.

  • Step 5: Commit feat(i18n): Community, Startseite und Meine Karten auf Englisch.

Task 12: Betrieb und Abnahme (Controller)

  • Schema: cd /home/webklar/apps/aircard-ios.org/dev && node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check (erwartet FEHLT für die neuen Objekte) → ohne --check anlegen → --check Exit 0.
  • API: Backup aircard-ios.org/api/ → die fünf Dateien kopieren → cd /home/webklar/apps && docker compose restart aircard-api → curl -s https://aircard-ios.org/api/gesundheit = {"ok":true}; curl auf /api/logos/melden mit unbekannter ID → 404 mit code:"logoGone"; /api/logos/herz ohne JWT → 401 code:"unauthorized".
  • Web: Push main → Webhook-Build → neue Bundles live (Hash im HTML geändert).
  • Live-Abnahme (Claude in Chrome in Kensos Sitzung; Konten/Passwörter gibt nur Kenso ein): Logo hochladen (PNG transparent + einfarbig, SVG, JPG) → Kachel, Karte, Download-PNG enthält Logo; Gast (zweiter Tab ohne Sitzung bzw. curl auf die Tabelle) sieht das Logo; Herz an/aus, Zahl und Sortierung „Beliebt“; Melden aus 3 Netzen (per docker exec webklar-aircard-api gegen 127.0.0.1:8088 mit X-Real-Ip) → ausgeblendet; Löschen; Sprachwechsel mit offenem Entwurf (bleibt), englische Startsprache bei englischem Browser, <html lang>, Titel; GitHub-Link; 1440 × 900 und 390 × 844, Hell/Dunkel; keine Marken-Logos mehr im Editor.
  • Abschluss: Whole-Branch-Review (ein sonnet-Reviewer), eine Fix-Welle nur für Critical/Important, Push, README.md (Funktionsliste, Sprachen, Logos), Memory aktualisieren, Bericht an Kenso.