diff --git a/docs/superpowers/plans/2026-10-04-aircard-community.md b/docs/superpowers/plans/2026-10-04-aircard-community.md
index e0aad86..b9bc117 100644
--- a/docs/superpowers/plans/2026-10-04-aircard-community.md
+++ b/docs/superpowers/plans/2026-10-04-aircard-community.md
@@ -72,6 +72,8 @@
- Kopfzeile: Logo `/favicon.svg?v=2` + „AirCard-IOS“ (→ `/`), Links „Community“ (`/#community`), „Gestalten“ (`/gestalten`), „Meine Karten“ (`/karten`), Suchfeld Platzhalter „Titel, Tags oder Herausgeber“, Knopf „☕ Unterstützen“, Theme-Knopf (`aria-label` „Hell/Dunkel umschalten“), Konto-Slot. Banner-Text exakt aus Spec §3.
- `index.html`: kleines Inline-Skript im `
` setzt `data-theme` aus `localStorage`/System vor dem ersten Paint (in try/catch).
+- **Ergänzungen aus Plan-Review (verbindlich):** Helles Theme: Tokens `--danger-text`, `--warn-text`, `--ok-text` in beiden Themes; die harten Farben (#ff9a9d, #f2c66d, #8fd9a8) in `web/src/pages/cards.css`, `web/src/editor/editor.css`, `web/src/account/account.css` durch diese Tokens ersetzen; `color-scheme` je Theme. `User.labels` optional (`labels?: string[]`), `getCurrentUser` setzt `labels ?? []`, `isAdmin` nutzt `?? []`; betroffene Tests anpassen (auth.test.ts `toEqual`, cards-page.test.ts erwartet `navigate('/gestalten?karte=a')`). `cards-page.ts` „Zum Editor“-Link → `/gestalten`. Marken-Link bleibt statisch in `index.html` (header.ts ergänzt nur Navigation/Suche/Knöpfe drumherum); statischer `` wird „AirCard-IOS – Kartendesigns für Apple Wallet“, `branding.test.ts` entsprechend anpassen. Platzhalter-Signaturen: `mountHomePage(root)`, `mountCommunityPage(root, id)`.
+
- [ ] **Step 1: Tests schreiben** – router: alle Routen inkl. `/community/abc123` → `{id:'community', params:{id:'abc123'}}`, `/community/` → home, unbekannt → home; `legacyRedirect(new URL('https://x/?karte=K1'))` → `/gestalten?karte=K1`, ohne `karte` → null; `navigate('/karten')` ruft `scrollTo(0,0)`; Titel je Route. theme: `initialTheme(null,true)` → dark, `('light',true)` → light, ungültiger Wert → System; `toggleTheme` setzt `data-theme` und speichert; `localStorage` wirft → kein Fehler. donate-banner: leere URL → nichts gerendert; Schließen setzt Flag und entfernt; Flag gesetzt → nichts. header: Suche Enter mit „ visa “ → `navigate('/?q=visa#community')`; leere Eingabe → nichts; kein ☕-Knopf bei leerem `donateUrl`. auth: `getCurrentUser` übernimmt `labels` (fehlend → `[]`), `isAdmin` true nur mit `'admin'`.
- [ ] **Step 2: Tests laufen lassen** → FAIL.
- [ ] **Step 3: Implementieren**; `main.ts`: Legacy-Umleitung vor `startRouter` (replaceState), Routen auf die Mount-Funktionen; Editor jetzt unter `gestalten`; `document.title` per `titleFor`.
@@ -88,6 +90,8 @@
- REST: `POST …/columns/boolean {key, required:false, default:false}`, `…/columns/integer {key, required:false, default:0, min:0}`, `…/columns/enum {key, elements, required:true}`, Indizes `POST /tablesdb/{db}/tables/{t}/indexes {key, type: 'fulltext'|'key', columns}` erst nachdem alle Spalten `available`; Index-Status ebenfalls auf `available` warten. Vergleich (`--check`) deckt Spaltentyp, Standard, Enum-Elemente, Index-Typ/Spalten ab.
- `docs/appwrite-console.md`: Abschnitt „Community“: JWT muss **an** bleiben; Label `admin` setzen; Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) + `read -s`-Befehl für `AIRCARD_API_KEY`; Hinweis, dass der Provisioning-Key für Indizes zusätzlich `indexes.read/write` braucht.
+- **Ergänzungen aus Plan-Review (verbindlich):** `DESIRED` umbauen auf `tables: [...]` (cards zuerst, dann gallery, reports) und `buckets: [...]` (card-images, gallery-images); `ensureTable/ensureColumns/waitAvailable/ensureBucket` parametrisieren, betroffene Tests umschreiben. `compareColumn` je Typ: string → size/required; boolean → required/default; integer → required/default (min/max der Antwort NICHT vergleichen); enum → elements (sortiert)/required. Index-Routen brauchen nur `tables.*`-Scopes (kein `indexes`-Scope) – in den Docs nicht verlangen. Docs: „JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten.“
+
- [ ] **Step 1: Tests** – Fake-Server um boolean/integer/enum/index-Routen erweitern; leerer Zustand → `--check` meldet alle neuen Objekte als FEHLT; Anlegen → alles da, Indizes erst nach `available`-Spalten; zweiter Lauf legt nichts an; abweichender Index-Typ → ABWEICHUNG.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4:** `node --test web/scripts/appwrite-setup.test.mjs` grün, **Step 5: Commit** `feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder`.
@@ -104,6 +108,8 @@
- `server.js`: Env `AIRCARD_API_PORT` (8088), `AIRCARD_APPWRITE_ENDPOINT` (`http://webklar-appwrite/v1`), `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY`, `AIRCARD_API_SALT`; fehlt Key oder Salt → Fehlermeldung ohne Wert, `exit(1)`.
- Compose-Service: `aircard-api` = `node:22-alpine`, `container_name webklar-aircard-api`, `init: true`, `mem_limit 128m`, `restart unless-stopped`, Volume `./aircard-ios.org/api:/srv:ro`, Command `node /srv/server.js`, Env wie oben aus `${…}`, Netz `webklar-network`, Labels Router `aircard-api` ``Host(`aircard-ios.org`) && PathPrefix(`/api`)`` websecure/letsencrypt Priorität 200, Port 8088.
+- **Ergänzungen aus Plan-Review (verbindlich):** (a) Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen, sonst 400; in `appwrite.js` jedes Pfadsegment `encodeURIComponent`; Tests mit `../x`, `..`, Zahl statt String. (b) Deterministische Zeilen-IDs: Galerie `rowId` = erste 32 Hex von `sha256(ownerId + '|' + sourceCardId)` → Appwrite-409 (doppelt) → API 409 „Diese Karte ist schon veröffentlicht.“; Meldung `rowId` = erste 32 Hex von `sha256(galleryId + '|' + reporterHash)` → 409 → `schonGemeldet`; `reportCount` = Anzahl Meldungszeilen der Karte (limit 100). (c) Dateien: fehlt → 400 „Bild nicht gefunden.“; anderer Bucket oder fremd → 403; `mimeType` ∈ {image/png, image/webp} und `sizeOriginal` ≤ 5 MiB für beide (Vorschau darf PNG sein). (d) Body: `tags` ist `string[]` (Server normalisiert erneut, > 5 oder > 20 Zeichen → 400); Admin-Body `{id, sichtbar}`. (e) IPv6-Adressen vor dem Hashen auf /64 kürzen; abgelaufene Rate-Limit-Einträge entfernen; Appwrite-Aufrufe mit `AbortSignal.timeout(10000)`. (f) Traefik-Router heißt `aircard-gallery-api`; Kommentare „Starten NUR mit …“ in compose-snippet.yml/README um `aircard-api` ergänzen.
+
- [ ] **Step 1: Tests** (`node:test`, Fake-Appwrite im Speicher, echte HTTP-Requests auf Port 0): Veröffentlichen ok (Zeile mit sichtbaren Rechten, `searchText`, `ownerId`); ohne JWT 401; fremde Datei (Rechte ohne `delete("user:me")`) 403; Datei in falschem Bucket 403; doppelt 409; Validierung (Titel leer/zu lang, 6 Tags) 400. Melden: 3 verschiedene IPs → `hidden=true`, Zeilen- und Dateirechte = ausgeblendet; dieselbe IP 5× → `reportCount` 1, Antworten `schonGemeldet`; 11. Meldung derselben IP in 1 h → 429, nach `now`+1 h wieder erlaubt; ausgeblendete Karte melden → 404. Zurückziehen: Autor ok (Zeile, 2 Dateien, Meldungen weg), Admin ok, fremd 403. Admin: Nicht-Admin 403; Einblenden setzt sichtbare Rechte, `hidden=false`, `reportCount=0`, löscht Meldungen. Body > 8 KB → 413; Log enthält nie Key/Salt/JWT.
- [ ] **Step 2: FAIL** (`node --test "deploy/api/*.test.js"`), **Step 3: Implementieren**, **Step 4: PASS**, **Step 5: Commit** `feat(api): aircard-api für Veröffentlichen, Melden und Admin`.
@@ -119,6 +125,8 @@
- `api.ts`: `apiPost(path, body, { auth: boolean })` → JSON; mit `auth` holt `account.createJWT()` (Cache 10 min); Fehler als `ApiError { status, message }` mit Server-Text; Netzfehler → „Der Dienst ist gerade nicht erreichbar.“; `publishCard(input)`, `withdrawCard(id)`, `reportCard(id, reason)`, `setVisibility(id, visible)`.
- `publish.ts`: `publishSavedCard(card: SavedCard, form: { title; issuer; tags; displayName }, deps)` → `{ id }`: Bild laden, rendern (PNG 1536 + WebP 480 via `exportCard`), zwei Uploads in `gallery-images` mit `[Permission.read(Role.any()), Permission.delete(Role.user(uid))]`, dann `publishCard`; bei Fehler beide Dateien löschen (best effort) und Fehler weiterwerfen. `normalizeTags(input: string): string[]` (wie Server).
+- **Ergänzungen aus Plan-Review (verbindlich):** JWT-Cache je Nutzer-ID, geleert bei `onAuthChange`/`onLogout`; API-401 → neues JWT holen, genau einmal wiederholen; kein `duration`. `ApiError` hat numerisches `code` (= HTTP-Status) und `message` (Servertext), damit `isUnauthorized`/`withLogin`/`errorMessage` greifen; Nicht-JSON, 502/503/504 oder HTML-200 → `code 503` „Der Dienst ist gerade nicht erreichbar.“; `createJWT`-Fehler `user_auth_method_unsupported` → „Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.“ In `cards.ts` eine eigenständige exportierte `fetchFile(bucketId, fileId)`; `CardsApi.fetchFile(fileId)` bleibt als Hülle über `IDS.bucket` (bestehende Tests unverändert). Tags an die API als `string[]`. PNG > 5 MiB vor dem Upload → „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“; Vorschau-Datei nach echtem Blob-Typ benennen. `myPublications`/`listReports` mit `limit(100)`. `nextCursor` = `cards.length === 24 ? letzte id : null`. Admin-Filter „Gemeldet“ = `greaterThan('reportCount', 0)` (ohne hidden-Bedingung). `getGalleryCard` mit ungültiger ID (Muster wie API) → `null` ohne Anfrage.
+
- [ ] **Step 1: Tests** – Queries exakt (inkl. Suche + Cursor + Admin-Filter); `toGalleryCard` mit fehlenden Feldern; `getGalleryCard` 404 → null; `apiPost` mit `auth` sendet Header `X-Aircard-Jwt: `; JWT-Cache (zweiter Aufruf innerhalb 10 min ohne neues `createJWT`); Fehlertext aus JSON `message`; Netzfehler-Text. `publishSavedCard`: Reihenfolge Upload, Upload, API; API-Fehler → 2× `deleteFile`, Fehler kommt an; zweiter Upload scheitert → erster gelöscht.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4: PASS + tsc + build**, **Step 5: Commit** `feat(community): Datenschicht für Galerie, API und Veröffentlichen`.
@@ -134,6 +142,8 @@
- `mountGalleryGrid(el, { load, onTag })` – Kacheln (Vorschau per Object-URL, beim Cleanup freigeben), Titel, Herausgeber, ≤ 3 Tags als Knöpfe; Kachel verlinkt `/community/` (`data-link`).
- CSS: Hero `min-height: auto; max-height: 60vh`, Raster `repeat(auto-fill, minmax(260px, 1fr))`; Hero-Karte schwebt (`@media (prefers-reduced-motion: no-preference)`).
+- **Ergänzungen aus Plan-Review (verbindlich):** Tag-Klick sucht nicht per Volltext (min. 3 Zeichen, Stoppwörter), sondern navigiert zu `/?tag=#community` → Query `contains('tags', tag)`; Suchfeld nutzt Volltext ab 3 Zeichen, kürzere Begriffe → `contains('searchText', q)`. Mehrwort-Suche = ODER (akzeptiert).
+
- [ ] **Step 1: Tests** – leere Galerie → Beispielbild + Leertext; Suche aus `?q=` landet in Queries und zeigt aktiven Begriff mit „ד; „Mehr laden“ nur bei `nextCursor`; Tag-Klick navigiert zu `/?q=#community`; Object-URLs werden beim Cleanup freigegeben.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren** (inkl. Hero-Bild), **Step 4: PASS + build**; Sichtprüfung headless Chromium (`npx vite preview`) bei 1440 × 900 und 390 × 844 mit gemockter Galerie (Playwright `page.route` beantwortet `/v1/tablesdb/…/rows` mit 30 Fake-Zeilen und `/v1/storage/…/view` mit einem PNG) – Überschrift „Community-Karten“ und Oberkante der ersten Reihe im Viewport; Screenshots im Scratchpad. **Step 5: Commit** `feat(home): kompakter Hero und Community-Galerie`.
@@ -148,6 +158,8 @@
- Detailseite laut Spec §5 (Texte exakt): Herunterladen (Blob → `aircard-.png`), „Als Vorlage gestalten“ → `navigate('/gestalten?vorlage=')`, Melden-Dialog (Gründe exakt, Ergebnis-Texte), Autor „Zurückziehen“ (Bestätigung via `confirmDialog`, danach `navigate('/')`), Admin „Ausblenden/Einblenden“, „Löschen“, „ Meldungen“ + Gründe (`listReports`). `document.title` = `titleFor(route, card.title)`.
- Editor `?vorlage=`: Galerie-Vollbild laden → `setImage`, Vorlage `image`, kein `saved`; ungespeicherte Arbeit → bestehende Rückfrage; Fehler → Hinweis „Diese Vorlage ist nicht (mehr) verfügbar.“
+- **Ergänzungen aus Plan-Review (verbindlich):** Nach dem `?vorlage=`-Import `history.replaceState` auf `/gestalten` (kein erneuter Import bei Zurück) und `saved`, `savedConfig`, `cardName` ausdrücklich zurücksetzen (sonst überschreibt Speichern eine zuvor geöffnete Karte).
+
- [ ] **Step 1: Tests** – nicht gefunden/ausgeblendet für Gast → Text + kein Bild; Autor sieht Zurückziehen, Fremder nicht, Admin sieht Admin-Knöpfe + Meldungszahl; Melden: doppelt → „Danke, du hast diese Karte schon gemeldet.“, 429 → Rate-Text; Vorlage setzt Template `image` und lässt `saved` null.
- [ ] **Step 2–5** wie üblich; Commit `feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage`.
@@ -161,11 +173,15 @@
**Interfaces:**
- Dialog laut Spec §6 (Felder, Grenzen, Pflicht-Checkbox-Text exakt), Absenden über `publishSavedCard`; Kachel-Status aus `myPublications(uid)`: „Veröffentlicht“ + „Ansehen“ (`/community/`) + „Zurückziehen“ bzw. „Ausgeblendet (gemeldet)“; nicht veröffentlicht → „Veröffentlichen“. Nicht angemeldet → bestehendes `withLogin`.
+- **Ergänzungen aus Plan-Review (verbindlich):** Ausgeblendete Kacheln behalten „Zurückziehen“ (Autor darf aufräumen und neu veröffentlichen).
+
- [ ] **Step 1: Tests** – Validierung im Dialog (Titel leer, > 60, 6 Tags, Checkbox fehlt); Erfolg → Badge + Ansehen-Link; 409 → Text; Zurückziehen → Status zurück.
- [ ] **Step 2–5**; Commit `feat(community): Veröffentlichen aus Meine Karten`.
### Task 8: Betrieb (Controller) und Abnahme
+- **Ergänzungen aus Plan-Review (verbindlich):** Kenso prüft, dass JWT in den Auth-Methoden **an** ist (Live-Check: Veröffentlichen klappt). Kein `indexes`-Scope nötig. Nach erfolgreichem Schema-Lauf den alten, im Chat geposteten Provisioning-Key löschen lassen. Spoofing-Test: zweimal `/api/melden` über die öffentliche URL mit unterschiedlichen gefälschten `X-Real-Ip` → zweite Antwort `schonGemeldet` (Traefik überschreibt den Header).
+
- [ ] Kenso: Label `admin`, Key `aircard-api` per `read -s` als `AIRCARD_API_KEY` in `.env` (Controller liefert den Befehl); Provisioning-Key um `indexes.read/write` ergänzen oder Controller legt Indizes nach Kensos OK an.
- [ ] Controller: `AIRCARD_API_SALT` (32 Byte hex) in `.env` (Backup), Setup-Skript `--check` → anlegen → `--check` OK; `/home/webklar/apps/aircard-ios.org/api/` mit `deploy/api/*.js` füllen (`cp`); Compose-Backup, Service einfügen, `docker compose config -q`, `docker compose up -d aircard-api`; `curl https://aircard-ios.org/api/gesundheit` → `{"ok":true}`; Push → Webhook-Build.
- [ ] Live-Abnahme (Claude in Chrome, Kensos Sitzung): Startseite 1440 × 900 Karten angeschnitten; Hell/Dunkel; Veröffentlichen einer Testkarte; Suche nach Tag; Detailseite, Herunterladen, Als Vorlage; Melden (3 verschiedene `X-Real-Ip` per `docker exec webklar-aircard-api` lokal gegen `127.0.0.1:8088`) → ausgeblendet, Gast sieht „nicht (mehr) verfügbar“; Admin Einblenden; Zurückziehen.