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

437 lines
49 KiB
Markdown
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.
# 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**: Das Seitenverhältnis kommt aus der viewBox (ohne brauchbare viewBox: quadratisch, entspricht den 1000 × 1000 der Spec); gerastert wird immer mit genau 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:
```ts
// 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:
```js
{ 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:
```js
// 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:
```ts
// 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`.
### Task 5: Kartenmodell ohne Marken-Logos, Renderer mit Community-Logo
**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:
```ts
// 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:
```ts
// 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(aspect: number): { w: number; h: number }; // längste Seite genau 2048; ungültiges Seitenverhältnis → 2048 × 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(1)` → `{w:2048,h:2048}`; `(2)` → `{w:2048,h:1024}`; `(3)` → `{w:2048,h:683}`; `(0.5)` → `{w:1024,h:2048}`; `(NaN)`/`(0)` → `{w:2048,h:2048}`.
- `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: Text lesen, Seitenverhältnis mit `svgAspect` aus `card/marks.ts` (viewBox), `width`/`height` aus `svgRasterSize` ins Wurzelelement schreiben (wie `withIntrinsicSize` in `marks.ts`), dann über `Image` aus einer Object-URL laden (nur als Bild); 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:
```ts
// 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 – <Text>“ (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:
```ts
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 <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf „<Zahl>“ (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:
```ts
// 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: „<Name> · von <Anzeigename> · ♥ <Zahl>“ 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: „<n> 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.