docs: Plan Community-Logos, Englisch und GitHub-Link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 18:03:45 +00:00
1 parent 067e428340
commit 562e760191
2 files changed
+437 -1

No files matched your search

@@ -0,0 +1,436 @@
# 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:
```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(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:
```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.