Files
AirCardIOS/docs/superpowers/plans/2026-10-04-aircard-website.md

55 KiB
Raw Permalink Blame History

AirCard-Website – 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: Kartenmotiv-Editor unter https://aircard-ios.org (Bild hochladen, Vorlage/Logo/Hell-Dunkel wählen, PNG 1536 × 969 herunterladen) mit Konten und gespeicherten Karten über ein eigenes Appwrite-Projekt.

Architecture: Statische Vite-+-TypeScript-Seite ohne Framework im Unterordner web/ von knso/AirCardIOS. Reine Kartenlogik (web/src/card/*) ist von UI und Appwrite getrennt und mit Vitest getestet. Konten/Speichern über das Appwrite-Web-SDK gegen https://aircard-ios.org/v1, das Traefik per Header-Matcher an Appwrite weiterreicht (first-party Cookie). Deploy nach dem Vorbild ticket.webklar.com: Gitea-Push → Webhook → Build-Container → out/ → nginx.

Tech Stack: TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom 20.14.5 (Tests), appwrite (Web-SDK) 22.4.1, @fontsource-variable/nunito-sans 5.3.0, Node 22 (Build-Container node:22-alpine, lokal 22.23.3), nginx:alpine, Traefik v3.6 (vorhanden), Appwrite 1.8.1 (vorhanden).

Spec: docs/superpowers/specs/2026-10-04-aircard-website-design.md. Jeder Task liest die Spec-Abschnitte, die er umsetzt.

Global Constraints

  • Arbeitskopie: /home/webklar/apps/aircard-ios.org/dev (Branch main, Remote https://git.webklar.com/knso/AirCardIOS.git). Pfade unten relativ dazu, außer sie beginnen mit /.
  • Versionen exakt pinnen (kein ^): vite 8.3.2, vitest 5.0.3, typescript 6.0.3, happy-dom 20.14.5, appwrite 22.4.1, @fontsource-variable/nunito-sans 5.3.0.
  • Kein UI-Framework, keine externen CDNs/Fonts zur Laufzeit.
  • Alle sichtbaren Texte auf Deutsch; Wortlaut aus der Spec bzw. aus diesem Plan.
  • Karte: CARD_W = 1536, CARD_H = 969, Rand 56, Vorschau-Radius 57 (nur Vorschau, nie im Export).
  • Upload: nur image/png, image/jpeg, image/webp, max. 5_242_880 Byte.
  • Details-Farben: Hell #FFFFFF, Dunkel #111111. Kartengrund ohne Bild bzw. unter transparenten Bildern: #1B2738.
  • Appwrite-IDs: Projekt aircard, Datenbank aircard, Tabelle cards, Bucket card-images.
  • Frontend-Endpoint: import.meta.env.VITE_APPWRITE_ENDPOINT ?? location.origin + '/v1', Projekt import.meta.env.VITE_APPWRITE_PROJECT_ID ?? 'aircard'.
  • Dateien aus Appwrite nie über getFileView/getFilePreview-URLs laden (Projekt als Query, kein Header → trifft die Traefik-Route nicht), sondern über fetchFile (Task 9).
  • Produktion: vor jeder Änderung an /home/webklar/apps/docker-compose.yml Backup docker-compose.yml.bak-2026-10-04-aircard; nie nacktes docker compose up -d; webklar-appwrite*, webklar-traefik und preview-portal nicht neu starten oder ändern; keine Secrets in Ausgaben, Logs oder Commits.
  • Konten anlegen und Passwörter auf der Live-Seite eingeben macht Kenso, nicht Claude. Claude arbeitet danach in Kensos angemeldeter Chrome-Sitzung weiter.
  • Tests: cd web && npx vitest run (Host-Node 24); Typprüfung cd web && npx tsc --noEmit; Deploy-Tests node --test deploy/webhook-server.test.js.
  • Zahlen mit Nachkommastellen in Tests mit toBeCloseTo(wert, 1) vergleichen.
  • Commit-Nachrichten enden mit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Subagents: Modell sonnet (Implementierung/Review), haiku für kleine Nachprüfungen.

Review Focus

  • Transparentes PNG hochgeladen → exportiertes PNG ist deckend (Grund #1B2738, Canvas ohne Alpha). Test in Task 4.
  • iPhone-Foto als HEIC oder > 5 MiB → klare deutsche Meldung, Editor unverändert. Test in Task 5.
  • Sitzung läuft zwischen Seitenaufruf und Speichern/Laden ab (401) → Login-Dialog, danach wird die Aktion genau einmal wiederholt. Tests in Task 9.
  • Doppelklick/-tipp auf „Speichern“ → genau eine Karte. Test in Task 9.
  • Neues Bild nach Verschieben/Zoomen eines anderen Bildes → Ausschnitt wird zurückgesetzt bzw. begrenzt, keine leeren Streifen im PNG. Tests in Task 4 und Task 5.

Task 1: Projektgerüst web/

Files:

  • Create: .gitignore, README.md (Kurzfassung, wird in Task 10 ergänzt), web/package.json, web/package-lock.json, web/tsconfig.json, web/vite.config.ts, web/index.html, web/public/favicon.svg (Platzhalter, Task 3 ersetzt ihn), web/src/main.ts, web/src/styles.css, web/src/pages/router.ts, web/src/vite-env.d.ts
  • Test: web/src/pages/router.test.ts

Interfaces:

  • Produces: type RouteId = 'editor' | 'cards' | 'reset'; matchRoute(pathname: string): RouteId; navigate(path: string): void; startRouter(render: (route: RouteId, root: HTMLElement) => void | (() => void)): void (ruft den Cleanup der vorherigen Seite, fängt Klicks auf a[data-link] ab, reagiert auf popstate). DOM-Gerüst in index.html: <header id="app-header">, <main id="app-root">, <footer id="app-footer">, darin #account-slot in der Kopfzeile.

  • Step 1: Test schreiben – matchRoute('/') → 'editor'; '/karten' und '/karten/' → 'cards'; '/passwort-neu' → 'reset'; '/irgendwas' → 'editor'.

  • Step 2: Gerüst anlegen – package.json ("type": "module", Scripts dev, build = tsc --noEmit && vite build, test = vitest run, preview), exakte Versionen, npm install erzeugt den Lockfile. vite.config.ts mit Vitest environment: 'happy-dom'. .gitignore: node_modules/, dist/, .env*. tsconfig.json: strict, target/lib ES2022 + DOM, types: ["vite/client"].

  • Step 3: Layout und Stil – index.html (lang="de", Titel „AirCard – Kartenmotiv gestalten“, Favicon /favicon.svg). styles.css mit Tokens auf :root: --bg #0F1828; --panel #162133; --panel-2 #1B2738; --line #2A3A52; --line-strong #3D5576; --sel-bg #1F3150; --sel-line #8FB4E8; --text #E8EEF7; --muted #9DB0C8; --primary #2A4566; --primary-hover #34557C; --danger #E5484D; --radius 14px; Schrift "Nunito Sans Variable" über import '@fontsource-variable/nunito-sans' in main.ts. Kopfzeile: Wortmarke „AirCard“ (Link /), Link „Meine Karten“ (/karten, data-link), rechts #account-slot. Fußzeile exakt: „Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.“ Seiten zunächst Platzhalter mit Überschrift.

  • Step 4: Prüfen – npx vitest run → PASS; npm run build → dist/index.html, keine Fehler.

  • Step 5: Commit – git add -A && git commit -m "feat(web): Projektgerüst, Router, Grundlayout"

Task 2: Kartenkonfiguration, Geometrie, Bildausschnitt

Files:

  • Create: web/src/card/config.ts, web/src/card/geometry.ts, web/src/card/crop.ts
  • Test: web/src/card/config.test.ts, web/src/card/geometry.test.ts, web/src/card/crop.test.ts

Interfaces:

  • Produces (config.ts):

    • type TemplateId = 'image' | 'minimal' | 'classic' | 'signature'; type DetailsTone = 'light' | 'dark'; type BrandId = 'aircards-symbol' | 'aircards-text' | 'apple' | 'none'
    • type NetworkId und NETWORK_IDS: readonly NetworkId[] in dieser Reihenfolge: visa-auto, visa-blue, visa-navy, visa-silver, mc-color, mc-silver, mc-wordmark, mc-silver-text, mc-classic, up-color, up-silver, up-white-frame, up-english, amex-blue, amex-outline, amex-select, jcb, discover
    • interface Crop { x: number; y: number; zoom: number } – x/y = Versatz der Bildmitte zur Kartenmitte in Karten-px, zoom ≥ 1 relativ zum Cover-Maßstab
    • interface CardConfig { v: 1; template: TemplateId; network: NetworkId; details: DetailsTone; brand: BrandId; crop: Crop }
    • DEFAULT_CONFIG = { v: 1, template: 'signature', network: 'visa-auto', details: 'light', brand: 'aircards-symbol', crop: { x: 0, y: 0, zoom: 1 } }; DEFAULT_CROP
    • parseConfig(json: string | null | undefined): CardConfig (feldweise validiert, Unbekanntes → Standard des Feldes), serializeConfig(c: CardConfig): string, TONE_COLOR: Record<DetailsTone, string>
  • Produces (geometry.ts): CARD_W, CARD_H, MARGIN = 56, PREVIEW_RADIUS = 57, interface Box { x: number; y: number; w: number; h: number }, WALLET_KEEP_CLEAR: Box = {x:0,y:779,w:620,h:190}, fitNetwork(aspect: number, maxH: number): Box (w = min(360, maxH·aspect), h = w/aspect, rechte Kante 1480, Unterkante 913), fitBrand(aspect: number, maxH = 100): Box (Box 56,56 bis max. 360 × maxH, links oben), layoutFor(t: TemplateId): Layout:

    interface Layout {
      network: boolean; brand: boolean;
      platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
      chip?: { x: number; y: number; w: number; h: number; r: number; hLines: number[]; vLines: number[]; padY: [number, number] };
      contactless?: { path: string; lineWidth: number; box: Box };
      number?: { startX: number; pitch: number; baseline: number; size: number; bulletY: number; bulletR: number; text: string };
    }
    

    Werte: Klassisch-PLATINUM {x:1480, baseline:123, size:46, trackingPx:9, align:'right'}; Signature-PLATINUM {x:60, baseline:226, size:44, trackingPx:8, align:'left'}; Chip {x:157, y:326, w:226, h:205, r:32, hLines:[383,429,474], vLines:[234,307], padY:[383,474]} (waagerechte Linien über die volle Chipbreite 157–383, auf das abgerundete Rechteck beschnitten; senkrechte Linien von padY[0] bis padY[1]); Kontaktlos {path:'M548.75 379.89a98.21 98.21 0 0 1 0 98.21M527.14 390.7a76.61 76.61 0 0 1 0 76.61M506.52 400.61a55.39 55.98 0 0 1 0 55.98M485.89 411.32a35.36 33.39 0 0 1 0 35.36', lineWidth:11.8, box:{x:480,y:374,w:88,h:110}}; Nummer {startX:157, pitch:45.6, baseline:650, size:72, bulletY:624, bulletR:8, text:'•••• •••• •••• 8888'}. image: network/brand false, keine optionalen Felder; minimal: nur network; classic: alles; signature: network, brand, platinum.

  • Produces (crop.ts): coverScale(imgW: number, imgH: number): number (= max(1536/w, 969/h)), imageRect(img: {w:number;h:number}, crop: Crop): Box, clampCrop(img, crop): Crop (zoom auf [1, 4], Versatz so, dass das Bild die Karte ganz bedeckt), panBy(img, crop, dx, dy): Crop, zoomTo(img, crop, zoom): Crop.

  • Step 1: Tests schreiben

    • config: Rundlauf parseConfig(serializeConfig(c)) = c; parseConfig(null) und parseConfig('kaputt{') → DEFAULT_CONFIG; network: 'paypal' → nur dieses Feld Standard; crop.zoom: 99 → 4, crop.x: NaN → 0; serializeConfig(DEFAULT_CONFIG).length < 2000; NETWORK_IDS.length === 18.
    • geometry: fitNetwork(330/109, 109) ≈ {x:1150, y:804, w:330, h:109}; fitNetwork(6, 109) → w 360, x+w = 1480, y+h = 913; fitBrand(1) → {x:56,y:56,w:100,h:100}; für jede Vorlage liegen folgende Boxen im Canvas und schneiden WALLET_KEEP_CLEAR nicht: Chip, Kontaktlos-Box, Nummernzeile {x:157,y:572,w:867,h:78}, PLATINUM-Zeile (feste Worst-Case-Boxen: Klassisch {x:1120,y:77,w:360,h:58}, Signature {x:60,y:182,w:360,h:55}), fitNetwork(1, 125), fitNetwork(6, 109), fitBrand(3.6); layoutFor('image') hat network === false und keine optionalen Felder.
    • crop: coverScale(3000, 1000) ≈ 0.969; 3000×1000, clampCrop(img, {x: 9999, y: 0, zoom: 1}).x ≈ 685.5; 1000×3000: x-Grenze 0, y > 0; Panorama 4000×200 deckt bei zoom 1 die volle Höhe (imageRect.h ≥ 969); zoomTo(…, 0.5).zoom → 1, zoomTo(…, 7).zoom → 4; imageRect(img, DEFAULT_CROP) ist mittig und deckt 0..1536 × 0..969 ab; panBy über den Rand hinaus bleibt begrenzt.
  • Step 2: Tests laufen lassen – npx vitest run src/card → FAIL (Module fehlen).

  • Step 3: Implementieren (Signaturen oben).

  • Step 4: Tests laufen lassen – PASS; npx tsc --noEmit sauber.

  • Step 5: Commit – git commit -m "feat(card): Konfiguration, Geometrie und Bildausschnitt"

Task 3: Logos (SVG-Dateien und Register)

Files:

  • Create: web/src/assets/marks/<id>.svg für alle 18 NetworkId sowie aircards-symbol.svg, aircards-text.svg, apple.svg; web/src/card/marks.ts
  • Modify: web/public/favicon.svg (eigene Kopie des AirCards-Symbols mit fester Farbe #E8EEF7 auf abgerundetem Quadrat #0F1828, kein currentColor)
  • Test: web/src/card/marks.test.ts

Interfaces:

  • Consumes: NetworkId, BrandId, DetailsTone, TONE_COLOR (Task 2)

  • Produces:

    type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo'
    interface MarkDef { id: NetworkId | Exclude<BrandId, 'none'>; group: MarkGroup; label: string; svg: string; tint: boolean; maxH: number }
    NETWORK_MARKS: readonly MarkDef[]   // 18, Reihenfolge = NETWORK_IDS
    BRAND_MARKS: readonly MarkDef[]     // aircards-symbol, aircards-text, apple (group 'Logo', maxH 100)
    getNetworkMark(id: NetworkId): MarkDef
    getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef
    svgAspect(svg: string): number      // aus viewBox
    tintSvg(svg: string, tone: DetailsTone): string
    loadMarkImage(def: MarkDef, tone: DetailsTone): Promise<HTMLImageElement>  // Blob-URL, Cache je (id, tone)
    

    SVGs per import x from '../assets/marks/x.svg?raw'. group dient im Optionsfeld als aria-label/title („Visa – Blau“).

  • Step 1: Test schreiben – NETWORK_MARKS.map(m => m.id) = NETWORK_IDS; Labels exakt in Reihenfolge (Wortlaut Screenshot): Automatisch, Blau, Marineblau, Metallisches Silber, Farbe, Metallisches Silber, Wortmarke, Silber + Text, Klassisch, Farbe, Metallisches Silber, Weißer Rahmen, Englisch, Blau, Umriss, Select, JCB, Discover; BRAND_MARKS Labels AirCards · Symbol, AirCards · Text, Apple; jedes svg beginnt mit <svg, hat viewBox, enthält weder <image noch <script; svgAspect endlich > 0; tint: true genau für visa-auto, amex-outline, aircards-symbol, aircards-text, apple, diese enthalten currentColor, und tintSvg(svg, 'dark') enthält #111111 und kein currentColor; maxH 109 für visa-*, mc-wordmark, mc-silver-text, up-english, discover; 125 für mc-color, mc-silver, mc-classic, up-color, up-silver, up-white-frame, amex-blue, amex-outline, jcb; 90 für amex-select; 100 für alle BRAND_MARKS.

  • Step 2: Test laufen lassen → FAIL.

  • Step 3: SVG-Dateien erstellen – Quellen (Wikimedia: PD-textlogo + Markenhinweis):

    • Visa: Pfad aus https://upload.wikimedia.org/wikipedia/commons/5/5c/Visa_Inc._logo_%282021%E2%80%93present%29.svg; visa-auto fill currentColor, visa-blue #1434CB, visa-navy #1A1F71, visa-silver linearer Verlauf #F4F4F4 → #A8A8A8 → #E2E2E2.
    • Mastercard: mc-color aus Commons File:Mastercard_2019_logo.svg; mc-silver = gleiche Kreise mit Silber-Verlauf, Überlappung #8C8C8C; mc-wordmark aus https://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg (Schriftzug weiß); mc-silver-text = Silber-Kreise + weißer Schriftzug; mc-classic aus https://upload.wikimedia.org/wikipedia/commons/b/b7/MasterCard_Logo.svg.
    • UnionPay: up-color aus Commons File:UnionPay_logo.svg; up-silver = gleiche Form mit Silber-Verläufen; up-white-frame = up-color mit weißer Kontur (Strichbreite 6 % der Höhe); up-english = Farblogo ohne chinesische Zeichen, Schriftzug „UnionPay“ weiß.
    • Amex: https://upload.wikimedia.org/wikipedia/commons/f/fa/American_Express_logo_%282018%29.svg; amex-blue original #016FD0; amex-outline Box transparent, Schrift/Rahmen currentColor; amex-select = amex-blue.
    • JCB: https://upload.wikimedia.org/wikipedia/commons/4/40/JCB_logo.svg.
    • Discover: https://upload.wikimedia.org/wikipedia/commons/5/57/Discover_Card_logo.svg, eingebettetes PNG des Balls durch <circle> mit Verlauf #FFB000 → #E5600F ersetzen, Schrift weiß.
    • Apple: Pfad aus Commons File:Apple_logo_black.svg, fill currentColor.
    • aircards-symbol: eigenes SVG, schräg gestellte abgerundete Karte (−12°) mit zwei kurzen Linien, fill currentColor; aircards-text: „AirCards“ als Pfade (Nunito Sans 800, einmalig mit opentype.js im Scratchpad umgewandelt), fill currentColor.
    • Ist /root/reports/aircard-marks-research/ lesbar, dürfen die dortigen Kandidaten statt Neu-Download benutzt werden.
    • Alle Dateien mit npx svgo@4 --multipass bereinigen; keine eingebetteten Bilder oder Schriftreferenzen.
  • Step 4: marks.ts implementieren.

  • Step 5: Prüfen – Tests PASS; Kontaktbogen aller 21 SVGs (+ Favicon) im Scratchpad als PNG rendern (z. B. npx @resvg/resvg-js-cli) und ansehen: nichts abgeschnitten, Farben stimmen.

  • Step 6: Commit – git commit -m "feat(card): Netzwerk- und Marken-Logos"

Task 4: Renderer

Files:

  • Create: web/src/card/render.ts
  • Test: web/src/card/render.test.ts

Interfaces:

  • Consumes: Task 2 (CardConfig, layoutFor, fitNetwork, fitBrand, imageRect, clampCrop, TONE_COLOR, CARD_W/H), Task 3 (getNetworkMark, getBrandMark, svgAspect, loadMarkImage)

  • Produces:

    type CardImage = { source: CanvasImageSource; w: number; h: number }
    interface CardAssets { image: CardImage | null; network: CanvasImageSource | null; brand: CanvasImageSource | null }
    type Ctx = Pick<CanvasRenderingContext2D, 'save'|'restore'|'scale'|'setTransform'|'fillRect'|'drawImage'|'fillText'|'measureText'|'beginPath'|'arc'|'fill'|'stroke'|'roundRect'|'clip'|'moveTo'|'lineTo'|'createLinearGradient'> & { fillStyle: unknown; strokeStyle: unknown; lineWidth: number; lineCap: CanvasLineCap; font: string; textAlign: CanvasTextAlign; textBaseline: CanvasTextBaseline }
    renderCard(ctx: Ctx, config: CardConfig, assets: CardAssets, strokePath?: (ctx: Ctx, d: string) => void): void   // zeichnet in 1536×969-Koordinaten
    drawCard(canvas: HTMLCanvasElement | OffscreenCanvas, config: CardConfig, assets: CardAssets): void   // getContext('2d', { alpha: false }), setTransform(1,0,0,1,0,0), scale(canvas.width/1536, canvas.height/969), renderCard
    loadCardAssets(config: CardConfig, image: CardImage | null): Promise<CardAssets>   // wartet auf document.fonts.load('700 46px "Nunito Sans Variable"'); lädt Netzwerk- und Brand-Logo immer (außer brand 'none'), unabhängig von der Vorlage, in Details-Farbe
    exportCard(config: CardConfig, assets: CardAssets, opts: { width: number; type: 'image/png' | 'image/webp'; quality?: number }): Promise<Blob>   // Canvas width × round(width·969/1536), drawCard, toBlob
    

    strokePath Standard: ctx.stroke(new Path2D(d)). Vorschau, Vorlagen-Miniaturen und Export benutzen alle drawCard.

  • Zeichenreihenfolge (fest):

    1. fillRect(0,0,1536,969) mit #1B2738.
    2. Bild mit imageRect(image, clampCrop(image, config.crop)) (defensiv begrenzt).
    3. Chip: abgerundetes Rechteck mit Verlauf hell #F2F2F2 → #A9A9A9, Linien #7A7A7A; dunkel #4A4A4A → #1C1C1C, Linien #0A0A0A; Linienbreite 3, auf das Rechteck beschnitten.
    4. Kontaktlos-Bögen in Details-Farbe, lineCap 'round'.
    5. Nummer: Punkte als Kreise (arc) an startX + pitch·i + pitch/2 für i = 0–3, 5–8, 10–13 auf bulletY; Ziffern 8 für i = 15–18 mit textAlign 'center' auf baseline.
    6. PLATINUM: zeichenweise mit textAlign 'left'; Gesamtbreite = Σ measureText(Buchstabe).width + (n−1)·trackingPx (keine Laufweite nach dem letzten Buchstaben); bei align 'right' Start = x − Gesamtbreite.
    7. Brand-Logo in fitBrand(svgAspect(getBrandMark(id).svg), getBrandMark(id).maxH).
    8. Netzwerk-Logo in fitNetwork(svgAspect(getNetworkMark(id).svg), getNetworkMark(id).maxH). Schrift `700 ${size}px "Nunito Sans Variable"`, Text in TONE_COLOR[config.details].
  • Step 1: Tests schreiben – aufzeichnender Fake-Ctx (measureText → { width: text.length * 20 }); vi.mock('./marks', …): getNetworkMark → { svg: '<svg viewBox="0 0 330 109"></svg>', maxH: 109, … }, getBrandMark → viewBox 0 0 100 100, maxH 100, svgAspect echt oder aus der viewBox:

    • erster Aufruf fillRect(0,0,1536,969) mit fillStyle '#1B2738', auch mit Bild (transparentes PNG).
    • image mit Bild: genau ein drawImage (das Bild), kein fillText.
    • Bild 3000×1000 mit crop.x = 9999: Bild-drawImage-Rechteck deckt 0..1536 × 0..969 ab.
    • minimal: drawImage(network, 1150, 804, 330, 109) (≈).
    • classic, details 'dark': alle fillText mit fillStyle '#111111'; letzter PLATINUM-Buchstabe endet bei x ≈ 1480; vier fillText('8', x, 650) mit x ≈ 863.8 / 909.4 / 955 / 1000.6; zwölf arc mit y 624.
    • signature, brand 'none': kein Brand-drawImage; erster PLATINUM-Buchstabe bei x 60.
    • ohne Bild: kein Bild-drawImage, Grund gefüllt.
  • Step 2: Tests laufen lassen → FAIL.

  • Step 3: Implementieren.

  • Step 4: Tests laufen lassen → PASS; npx tsc --noEmit sauber.

  • Step 5: Commit – git commit -m "feat(card): Canvas-Renderer und Export"

Task 5: Editor-Oberfläche

Files:

  • Create: web/src/editor/state.ts, web/src/editor/upload.ts, web/src/editor/editor-page.ts, web/src/editor/templates-panel.ts, web/src/editor/preview.ts, web/src/editor/options-panel.ts, web/src/editor/download.ts, web/src/editor/editor.css
  • Modify: web/src/main.ts (Store einmal erzeugen; Route editor → mountEditor)
  • Test: web/src/editor/state.test.ts, web/src/editor/upload.test.ts, web/src/editor/download.test.ts

Interfaces:

  • Consumes: Tasks 2–4.

  • Produces:

    interface SavedCardRef { id: string; name: string; imageFileId: string; previewFileId: string; updatedAt: string }
    interface EditorState { config: CardConfig; image: CardImage | null; imageBlob: Blob | null; imageChanged: boolean; saved: SavedCardRef | null; cardName: string; saving: boolean }
    createStore(initial: EditorState): EditorStore   // { get(): EditorState; set(patch: Partial<EditorState>): void; subscribe(fn: (s: EditorState) => void): () => void }
    initialEditorState(): EditorState   // DEFAULT_CONFIG, kein Bild, saved null, cardName 'Meine Karte'
    setImage(store: EditorStore, image: CardImage, blob: Blob): void   // setzt image/imageBlob, imageChanged true, crop = DEFAULT_CROP
    validateImageFile(f: { type: string; size: number }): { ok: true } | { ok: false; message: string }
    decodeImage(blob: Blob): Promise<CardImage>   // source = HTMLImageElement; reject mit Meldung „Das Bild konnte nicht gelesen werden.“
    downloadFileName(cardName: string, date: Date): string
    mountEditor(root: HTMLElement, store: EditorStore, hooks: { onSave(): void }): () => void
    

    hooks.onSave ist bis Task 9 ein No-op.

  • UI (Spec §4): drei Spalten (grid-template-columns: 300px 1fr 400px), unter 900 px einspaltig (Vorschau, Vorlagen als waagerechte Scroll-Reihe, Optionen). Vorlagenkacheln mit Live-Miniatur (Canvas 384 × 242 über drawCard), Titel/Untertitel: Nur Bild / „Bild unverändert lassen“, Minimalistisch / „Dein Bild steht im Mittelpunkt“, Klassisch / „Die vertrauten Kartendetails“, Signature / „Ein bisschen mehr Persönlichkeit“; Auswahl = Rahmen --sel-line + Häkchen. Mitte ohne Bild: gestrichelte Ablagefläche (Icon, „Ziehe deine Idee hierher“, Knopf „Bild auswählen“, „PNG / JPG / WebP · Max. 5 MiB“, Drag-and-drop + Dateiauswahl); mit Bild: Canvas 1536 × 969 per CSS skaliert, border-radius: 3.711% / 5.882%, Ziehen per Pointer-Events (panBy, CSS-Delta × 1536 / clientWidth), Zoom-Regler 1–4 (Schritt 0.01). Darunter „1536 × 969 PNG“, „Bild ersetzen“, Herunterladen, Speichern – beide ohne Bild deaktiviert (title „Erst ein Bild wählen“); Hinweis „Dein Bild bleibt auf deinem Gerät, bis du speicherst.“ Rechts „Individuell gestalten“: „Kartennetzwerk“ (3-spaltiges Raster, Knopf = Logo + Label, aria-label „ – “), „Details“ (Hell/Dunkel als Radio-Knöpfe), „Logo“ (AirCards · Symbol, AirCards · Text, Apple, Kein Logo).

  • Download: exportCard(config, assets, { width: 1536, type: 'image/png' }) über <a download>; Blob > 4 000 000 Byte → Hinweis „Das PNG ist größer als 4 MB – Wallet nimmt eventuell nur kleinere Bilder.“

  • Ungültige Datei: Meldung an Ablagefläche bzw. unter der Karte, Zustand bleibt.

  • Step 1: Tests schreiben

    • state: set mischt ein und benachrichtigt; nach unsubscribe keine Aufrufe; setImage nach crop {x: 300, y: 0, zoom: 2} → crop = DEFAULT_CROP, imageChanged true.
    • upload: PNG 1 000 Byte → ok; image/heic → 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.'; PNG 5 242 881 Byte → 'Das Bild ist größer als 5 MiB.'; genau 5 242 880 → ok; leerer Typ → Typ-Meldung.
    • download: downloadFileName('Mein Urlaub!', d) → 'aircard-mein-urlaub.png'; downloadFileName(' ', new Date('2026-10-04T10:00:00Z')) → 'aircard-2026-10-04.png'; 'Grüße' → 'aircard-gruesse.png'.
  • Step 2: Tests laufen lassen → FAIL.

  • Step 3: Implementieren.

  • Step 4: Tests + Build – npx vitest run → PASS; npm run build → ok.

  • Step 5: Commit – git commit -m "feat(editor): Editor-Oberfläche mit Upload, Vorschau und Download" (Sichtprüfung im Browser folgt in Task 6 Step 9).

Task 6: Deployment auf aircard-ios.org

Files:

  • Create: deploy/deploy.sh, deploy/webhook-server.js, deploy/webhook-server.test.js, deploy/nginx/aircard-ios.org.conf, deploy/traefik/aircard.yml, deploy/compose-snippet.yml, deploy/README.md
  • Server (außerhalb des Repos): /home/webklar/apps/aircard-ios.org/{build/,out/,deploy.sh,webhook-server.js,nginx/aircard-ios.org.conf}, /home/webklar/apps/traefik/dynamic/aircard.yml, /home/webklar/apps/docker-compose.yml

Interfaces:

  • webhook-server.js (CommonJS, Node 22, ohne Abhängigkeiten):

    • verifySignature(raw: Buffer, signatureHex: string | undefined, secret: string): boolean – Header x-gitea-signature (kleingeschriebenes Hex von HMAC-SHA256 über den Rohbody, ohne Präfix); false bei leerem Secret, fehlender Signatur oder ungleicher Länge (vor timingSafeEqual prüfen); keine anderen Header-Fallbacks.
    • branchFromPayload(body: unknown): string | null – refs/heads/<x> → <x>.
    • createServer({ secret, branch, runDeploy }): http.Server – POST /webhook/deploy: 401 bei falscher Signatur; 202 {"queued":true} bei passendem Branch; 200 {"ignored":true} sonst; alles andere 404. Läufe nacheinander; höchstens ein Lauf wartet zusätzlich zum laufenden.
    • Start nur bei require.main === module: Ist AIRCARD_WEBHOOK_TOKEN leer → Fehlermeldung ohne Wert, process.exit(1). Port AIRCARD_WEBHOOK_PORT (8087), Branch AIRCARD_DEPLOY_BRANCH (main), Skript AIRCARD_DEPLOY_SCRIPT (/deploy.sh) mit DEPLOY_TRIGGER im Env; beim Start initial, dann alle POLL_INTERVAL_MINUTES (15) poll.
  • deploy.sh (POSIX sh, set -e): Variablen REPO_DIR (/app), OUT_DIR (/out), GITEA_URL, GITEA_USER (knso), GITEA_REPO (AirCardIOS), GITEA_TOKEN (optional), APP_SUBDIR (web), DEPLOY_BRANCH (main), DEPLOY_TRIGGER. Ablauf: klonen bzw. fetch + reset --hard origin/$DEPLOY_BRANCH + clean -fd; case "${DEPLOY_TRIGGER:-manual}" in poll|initial) → ist Marker $REPO_DIR/.git/aircard-last-deployed-commit = HEAD und $OUT_DIR/index.html vorhanden → „unverändert“, Exit 0;; esac (webhook/manual/leer bauen immer); Marker = HEAD vor dem Build schreiben (zuletzt versuchter Commit – ein kaputter Build wird nicht alle 15 Minuten wiederholt; alte Seite bleibt online); cd $APP_SUBDIR && npm ci --no-audit --no-fund && npm run build; dist/index.html prüfen; rm -rf $OUT_DIR/* && cp -r dist/. $OUT_DIR/; letzte Zeile echo "Deployment abgeschlossen"; Fehlerzeilen beginnen mit ERROR:. Manuell: docker exec webklar-aircard-build sh /deploy.sh.

  • nginx: Server-Header X-Frame-Options SAMEORIGIN, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin; gzip; location ^~ /assets/ { try_files $uri =404; expires 1y; access_log off; }; location ^~ /v1/ { return 404; }; location = /index.html { expires -1; }; Bild-/Font-Regex 7 Tage; location / { try_files $uri $uri/ /index.html; expires -1; }; location ~ /\.(?!well-known) { deny all; }.

  • traefik/aircard.yml: Router aircard-api, rule Host(`aircard-ios.org`) && PathPrefix(`/v1`) && Header(`X-Appwrite-Project`, `aircard`), entryPoints [websecure], service appwrite@docker, priority 150, tls.certResolver: letsencrypt.

  • compose-snippet.yml (an den eship-Block anhängen): aircard-build = node:22-alpine, container_name webklar-aircard-build, restart unless-stopped, init: true, mem_limit 768m, working_dir /app, Env GITEA_URL=https://git.webklar.com, GITEA_USER=knso, GITEA_REPO=AirCardIOS, GITEA_TOKEN=${GITEA_DEPLOY_TOKEN}, APP_SUBDIR=web, DEPLOY_BRANCH=main, AIRCARD_DEPLOY_BRANCH=main, AIRCARD_WEBHOOK_TOKEN=${GITEA_DEPLOY_WEBHOOK_SECRET}, AIRCARD_WEBHOOK_PORT=8087, AIRCARD_DEPLOY_SCRIPT=/deploy.sh, POLL_INTERVAL_MINUTES=15, NODE_OPTIONS=--max-old-space-size=512, VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1, VITE_APPWRITE_PROJECT_ID=aircard; Volumes ./aircard-ios.org/build:/app, ./aircard-ios.org/out:/out, ./aircard-ios.org/deploy.sh:/deploy.sh:ro, ./aircard-ios.org/webhook-server.js:/webhook/server.js:ro; Netz webklar-network; Command /bin/sh -c "apk add --no-cache git && node /webhook/server.js"; Labels Router aircard-webhook Host(`aircard-ios.org`) && Path(`/webhook/deploy`), websecure, letsencrypt, priority 200, Service-Port 8087. aircard = nginx:alpine, container_name webklar-aircard, restart unless-stopped, mem_limit 64m, Volumes out (ro) + nginx-Konfig (ro), Netz webklar-network; Labels Router aircard Host(`aircard-ios.org`) websecure/letsencrypt, Service-Port 80; Router aircard-http (entrypoint web) mit Middleware aircard-https (redirectscheme https, permanent). Zusätzlich auskommentiert die www-Labels für Step 10.

  • Step 1: Tests schreiben – deploy/webhook-server.test.js (node:test): Signatur gültig; ungültig ('deadbeef', kürzer) → false ohne Exception; leeres Secret → false; fehlender Header → false; branchFromPayload({ref:'refs/heads/main'}) → 'main', {} → null; Server auf Port 0: unsignierter POST → 401; signierter Push auf main → 202 und runDeploy('webhook'); Push auf dev → 200 ignoriert; GET / → 404; drei schnelle signierte Pushes bei blockiertem runDeploy → alle 202, nach Freigabe genau 2 Läufe.

  • Step 2: Tests laufen lassen – node --test deploy/webhook-server.test.js → FAIL.

  • Step 3: Dateien in deploy/ schreiben; deploy/README.md nennt Kopierziele, Start, manuellen Deploy und Webhook.

  • Step 4: Prüfen – node --test deploy/webhook-server.test.js → PASS; sh -n deploy/deploy.sh → ok; python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" deploy/traefik/aircard.yml → ok.

  • Step 5: Commit + erster Push – git commit -m "feat(deploy): Webhook, Build-Skript, nginx, Traefik". Push-Remote wie bei den anderen knso-Arbeitskopien (Token-URL aus einer bestehenden knso-Arbeitskopie übernehmen, nie ausgeben), git push -u origin main. Blockiert der Auto-Modus den Push: Kenso bitten, git -C /home/webklar/apps/aircard-ios.org/dev push -u origin main auszuführen, und warten.

  • Step 6: Server einrichten – Ordner build/, out/, nginx/ unter /home/webklar/apps/aircard-ios.org/; deploy.sh (chmod +x), webhook-server.js, nginx-Konfig kopieren; cp docker-compose.yml docker-compose.yml.bak-2026-10-04-aircard; Services aus deploy/compose-snippet.yml nach dem eship-Block einfügen; docker compose config -q; echo $? → 0, einzige erlaubte Ausgabe ist die bestehende Warnung „attribute version is obsolete“. Traefik-Datei atomar: cp deploy/traefik/aircard.yml /home/webklar/apps/traefik/dynamic/aircard.yml.tmp && mv /home/webklar/apps/traefik/dynamic/aircard.yml.tmp /home/webklar/apps/traefik/dynamic/aircard.yml; danach docker logs --since 2m webklar-traefik 2>&1 | grep -iE 'error|aircard' → kein Fehler des File-Providers (bekannte ACME-Fehler anderer Hosts ignorieren).

  • Step 7: Starten – cd /home/webklar/apps && docker compose up --dry-run -d aircard-build aircard → nur webklar-aircard-build und webklar-aircard werden angelegt; dann ohne --dry-run. Auf das Ende warten: for i in $(seq 1 60); do docker logs webklar-aircard-build 2>&1 | grep -qE 'Deployment abgeschlossen|ERROR:' && break; sleep 5; done; docker logs --tail 40 webklar-aircard-build → „Deployment abgeschlossen“.

  • Step 8: Gitea-Webhook – an knso/AirCardIOS: Typ gitea, URL https://aircard-ios.org/webhook/deploy, Content-Type json, Secret = Wert von GITEA_DEPLOY_WEBHOOK_SECRET aus /home/webklar/apps/.env, nur Push-Events, Branch-Filter main. Per Gitea-API (POST /api/v1/repos/knso/AirCardIOS/hooks) mit GITEA_SYSTEM_TOKEN aus .env, beide Werte nur innerhalb des Befehls, nie ausgeben. Blockiert der Auto-Modus das: Kenso trägt den Hook in der Gitea-Oberfläche ein und kopiert das Secret selbst aus .env (Zeile GITEA_DEPLOY_WEBHOOK_SECRET=…, volle 64 Zeichen); Claude nennt nur URL, Content-Type, Events, Branch-Filter. Prüfen mit „Test delivery“ → Antwort 202 (401 = Secret falsch), Log zeigt Branch main.

  • Step 9: Prüfen

    • curl -sI http://aircard-ios.org → 301 auf https; curl -sI https://aircard-ios.org → 200 mit gültigem Zertifikat (ohne -k).
    • curl -s -o /dev/null -w '%{http_code}' https://aircard-ios.org/karten → 200, Body enthält id="app-root".
    • curl -sI https://aircard-ios.org/assets/<eine .js-Datei aus out/assets> → Cache-Control: max-age=31536000.
    • curl -s -o /dev/null -w '%{http_code}' -X POST https://aircard-ios.org/webhook/deploy → 401.
    • curl -s -i -H 'X-Appwrite-Project: aircard' https://aircard-ios.org/v1/health/version → solange das Projekt noch nicht existiert: Header Server: Appwrite und Body mit "type":"project_not_found" (beweist die Route); ohne Header → 404 von nginx.
    • nach 2 Minuten Leerlauf docker stats --no-stream webklar-aircard webklar-aircard-build → nginx < 20 MB, Build-Container < 250 MB.
    • Editor im Browser (Claude in Chrome): Bild hochladen, alle vier Vorlagen, Stichprobe Logos, Hell/Dunkel, Verschieben/Zoom, Download → PNG 1536 × 969; HEIC-Datei → Meldung.
  • Step 10: www (nur wenn DNS da) – liefert dig +short www.aircard-ios.org @8.8.8.8 91.99.156.85: die auskommentierten www-Labels am Service aircard aktivieren (Router aircard-www websecure/letsencrypt und aircard-www-http web, Middleware aircard-www = redirectregex ^https?://www\\.aircard-ios\\.org(.*) → https://aircard-ios.org$${1}, permanent), Backup, docker compose config -q, docker compose up -d aircard; curl -sI 'https://www.aircard-ios.org/x?y=1' → 301 auf https://aircard-ios.org/x?y=1. Ohne DNS überspringen und im Bericht nennen.

  • Step 11: Commit (falls Anpassungen); Memory-Notiz aktualisieren.

Task 7: Appwrite-Projekt und Schema

Files:

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

Interfaces:

  • Ergebnis: Datenbank aircard (Name „AirCard“); Tabelle cards (Name „Cards“, Rechte ['create("users")'], rowSecurity: true, Spalten name string 80, config string 2000, imageFileId string 36, previewFileId string 36, alle required: true); Bucket card-images (Name „Card Images“, Rechte ['create("users")'], fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['png','jpg','jpeg','webp'], compression: 'none', antivirus: false; Verschlüsselung/Transformationen Standard). Keine eigenen Indizes – orderDesc('$updatedAt') nutzt den Systemindex.

  • Aufruf: node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]. Endpoint AIRCARD_APPWRITE_ENDPOINT (Standard https://appwrite.webklar.com/v1), Projekt aircard, Key AIRCARD_APPWRITE_API_KEY. Jede Anfrage mit Content-Type: application/json, X-Appwrite-Project, X-Appwrite-Key. Bodies: POST /tablesdb {databaseId:'aircard', name:'AirCard'}; POST /tablesdb/aircard/tables {tableId:'cards', name:'Cards', permissions:['create("users")'], rowSecurity:true}; POST /tablesdb/aircard/tables/cards/columns/string {key, size, required:true} (Antwort 202, status: 'processing'); POST /storage/buckets {bucketId:'card-images', name:'Card Images', permissions, fileSecurity, maximumFileSize, allowedFileExtensions, compression, antivirus}.

  • Logik: je Objekt erst GET; 404 mit type !== 'project_not_found' = fehlt → anlegen; jeder andere Fehler (401, 404 project_not_found, 5xx) → Abbruch mit klarer Meldung, ohne den Key auszugeben. Spalten nacheinander anlegen, danach bis 30 s per GET auf status: 'available' warten (bereit vor dem ersten Schreibzugriff). Vergleichen und Abweichungen melden (nicht ändern): $permissions, rowSecurity, Spalten size/required/status, Bucket fileSecurity, maximumFileSize, allowedFileExtensions (sortiert). --check legt nichts an, meldet Fehlendes/Abweichendes und endet dann mit Exit 1, sonst „OK: Schema vollständig“ und Exit 0.

  • Step 1: Klickliste schreiben – docs/appwrite-console.md für Kenso: Projekt „AirCard“ mit ID aircard in der bestehenden Organisation; Plattform Web aircard-ios.org und localhost; Auth: nur E-Mail/Passwort an (Magic URL, E-Mail-OTP, Telefon, Anonym, JWT aus), Sitzungsdauer 30 Tage, max. 5 Sitzungen, Nutzerlimit 2000, Passwort-Wörterbuch an; Dienste aus: Functions, Sites, Messaging, Teams, GraphQL; API-Key aircard-provision mit databases.read/write, tables.read/write, columns.read/write, buckets.read/write; Zeile AIRCARD_APPWRITE_API_KEY=… in /home/webklar/apps/.env eintragen. Commit.

  • Step 2: Kenso bitten, die Klickliste abzuarbeiten; warten, bis er Bescheid gibt.

  • Step 3: Skript schreiben.

  • Step 4: Vorher prüfen – node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check → Exit 1, listet Datenbank, Tabelle, 4 Spalten, Bucket als fehlend.

  • Step 5: Anlegen – ohne --check → alles angelegt.

  • Step 6: Nachher prüfen – --check → „OK: Schema vollständig“, Exit 0; erneuter Lauf ohne --check legt nichts doppelt an; curl -s -H 'X-Appwrite-Project: aircard' https://aircard-ios.org/v1/health/version → {"version":"1.8.1"}.

  • Step 7: Commit – git commit -m "feat(appwrite): Setup-Skript und Console-Anleitung"

Task 8: Konten (Login, Registrierung, Passwort)

Files:

  • Create: web/src/account/appwrite.ts, web/src/account/auth.ts, web/src/account/errors.ts, web/src/account/login-dialog.ts, web/src/account/account-menu.ts, web/src/pages/reset-page.ts, web/src/account/account.css
  • Modify: web/src/main.ts (Konto-Menü in #account-slot, Route reset)
  • Test: web/src/account/errors.test.ts, web/src/account/auth.test.ts

Interfaces:

  • Produces:

    // appwrite.ts
    client: Client; account: Account; tables: TablesDB; storage: Storage
    APPWRITE_ENDPOINT: string; APPWRITE_PROJECT: string
    IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const
    // auth.ts
    type User = { $id: string; email: string }
    getCurrentUser(): Promise<User | null>   // 401 → null; Antwort ohne string-$id/email → throw Error('Unerwartete Antwort')
    currentUserId(): string | null           // Cache aus dem letzten getCurrentUser/login/register/logout
    register(email: string, password: string): Promise<User>   // account.create({ userId: ID.unique(), email, password, name: email.split('@')[0].slice(0,128) }), dann login
    login(email: string, password: string): Promise<User>
    logout(): Promise<void>                  // 401 ignorieren, Cache leeren, onAuthChange(null)
    requestPasswordReset(email: string): Promise<void>   // URL `${location.origin}/passwort-neu`
    completePasswordReset(userId: string, secret: string, password: string): Promise<void>
    onAuthChange(fn: (u: User | null) => void): () => void
    validatePassword(pw: string): string | null   // 8–256 Zeichen, sonst Meldung
    // errors.ts
    type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load'
    errorMessage(err: unknown, ctx: ErrorContext): string
    isUnauthorized(err: unknown): boolean    // code 401
    // login-dialog.ts
    requireLogin(view?: 'login' | 'register' | 'forgot'): Promise<boolean>   // <dialog>; true nach Login/Registrierung, false bei Abbruch
    

    SDK-Aufrufe im Objekt-Parameter-Stil von appwrite 22.4.1.

  • errorMessage – zuerst type, dann code; bei general_argument_invalid den Parameter per /`(\w+)` param/ aus err.message lesen:

    • user_invalid_credentials → „E-Mail oder Passwort ist falsch.“
    • user_already_exists → „Diese E-Mail ist schon registriert. Melde dich an.“
    • user_invalid_token → „Der Link ist ungültig oder abgelaufen.“
    • user_not_found: ctx recover → „Zu dieser E-Mail gibt es kein Konto.“; ctx reset → „Der Link ist ungültig oder abgelaufen.“
    • general_argument_invalid + Parameter email → „Bitte gib eine gültige E-Mail-Adresse ein.“; password + ctx login → „E-Mail oder Passwort ist falsch.“; password sonst: Nachricht enthält „commonly used“ → „Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.“, sonst „Das Passwort muss 8 bis 256 Zeichen lang sein.“; userId/secret → „Der Link ist ungültig oder abgelaufen.“
    • row_not_found → „Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.“
    • storage_file_not_found → „Das Bild dieser Karte fehlt.“
    • general_unknown_origin → „Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.“
    • user_count_exceeded → „Zurzeit sind keine neuen Konten möglich.“
    • Code 429 → „Zu viele Versuche – bitte kurz warten.“
    • TypeError (Netz) → „Keine Verbindung zum Server.“
    • sonst → „Etwas ist schiefgelaufen. Bitte nochmal versuchen.“
  • UI: Dialog mit Reitern „Anmelden“ / „Registrieren“ (E-Mail + Passwort; Registrieren mit zweitem Passwortfeld, Abgleich „Die Passwörter stimmen nicht überein.“ und validatePassword vor dem Senden), Link „Passwort vergessen?“ → E-Mail-Feld, Erfolg „Wir haben dir einen Link geschickt. Er gilt eine Stunde.“; bei user_already_exists Link „Anmelden“ zum Reiter. Konto-Menü: abgemeldet „Anmelden“; angemeldet E-Mail + „Abmelden“. /passwort-neu: liest userId, secret; zwei Passwortfelder; Erfolg → „Passwort geändert. Du kannst dich jetzt anmelden.“ + „Anmelden“. „Der Link ist ungültig oder abgelaufen.“ + Knopf „Neuen Link anfordern“ (öffnet requireLogin('forgot')) bei fehlenden Parametern, user_invalid_token, user_not_found und general_argument_invalid mit Parameter userId/secret; nur general_argument_invalid mit password zeigt den Hinweis am Feld.

  • Step 1: Tests schreiben – errors.test.ts: jede Zeile oben mit Fake-Fehlern { code, type, message }, dabei echte Server-Texte: 'Invalid \password` param: Password must be between 8 and 256 characters long.'(ctxlogin→ falsches Passwort, ctxregister→ 8–256),'Invalid `email` param: Value must be a valid email address', 'Invalid `password` param: Password must be at least 8 characters and should not be one of the commonly used password.'(register → „zu verbreitet“),'Invalid `userId` param: …'(reset → Link ungültig);new TypeError('Failed to fetch'); isUnauthorized({code:401})true,{code:404}false.auth.test.tsmitvi.mock('./appwrite'): getCurrentUserbei Antwort{ message: '<!doctype html>' }→ wirft; bei 401 →nullundcurrentUserId()→null; bei { $id: 'u1', email: 'a@b.de' }→ User undcurrentUserId()→'u1'; logoutbei 401 löst auf und ruftonAuthChange(null); validatePassword('kurz')→ Meldung, 8 Zeichen →null`.

  • Step 2: Tests laufen lassen → FAIL.

  • Step 3: Implementieren.

  • Step 4: Tests + Build – PASS; npm run build ok.

  • Step 5: Commit + Push – git commit -m "feat(account): Login, Registrierung, Passwort zurücksetzen"; Push nach main (wie Task 6 Step 5); Webhook-Build abwarten (Log „Deployment abgeschlossen“).

  • Step 6: Live prüfen – Kenso registriert auf https://aircard-ios.org ein Testkonto, meldet sich ab und wieder an, probiert einmal ein falsches Passwort und „Passwort vergessen“ mit seinem Postfach (Mail kommt an, Text beginnt mit „Hallo ,“, Link setzt neues Passwort). Danach prüft Claude in Kensos angemeldetem Chrome: localStorage.getItem('cookieFallback') → null; Konto-Menü zeigt die E-Mail; falls read_network_requests die Login-Antwort zeigt: Set-Cookie … Domain=.aircard-ios.org; Secure; HttpOnly. Scheitert das Cookie, Fallback aus Spec §3 (Endpoint https://appwrite.webklar.com/v1) umsetzen und Kenso informieren.

Task 9: Karten speichern und „Meine Karten“

Files:

  • Create: web/src/account/cards.ts, web/src/account/with-login.ts, web/src/editor/save.ts, web/src/pages/cards-page.ts, web/src/pages/cards.css
  • Modify: web/src/editor/editor-page.ts (Speichern an createSaveAction, Laden über ?karte=<id>), web/src/main.ts (Route cards)
  • Test: web/src/account/cards.test.ts, web/src/account/with-login.test.ts, web/src/editor/save.test.ts

Interfaces:

  • Consumes: Task 2 (parseConfig, serializeConfig, clampCrop), Task 4 (exportCard, loadCardAssets, drawCard), Task 5 (EditorStore, SavedCardRef, setImage, decodeImage, downloadFileName), Task 8 (client, IDS, tables, storage, getCurrentUser, currentUserId, requireLogin, errorMessage, isUnauthorized).

  • Produces:

    interface CardRowData { name: string; config: string; imageFileId: string; previewFileId: string }
    interface CardRow extends CardRowData { $id: string; $updatedAt: string }
    interface CardsApi {
      createFile(file: File, permissions: string[]): Promise<{ $id: string }>
      deleteFile(fileId: string): Promise<void>
      fetchFile(fileId: string): Promise<Blob>
      createRow(data: CardRowData, permissions: string[]): Promise<CardRow>
      updateRow(id: string, data: Partial<CardRowData>): Promise<CardRow>
      getRow(id: string): Promise<CardRow>
      deleteRow(id: string): Promise<void>
      listRows(): Promise<CardRow[]>
    }
    interface SavedCard extends SavedCardRef { config: CardConfig }
    interface SaveInput { name: string; config: CardConfig; image: Blob; imageChanged: boolean; preview: Blob; previous: SavedCardRef | null }
    normalizeName(name: string): string          // trim, Leerraum zusammenfassen, max. 80 Codepoints, leer → 'Meine Karte'
    ownerPermissions(userId: string): string[]   // ['read("user:<id>")','update("user:<id>")','delete("user:<id>")']
    toUploadFile(blob: Blob, base: 'image' | 'preview'): File   // Name base + '.' + Endung aus blob.type: png/jpg/webp; anderer Typ → Error
    createCardsService(api: CardsApi, userId: () => string): {
      list(): Promise<SavedCard[]>; get(id: string): Promise<SavedCard>; save(input: SaveInput): Promise<SavedCard>;
      rename(id: string, name: string): Promise<SavedCard>; remove(card: SavedCardRef): Promise<void>;
      loadImage(card: SavedCardRef): Promise<Blob>; loadPreview(card: SavedCardRef): Promise<Blob>
    }
    appwriteCardsApi(): CardsApi
    // with-login.ts
    withLogin<T>(fn: () => Promise<T>, requireLoginFn?: typeof requireLogin): Promise<T>   // bei isUnauthorized einmal requireLogin, dann fn erneut; Abbruch → throw LoginCancelled
    class LoginCancelled extends Error {}
    // save.ts
    type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
    createSaveAction(deps: { getUser(): Promise<User | null>; requireLogin(): Promise<boolean>; doSave(): Promise<void>; onStatus(s: SaveStatus, message?: string): void }): () => Promise<void>
    
  • appwriteCardsApi:

    • createFile → storage.createFile({ bucketId: IDS.bucket, fileId: ID.unique(), file, permissions }).
    • createRow → tables.createRow({ databaseId: IDS.database, tableId: IDS.table, rowId: ID.unique(), data, permissions }); updateRow ohne permissions.
    • listRows → queries: [Query.orderDesc('$updatedAt'), Query.limit(100)], Ergebnis .rows.
    • fetchFile: const url = new URL(\${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${id}/view`); const { uri, options } = client.prepareRequest('GET', url); const res = await fetch(uri, options); bei !res.okJSON lesen undthrow new AppwriteException(j.message ?? res.statusText, res.status, j.type ?? '', text); Blob ohne image/-Typ → AppwriteException('Unerwartete Antwort', 502, 'general_unknown', ''). Kommentar: kein getFileView` (Projekt als Query, trifft die Traefik-Route nicht).
  • userId-Thunk im Editor: () => { const id = currentUserId(); if (!id) throw Object.assign(new Error('unauthorized'), { code: 401 }); return id }.

  • Regeln für save:

    • neu (previous === null): createFile(toUploadFile(image,'image')) → createFile(toUploadFile(preview,'preview')) → createRow mit name: normalizeName(name), config: serializeConfig(config); scheitert ein Schritt, bereits hochgeladene Dateien löschen, Fehler weiterwerfen.
    • bestehend: Vorschau immer neu; Bild nur wenn imageChanged; updateRow; scheitert das Update, nur die neuen Dateien löschen. Danach alte Vorschau (und ggf. altes Bild) löschen – best effort: Fehler (auch 404) verschlucken, save löst mit der neuen SavedCard auf.
    • Dateien und Zeilen immer mit ownerPermissions(userId()); wirft userId(), wird nichts hochgeladen.
    • remove: Zeile löschen, dann beide Dateien; Datei-Fehler ignorieren.
  • createSaveAction: läuft schon ein Speichern → sofort zurück; ohne Nutzer → requireLogin(), bei false Status idle, Ende; doSave(); bei isUnauthorized einmal requireLogin() (bei false → idle) und erneut doSave(); Erfolg → saved; Fehler → error mit „Speichern fehlgeschlagen – “ + errorMessage(err, 'save').

  • Editor doSave: ohne imageBlob → onStatus('error', 'Bitte zuerst ein Bild wählen.') und Ende; erst await getCurrentUser() (Cache warm); Vorschau per exportCard(config, assets, { width: 480, type: 'image/webp', quality: 0.85 }); previous: store.get().saved; nach Erfolg saved = Ergebnis, imageChanged: false. Knopf zeigt „Speichert …“ (deaktiviert), danach 2 s „Gespeichert“.

  • Laden /?karte=<id> über withLogin: service.get → loadImage → decodeImage → setImage, danach config.crop = clampCrop(image, config.crop), imageChanged: false, saved = Karte; storage_file_not_found → Karte ohne Bild + Hinweis „Das Bild dieser Karte fehlt.“; row_not_found → Meldung aus errorMessage und leerer Editor; LoginCancelled → Hinweis „Melde dich an, um diese Karte zu öffnen.“

  • „Meine Karten“ (/karten): abgemeldet → „Melde dich an, um deine Karten zu sehen.“ + Knopf „Anmelden“; leer → „Noch keine Karten gespeichert.“ + Link zum Editor; sonst Kachelraster (Vorschau per loadPreview → Object-URL, beim Verlassen freigeben; Name; Datum dd.mm.yyyy). Aktionen: Öffnen (navigate('/?karte=<id>')), Herunterladen (Bild laden → drawCard → PNG mit downloadFileName), Umbenennen (Inline-Eingabe, Enter speichert, Esc bricht ab), Löschen (eigener Dialog „Karte „“ löschen?“, kein window.confirm). list, rename, remove, loadImage, loadPreview laufen über withLogin.

  • Step 1: Tests schreiben

    • cards.test.ts mit Fake-CardsApi (zeichnet Aufrufe auf, pro Methode einmal fehlschlagen lassen):
      • neu: zwei createFile mit ownerPermissions('u1'), Dateinamen image.png/preview.webp (je nach Blob-Typ; JPEG → image.jpg); createRow mit serializeConfig(c); Ergebnis mit geparster Config.
      • neu, createRow wirft → beide neuen Dateien gelöscht, Fehler kommt an.
      • neu, userId wirft → kein createFile.
      • bestehend ohne Bildwechsel: ein createFile (Vorschau), updateRow mit neuer previewFileId, danach deleteFile(alteVorschau), altes Bild bleibt.
      • bestehend mit Bildwechsel: zwei createFile, nach updateRow alte Dateien gelöscht.
      • bestehend, updateRow wirft → nur neue Dateien gelöscht.
      • bestehend, deleteFile(alteVorschau) wirft → save löst trotzdem auf.
      • remove: deleteRow vor deleteFile; deleteFile wirft → löst auf.
      • list: Zeile mit kaputtem config → DEFAULT_CONFIG.
      • toUploadFile(new Blob([], {type:'image/gif'}), 'image') → wirft.
      • normalizeName: ' Mein Urlaub ' → 'Mein Urlaub'; ' ' → 'Meine Karte'; 100 × '😀' → 80 Codepoints; 81 ASCII-Zeichen → 80.
    • with-login.test.ts: fn wirft {code:401}, requireLogin → true → fn zweimal, Ergebnis kommt an; requireLogin → false → LoginCancelled; anderer Fehler → durchgereicht, kein requireLogin.
    • save.test.ts: zwei schnelle Aufrufe → doSave einmal; ohne Nutzer + requireLogin → false → doSave nie, letzter Status idle; doSave wirft {code:401}, requireLogin → true → requireLogin einmal, doSave zweimal, Status saved; 401 und requireLogin → false → idle; anderer Fehler → error mit Präfix „Speichern fehlgeschlagen – “.
  • Step 2: Tests laufen lassen → FAIL.

  • Step 3: Implementieren.

  • Step 4: Tests + Build – PASS; npm run build ok.

  • Step 5: Commit + Push – git commit -m "feat(cards): Karten speichern und Meine Karten"; Push nach main, Build abwarten.

  • Step 6: Live prüfen (Claude in Chrome in Kensos angemeldeter Testsitzung): Karte gestalten → Speichern → neu laden → /karten zeigt Kachel → Öffnen lädt Bild und Einstellungen → Bild ersetzen + Speichern → Umbenennen → Herunterladen → eine eigens angelegte Testkarte löschen. Kenso meldet sich einmal ab, klickt Speichern und meldet sich im Dialog an → Karte gespeichert. Pflicht – zweites Konto: Kenso legt in einem privaten Fenster ein zweites Testkonto an und meldet sich dort an; Claude prüft in diesem Fenster fetch('/v1/tablesdb/aircard/tables/cards/rows/<ID Konto 1>', {headers: {'X-Appwrite-Project': 'aircard'}}) → 404 row_not_found, fetch('/v1/storage/buckets/card-images/files/<Datei-ID Konto 1>/view', {headers: {'X-Appwrite-Project': 'aircard'}}) → 404, /?karte=<ID Konto 1> zeigt „Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.“, /karten zeigt „Noch keine Karten gespeichert.“

Task 10: Abnahme und Abschluss

Files:

  • Modify: README.md (Zweck, lokale Entwicklung, Tests, Deploy, Appwrite-Setup, Verweis auf Spec/Plan)

  • Step 1: Gesamtprüfung nach Spec §8 – jeden Punkt der Abnahmeliste einzeln abhaken (Befehle aus Task 6 Step 9 – jetzt mit {"version":"1.8.1"} für den Header-Check –, Task 8 Step 6, Task 9 Step 6 inkl. zweitem Konto). Zusätzlich: jede Vorlage × Hell/Dunkel einmal heruntergeladen und angesehen (1536 × 969, eckig, Freibereich unten links nur Bild); im Scratchpad die PNG-Kopfdaten prüfen: Bytes 16–23 = Breite 1536, Höhe 969; Farbtyp (Byte 25) 2 = RGB erwartet – ist er 6 (RGBA), im Bericht als „deckendes RGBA“ vermerken; transparentes PNG als Quelle → deckendes Ergebnis; HEIC → Meldung; Handy-Breite 390 px (resize_window): kein waagerechtes Scrollen, Vorschau oben, Vorlagenreihe scrollt waagerecht.

  • Step 2: Testkonten – Kenso entscheidet, ob er sie behält oder in der Appwrite-Console löscht (Claude löscht keine Konten).

  • Step 3: README + Commit + Push – git commit -m "docs: README für AirCard"; Push.

  • Step 4: Bericht an Kenso: was live ist, Links, offene Punkte (www-DNS, Projekt-SMTP optional, Backup der Appwrite-Uploads separat); Memory aircard-website.md aktualisieren.