Files
AirCardIOS/docs/superpowers/plans/2026-10-04-aircard-community.md
T
2026-10-04 13:08:34 +00:00

22 KiB
Raw Blame History

AirCard-IOS Startseite & Community-Galerie – 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: Kompakte Startseite mit Community-Galerie, Veröffentlichen/Melden/Admin, Spenden-Banner und Hell/Dunkel-Schalter für https://aircard-ios.org.

Architecture: Der bestehende Vite-+-TS-Client bekommt neue Routen und Module (web/src/community/*, web/src/shell/*). Lesen der Galerie direkt aus Appwrite (öffentliche Zeilen), alle Galerie-Schreibvorgänge über einen neuen kleinen Node-Dienst aircard-api (deploy/api/) mit Server-Key, erreichbar unter /api per Traefik. Schema-Erweiterung ü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 für aircard-api (nur Built-ins), Appwrite 1.8.1 TablesDB, Traefik v3.6.

Spec: docs/superpowers/specs/2026-10-04-aircard-community-design.md (Commit 970e68e). Basis: docs/superpowers/specs/2026-10-04-aircard-website-design.md.

Global Constraints

  • Arbeitskopie /home/webklar/apps/aircard-ios.org/dev, Branch main, direkt committen, nicht pushen (Controller pusht).
  • Versionen unverändert gepinnt; keine neuen npm-Abhängigkeiten im Client; deploy/api/ ohne Abhängigkeiten.
  • Alle sichtbaren Texte Deutsch, Wortlaut aus der Spec.
  • Appwrite: Projekt 6ac2130a00289343d6d5; Datenbank aircard; neue Tabellen gallery, reports; neuer Bucket gallery-images; bestehende cards/card-images unverändert.
  • Galerie-Zeilenrechte: sichtbar genau ['read("any")']; ausgeblendet genau ['read("user:<owner>")','read("label:admin")']. Galerie-Dateirechte sichtbar ['read("any")','delete("user:<owner>")'], ausgeblendet ['read("user:<owner>")','read("label:admin")','delete("user:<owner>")'].
  • Auto-Ausblenden ab 3 verschiedenen Meldern; Rate-Limit 10 Meldungen pro Stunde und IP; Seiten zu 24 Karten.
  • Admin = Appwrite-Label admin (account.get().labels).
  • DONATE_URL in web/src/config.ts; leer ⇒ Banner und Spenden-Knopf unsichtbar.
  • Theme: data-theme auf <html>, localStorage aircard.theme; Banner localStorage aircard.donateBannerClosed. Jeder localStorage-Zugriff in try/catch.
  • Hero: bei 1440 × 900 und 390 × 844 müssen „Community-Karten“-Überschrift und der obere Rand der ersten Kartenreihe im ersten Bildschirm sichtbar sein.
  • Keine Secrets in Ausgaben, Logs, Commits; .env nie öffnen (Ausnahme: Controller-Ops).
  • Tests: cd web && npx vitest run, npx tsc --noEmit, npm run build; node --test deploy/api/*.test.js (Glob in Anführungszeichen), bestehende node-Tests grün halten.
  • Commits enden mit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Subagents: sonnet (Implementierung/Review), haiku für kleine Nachprüfungen; nie Subagents aus Subagents.

Review Focus

  • Fremdes Veröffentlichen: Nutzer schickt imageFileId einer fremden Datei an /api/veroeffentlichen → 403, nichts angelegt. Test in Task 3.
  • Meldungs-Flut / Selbst-Ausblenden: dieselbe IP meldet 5× → zählt 1; 11. Meldung in einer Stunde → 429. Test in Task 3.
  • Ausgeblendete Karte per Direkt-Link: Gast öffnet /community/<id> einer ausgeblendeten Karte → „nicht (mehr) verfügbar“, kein Bild. Test in Task 6 (Client) und Task 3 (Rechte).
  • Leere Galerie / leeres DONATE_URL: Startseite ohne Karten zeigt Beispielmotiv und Leertext; ohne Link kein toter Spenden-Knopf. Tests in Tasks 1 und 5.
  • Theme-Wechsel mit offenem Editor: Umschalten ändert nur UI-Tokens, nicht die Kartenfarben/PNG. Test in Task 1 (render unabhängig von Theme).

Task 1: Shell – Routen, Kopfzeile, Theme, Spenden-Banner

Files:

  • Create: web/src/config.ts, web/src/shell/theme.ts, web/src/shell/donate-banner.ts, web/src/shell/header.ts, web/src/shell/shell.css, web/src/pages/home-page.ts (Platzhalter mit Überschrift, Task 5 füllt), web/src/pages/community-page.ts (Platzhalter, Task 6 füllt)
  • Modify: web/index.html (Kopfzeilen-Gerüst, Banner-Slot #donate-banner, Theme-Init-Skript gegen Aufblitzen), web/src/pages/router.ts, web/src/main.ts, web/src/styles.css (helle Tokens), web/src/account/account-menu.ts (Avatar-Kreis + Menü), web/src/account/auth.ts (User.labels, isAdmin), web/src/editor/card-url.ts und web/src/pages/cards-page.ts (Pfad /gestalten?karte=)
  • Test: web/src/pages/router.test.ts, web/src/shell/theme.test.ts, web/src/shell/donate-banner.test.ts, web/src/shell/header.test.ts, web/src/account/auth.test.ts (labels)

Interfaces:

  • Produces:

    // router.ts (ersetzt RouteId)
    type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset'
    interface Route { id: RouteId; params: { id?: string } }
    matchRoute(pathname: string): Route      // '/' home, '/gestalten' gestalten, '/karten' cards, '/community/<id>' community {id}, '/passwort-neu' reset, sonst home
    legacyRedirect(url: URL): string | null  // '/?karte=X' → '/gestalten?karte=X', sonst null
    navigate(path: string): void             // pushState, render, scrollTo(0,0) außer bei Hash-Ziel (dann scrollIntoView des Ankers)
    startRouter(render: (route: Route, root: HTMLElement) => void | (() => void)): void
    ROUTE_TITLES / titleFor(route: Route, detailTitle?: string): string   // Wortlaut Spec §2
    // config.ts
    export const DONATE_URL: string = ''     // Kenso liefert den Link
    // theme.ts
    type Theme = 'light' | 'dark'
    initialTheme(stored: string | null, prefersDark: boolean): Theme
    applyTheme(t: Theme): void; toggleTheme(): Theme   // speichert in localStorage (try/catch)
    // donate-banner.ts
    mountDonateBanner(slot: HTMLElement, url: string): void   // nichts rendern wenn url leer oder geschlossen
    // header.ts
    mountHeader(el: HTMLElement, opts: { donateUrl: string }): () => void  // Navigation, Suchfeld (Enter → navigate('/?q=…#community')), ☕-Knopf, Theme-Schalter, Menü-Icon < 900 px
    // auth.ts
    type User = { $id: string; email: string; labels: string[] }
    isAdmin(user: User | null): boolean
    
  • 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 <head> setzt data-theme aus localStorage/System vor dem ersten Paint (in try/catch).

  • 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.

  • Step 4: Prüfen – Suite grün, tsc, build.

  • Step 5: Commit – feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner

Task 2: Schema-Erweiterung (Setup-Skript)

Files:

  • Modify: web/scripts/appwrite-setup.mjs, web/scripts/appwrite-setup.test.mjs, docs/appwrite-console.md

Interfaces:

  • DESIRED bekommt: Tabelle gallery (rowSecurity true, Tabellenrechte []), Spalten gemäß Spec §7 (Typen string/boolean/integer, Standardwerte hidden=false, reportCount=0), Indizes search_text (fulltext, [searchText]), owner (key, [ownerId]), hidden (key, [hidden]); Tabelle reports (rowSecurity false, Tabellenrechte ['read("label:admin")']), Spalten galleryId string 36 Pflicht, reason enum [unangemessen, kartendaten, urheberrecht, spam, sonstiges] Pflicht, reporterHash string 64 Pflicht, Index gallery (key, [galleryId]); Bucket gallery-images (Rechte ['create("users")'], fileSecurity true, maximumFileSize 5242880, Endungen [png, webp], compression none, antivirus false).

  • 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.

  • 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.

Task 3: API-Dienst aircard-api

Files:

  • Create: deploy/api/appwrite.js (REST-Helfer mit Server-Key), deploy/api/app.js (Routen, Validierung, Logik), deploy/api/server.js (Start: Env lesen, http.createServer), deploy/api/app.test.js, deploy/api/README.md
  • Modify: deploy/compose-snippet.yml (Service aircard-api), deploy/README.md

Interfaces:

  • appwrite.js: createAppwrite({ endpoint, project, key, fetchImpl }) → { getRow, listRows(table, queries), createRow, updateRow(table, id, data, permissions?), deleteRow, getFile(bucket, id), updateFilePermissions(bucket, id, permissions), deleteFile, accountFromJwt(jwt) } (letzteres GET /account mit X-Appwrite-JWT, ohne Key; der Client schickt das JWT im Header X-Aircard-Jwt). Queries als Appwrite-JSON-Strings ({"method":"equal","attribute":"…","values":[…]}).

  • app.js: createApp({ appwrite, salt, now = Date.now, log }) → (req, res) => Promise<void>; reine Helfer exportiert: visibleRowPermissions(), hiddenRowPermissions(owner), visibleFilePermissions(owner), hiddenFilePermissions(owner), reporterHash(salt, ip) (HMAC-SHA256 hex), clientIp(req) (x-real-ip, sonst erstes x-forwarded-for, sonst Socket), validatePublish(body) → {ok, data|message} (Längen Spec §6, Tags normalisiert: trim, klein, leer raus, max. 5, je ≤ 20), buildSearchText({title, issuer, tags}) (klein, ≤ 300).

  • Endpunkte, Statuscodes und Texte exakt Spec §8/§11: POST /api/veroeffentlichen (401 ohne/ungültiges JWT, 400 Validierung, 403 Datei nicht meine/falscher Bucket, 409 „Diese Karte ist schon veröffentlicht.“, 201 {id}), POST /api/zurueckziehen (403 fremd, 404 fehlt, 200), POST /api/melden (400 Grund, 404 fehlt/ausgeblendet, 200 {ok:true} bzw. {schonGemeldet:true}, 429 „Zu viele Meldungen – bitte später nochmal.“), POST /api/admin/sichtbarkeit (403 kein Admin, 200), GET /api/gesundheit. Body max. 8 KB (413), nur application/json (415), sonst 404.

  • 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.

  • 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.

Task 4: Client-Datenschicht Community

Files:

  • Create: web/src/community/gallery.ts, web/src/community/api.ts, web/src/community/publish.ts
  • Modify: web/src/account/appwrite.ts (IDS.gallery='gallery', IDS.reports='reports', IDS.galleryBucket='gallery-images'), web/src/account/cards.ts (fetchFile(bucketId, fileId) verallgemeinern, bestehende Aufrufer anpassen)
  • Test: web/src/community/gallery.test.ts, api.test.ts, publish.test.ts

Interfaces:

  • gallery.ts: interface GalleryCard { id; title; issuer; tags: string[]; displayName; imageFileId; previewFileId; sourceCardId; ownerId; hidden: boolean; reportCount: number; createdAt: string }; toGalleryCard(row); galleryQueries(opts: { q?: string; cursor?: string; filter?: 'all'|'reported'|'hidden' }): string[] (öffentlich immer equal('hidden', false), orderDesc('$createdAt'), limit(24), Suche search('searchText', q), Admin-Filter laut Spec §7); listGallery(opts) → { cards, nextCursor | null }; getGalleryCard(id) → GalleryCard | null (404 → null); myPublications(userId) → Map<sourceCardId, GalleryCard>; listReports(galleryId) (Admin); loadGalleryImage(card, 'full'|'preview') → Blob (über fetchFile).

  • 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).

  • 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>; 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.

Task 5: Startseite (Hero + Galerie)

Files:

  • Modify: web/src/pages/home-page.ts (Platzhalter aus Task 1 ersetzen)
  • Create: web/src/community/gallery-grid.ts, web/src/community/community.css, web/public/hero-beispiel.png (1536 × 969, eigenes markenfreies Motiv: lila/blauer Verlauf mit abstrakten Wellen + AirCard-IOS-Symbol, erzeugt mit einem Skript im Scratchpad)
  • Test: web/src/pages/home-page.test.ts, web/src/community/gallery-grid.test.ts

Interfaces:

  • mountHomePage(root, deps = defaultDeps) – Hero laut Spec §4 (Texte exakt), Hero-Karte = Vollbild der neuesten sichtbaren Karte oder /hero-beispiel.png; Galerie-Abschnitt id="community"; liest ?q= aus der URL; „Mehr laden“ hängt an; Leer-/Kein-Treffer-Texte exakt; Admin sieht Filter-Chips „Alle/Gemeldet/Ausgeblendet“.

  • mountGalleryGrid(el, { load, onTag }) – Kacheln (Vorschau per Object-URL, beim Cleanup freigeben), Titel, Herausgeber, ≤ 3 Tags als Knöpfe; Kachel verlinkt /community/<id> (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)).

  • 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=<tag>#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.

Task 6: Detailseite /community/<id> und „Als Vorlage“

Files:

  • Modify: web/src/pages/community-page.ts (Platzhalter aus Task 1 ersetzen), web/src/editor/editor-page.ts (?vorlage=<id>)
  • Create: web/src/community/report-dialog.ts
  • Test: web/src/pages/community-page.test.ts, web/src/community/report-dialog.test.ts, Ergänzung web/src/editor/editor-page.test.ts

Interfaces:

  • Detailseite laut Spec §5 (Texte exakt): Herunterladen (Blob → aircard-<slug>.png), „Als Vorlage gestalten“ → navigate('/gestalten?vorlage=<id>'), 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=<id>: Galerie-Vollbild laden → setImage, Vorlage image, kein saved; ungespeicherte Arbeit → bestehende Rückfrage; Fehler → Hinweis „Diese Vorlage ist nicht (mehr) verfügbar.“

  • 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.

Task 7: Veröffentlichen aus „Meine Karten“

Files:

  • Create: web/src/community/publish-dialog.ts
  • Modify: web/src/pages/cards-page.ts, web/src/pages/cards.css
  • Test: web/src/community/publish-dialog.test.ts, Ergänzung web/src/pages/cards-page.test.ts

Interfaces:

  • Dialog laut Spec §6 (Felder, Grenzen, Pflicht-Checkbox-Text exakt), Absenden über publishSavedCard; Kachel-Status aus myPublications(uid): „Veröffentlicht“ + „Ansehen“ (/community/<id>) + „Zurückziehen“ bzw. „Ausgeblendet (gemeldet)“; nicht veröffentlicht → „Veröffentlichen“. Nicht angemeldet → bestehendes withLogin.

  • 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

  • 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.
  • Final-Review (Workflow, 3 Linsen) → eine Fix-Welle → Push; Memory aktualisieren; Bericht an Kenso.