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

407 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AirCard-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`:
```ts
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:
```ts
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:
```ts
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:
```ts
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` „<group> – <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:
```ts
// 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.'` (ctx `login` → falsches Passwort, ctx `register` → 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.ts` mit `vi.mock('./appwrite')`: `getCurrentUser` bei Antwort `{ message: '<!doctype html>' }` → wirft; bei 401 → `null` und `currentUserId()` → `null`; bei `{ $id: 'u1', email: 'a@b.de' }` → User und `currentUserId()` → `'u1'`; `logout` bei 401 löst auf und ruft `onAuthChange(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 <Name>,“, 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:
```ts
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.ok` JSON lesen und `throw 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 „<Name>“ 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.