55 KiB
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(Branchmain, Remotehttps://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, Rand56, Vorschau-Radius57(nur Vorschau, nie im Export). - Upload: nur
image/png,image/jpeg,image/webp, max.5_242_880Byte. - Details-Farben: Hell
#FFFFFF, Dunkel#111111. Kartengrund ohne Bild bzw. unter transparenten Bildern:#1B2738. - Appwrite-IDs: Projekt
aircard, Datenbankaircard, Tabellecards, Bucketcard-images. - Frontend-Endpoint:
import.meta.env.VITE_APPWRITE_ENDPOINT ?? location.origin + '/v1', Projektimport.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 überfetchFile(Task 9). - Produktion: vor jeder Änderung an
/home/webklar/apps/docker-compose.ymlBackupdocker-compose.yml.bak-2026-10-04-aircard; nie nacktesdocker compose up -d;webklar-appwrite*,webklar-traefikundpreview-portalnicht 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üfungcd web && npx tsc --noEmit; Deploy-Testsnode --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),haikufü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 aufa[data-link]ab, reagiert aufpopstate). DOM-Gerüst inindex.html:<header id="app-header">,<main id="app-root">,<footer id="app-footer">, darin#account-slotin 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", Scriptsdev,build=tsc --noEmit && vite build,test=vitest run,preview), exakte Versionen,npm installerzeugt den Lockfile.vite.config.tsmit Vitestenvironment: 'happy-dom'..gitignore:node_modules/,dist/,.env*.tsconfig.json:strict,target/libES2022 + DOM,types: ["vite/client"]. -
Step 3: Layout und Stil –
index.html(lang="de", Titel „AirCard – Kartenmotiv gestalten“, Favicon/favicon.svg).styles.cssmit 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"überimport '@fontsource-variable/nunito-sans'inmain.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 NetworkIdundNETWORK_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, discoverinterface Crop { x: number; y: number; zoom: number }– x/y = Versatz der Bildmitte zur Kartenmitte in Karten-px, zoom ≥ 1 relativ zum Cover-Maßstabinterface 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_CROPparseConfig(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 vonpadY[0]bispadY[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/brandfalse, keine optionalen Felder;minimal: nurnetwork;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)undparseConfig('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 schneidenWALLET_KEEP_CLEARnicht: 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')hatnetwork === falseund 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.
- config: Rundlauf
-
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 --noEmitsauber. -
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>.svgfür alle 18NetworkIdsowieaircards-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#E8EEF7auf abgerundetem Quadrat#0F1828, keincurrentColor) - 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'.groupdient im Optionsfeld alsaria-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_MARKSLabelsAirCards · Symbol, AirCards · Text, Apple; jedessvgbeginnt mit<svg, hatviewBox, enthält weder<imagenoch<script;svgAspectendlich > 0;tint: truegenau fürvisa-auto,amex-outline,aircards-symbol,aircards-text,apple, diese enthaltencurrentColor, undtintSvg(svg, 'dark')enthält#111111und keincurrentColor;maxH109 fürvisa-*,mc-wordmark,mc-silver-text,up-english,discover; 125 fürmc-color,mc-silver,mc-classic,up-color,up-silver,up-white-frame,amex-blue,amex-outline,jcb; 90 füramex-select; 100 für alleBRAND_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-autofillcurrentColor,visa-blue#1434CB,visa-navy#1A1F71,visa-silverlinearer Verlauf#F4F4F4 → #A8A8A8 → #E2E2E2. - Mastercard:
mc-coloraus CommonsFile:Mastercard_2019_logo.svg;mc-silver= gleiche Kreise mit Silber-Verlauf, Überlappung#8C8C8C;mc-wordmarkaushttps://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg(Schriftzug weiß);mc-silver-text= Silber-Kreise + weißer Schriftzug;mc-classicaushttps://upload.wikimedia.org/wikipedia/commons/b/b7/MasterCard_Logo.svg. - UnionPay:
up-coloraus CommonsFile:UnionPay_logo.svg;up-silver= gleiche Form mit Silber-Verläufen;up-white-frame=up-colormit 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-blueoriginal#016FD0;amex-outlineBox transparent, Schrift/RahmencurrentColor;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 → #E5600Fersetzen, Schrift weiß. - Apple: Pfad aus Commons
File:Apple_logo_black.svg, fillcurrentColor. aircards-symbol: eigenes SVG, schräg gestellte abgerundete Karte (−12°) mit zwei kurzen Linien, fillcurrentColor;aircards-text: „AirCards“ als Pfade (Nunito Sans 800, einmalig mitopentype.jsim Scratchpad umgewandelt), fillcurrentColor.- Ist
/root/reports/aircard-marks-research/lesbar, dürfen die dortigen Kandidaten statt Neu-Download benutzt werden. - Alle Dateien mit
npx svgo@4 --multipassbereinigen; keine eingebetteten Bilder oder Schriftreferenzen.
- Visa: Pfad aus
-
Step 4:
marks.tsimplementieren. -
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, toBlobstrokePathStandard:ctx.stroke(new Path2D(d)). Vorschau, Vorlagen-Miniaturen und Export benutzen alledrawCard. -
Zeichenreihenfolge (fest):
fillRect(0,0,1536,969)mit#1B2738.- Bild mit
imageRect(image, clampCrop(image, config.crop))(defensiv begrenzt). - Chip: abgerundetes Rechteck mit Verlauf hell
#F2F2F2 → #A9A9A9, Linien#7A7A7A; dunkel#4A4A4A → #1C1C1C, Linien#0A0A0A; Linienbreite 3, auf das Rechteck beschnitten. - Kontaktlos-Bögen in Details-Farbe,
lineCap 'round'. - Nummer: Punkte als Kreise (
arc) anstartX + pitch·i + pitch/2für i = 0–3, 5–8, 10–13 aufbulletY; Ziffern8für i = 15–18 mittextAlign 'center'aufbaseline. - PLATINUM: zeichenweise mit
textAlign 'left'; Gesamtbreite = ΣmeasureText(Buchstabe).width+ (n−1)·trackingPx(keine Laufweite nach dem letzten Buchstaben); beialign 'right'Start = x − Gesamtbreite. - Brand-Logo in
fitBrand(svgAspect(getBrandMark(id).svg), getBrandMark(id).maxH). - Netzwerk-Logo in
fitNetwork(svgAspect(getNetworkMark(id).svg), getNetworkMark(id).maxH). Schrift`700 ${size}px "Nunito Sans Variable"`, Text inTONE_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→ viewBox0 0 100 100,maxH 100,svgAspectecht oder aus der viewBox:- erster Aufruf
fillRect(0,0,1536,969)mitfillStyle '#1B2738', auch mit Bild (transparentes PNG). imagemit Bild: genau eindrawImage(das Bild), keinfillText.- 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': allefillTextmitfillStyle '#111111'; letzter PLATINUM-Buchstabe endet bei x ≈ 1480; vierfillText('8', x, 650)mit x ≈ 863.8 / 909.4 / 955 / 1000.6; zwölfarcmit y 624.signature,brand 'none': kein Brand-drawImage; erster PLATINUM-Buchstabe bei x 60.- ohne Bild: kein Bild-
drawImage, Grund gefüllt.
- erster Aufruf
-
Step 2: Tests laufen lassen → FAIL.
-
Step 3: Implementieren.
-
Step 4: Tests laufen lassen → PASS;
npx tsc --noEmitsauber. -
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; Routeeditor→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 }): () => voidhooks.onSaveist 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 überdrawCard), 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:
setmischt ein und benachrichtigt; nachunsubscribekeine Aufrufe;setImagenachcrop {x: 300, y: 0, zoom: 2}→crop=DEFAULT_CROP,imageChangedtrue. - 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'.
- state:
-
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– Headerx-gitea-signature(kleingeschriebenes Hex von HMAC-SHA256 über den Rohbody, ohne Präfix);falsebei leerem Secret, fehlender Signatur oder ungleicher Länge (vortimingSafeEqualprü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: IstAIRCARD_WEBHOOK_TOKENleer → Fehlermeldung ohne Wert,process.exit(1). PortAIRCARD_WEBHOOK_PORT(8087), BranchAIRCARD_DEPLOY_BRANCH(main), SkriptAIRCARD_DEPLOY_SCRIPT(/deploy.sh) mitDEPLOY_TRIGGERim Env; beim Startinitial, dann allePOLL_INTERVAL_MINUTES(15)poll.
-
deploy.sh(POSIX sh,set -e): VariablenREPO_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.htmlvorhanden → „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.htmlprüfen;rm -rf $OUT_DIR/* && cp -r dist/. $OUT_DIR/; letzte Zeileecho "Deployment abgeschlossen"; Fehlerzeilen beginnen mitERROR:. 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: Routeraircard-api, ruleHost(`aircard-ios.org`) && PathPrefix(`/v1`) && Header(`X-Appwrite-Project`, `aircard`), entryPoints[websecure], serviceappwrite@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, EnvGITEA_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; Netzwebklar-network; Command/bin/sh -c "apk add --no-cache git && node /webhook/server.js"; Labels Routeraircard-webhookHost(`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, Volumesout(ro) + nginx-Konfig (ro), Netzwebklar-network; Labels RouteraircardHost(`aircard-ios.org`)websecure/letsencrypt, Service-Port 80; Routeraircard-http(entrypointweb) mit Middlewareaircard-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 undrunDeploy('webhook'); Push aufdev→ 200 ignoriert;GET /→ 404; drei schnelle signierte Pushes bei blockiertemrunDeploy→ 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.mdnennt 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 mainauszufü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 ausdeploy/compose-snippet.ymlnach dem eship-Block einfügen;docker compose config -q; echo $?→0, einzige erlaubte Ausgabe ist die bestehende Warnung „attributeversionis 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; danachdocker 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→ nurwebklar-aircard-buildundwebklar-aircardwerden 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, URLhttps://aircard-ios.org/webhook/deploy, Content-Type json, Secret = Wert vonGITEA_DEPLOY_WEBHOOK_SECRETaus/home/webklar/apps/.env, nur Push-Events, Branch-Filtermain. Per Gitea-API (POST /api/v1/repos/knso/AirCardIOS/hooks) mitGITEA_SYSTEM_TOKENaus.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(ZeileGITEA_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 Branchmain. -
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ältid="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: HeaderServer: Appwriteund 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.891.99.156.85: die auskommentierten www-Labels am Serviceaircardaktivieren (Routeraircard-wwwwebsecure/letsencrypt undaircard-www-httpweb, Middlewareaircard-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 aufhttps://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“); Tabellecards(Name „Cards“, Rechte['create("users")'],rowSecurity: true, Spaltennamestring 80,configstring 2000,imageFileIdstring 36,previewFileIdstring 36, allerequired: true); Bucketcard-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]. EndpointAIRCARD_APPWRITE_ENDPOINT(Standardhttps://appwrite.webklar.com/v1), Projektaircard, KeyAIRCARD_APPWRITE_API_KEY. Jede Anfrage mitContent-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, 404project_not_found, 5xx) → Abbruch mit klarer Meldung, ohne den Key auszugeben. Spalten nacheinander anlegen, danach bis 30 s per GET aufstatus: 'available'warten (bereit vor dem ersten Schreibzugriff). Vergleichen und Abweichungen melden (nicht ändern):$permissions,rowSecurity, Spaltensize/required/status, BucketfileSecurity,maximumFileSize,allowedFileExtensions(sortiert).--checklegt 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.mdfür Kenso: Projekt „AirCard“ mit IDaircardin der bestehenden Organisation; Plattform Webaircard-ios.orgundlocalhost; 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-Keyaircard-provisionmitdatabases.read/write,tables.read/write,columns.read/write,buckets.read/write; ZeileAIRCARD_APPWRITE_API_KEY=…in/home/webklar/apps/.enveintragen. 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--checklegt 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, Routereset) - 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 AbbruchSDK-Aufrufe im Objekt-Parameter-Stil von appwrite 22.4.1.
-
errorMessage– zuersttype, danncode; beigeneral_argument_invalidden Parameter per/`(\w+)` param/auserr.messagelesen: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: ctxrecover→ „Zu dieser E-Mail gibt es kein Konto.“; ctxreset→ „Der Link ist ungültig oder abgelaufen.“general_argument_invalid+ Parameteremail→ „Bitte gib eine gültige E-Mail-Adresse ein.“;password+ ctxlogin→ „E-Mail oder Passwort ist falsch.“;passwordsonst: 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
validatePasswordvor dem Senden), Link „Passwort vergessen?“ → E-Mail-Feld, Erfolg „Wir haben dir einen Link geschickt. Er gilt eine Stunde.“; beiuser_already_existsLink „Anmelden“ zum Reiter. Konto-Menü: abgemeldet „Anmelden“; angemeldet E-Mail + „Abmelden“./passwort-neu: liestuserId,secret; zwei Passwortfelder; Erfolg → „Passwort geändert. Du kannst dich jetzt anmelden.“ + „Anmelden“. „Der Link ist ungültig oder abgelaufen.“ + Knopf „Neuen Link anfordern“ (öffnetrequireLogin('forgot')) bei fehlenden Parametern,user_invalid_token,user_not_foundundgeneral_argument_invalidmit ParameteruserId/secret; nurgeneral_argument_invalidmitpasswordzeigt 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 buildok. -
Step 5: Commit + Push –
git commit -m "feat(account): Login, Registrierung, Passwort zurücksetzen"; Push nachmain(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; fallsread_network_requestsdie Login-Antwort zeigt:Set-Cookie … Domain=.aircard-ios.org; Secure; HttpOnly. Scheitert das Cookie, Fallback aus Spec §3 (Endpointhttps://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 ancreateSaveAction, Laden über?karte=<id>),web/src/main.ts(Routecards) - 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 });updateRowohnepermissions.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 ohneimage/-Typ →AppwriteException('Unerwartete Antwort', 502, 'general_unknown', ''). Kommentar: keingetFileView` (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'))→createRowmitname: 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,savelöst mit der neuenSavedCardauf. - Dateien und Zeilen immer mit
ownerPermissions(userId()); wirftuserId(), wird nichts hochgeladen. remove: Zeile löschen, dann beide Dateien; Datei-Fehler ignorieren.
- neu (
-
createSaveAction: läuft schon ein Speichern → sofort zurück; ohne Nutzer →requireLogin(), beifalseStatusidle, Ende;doSave(); beiisUnauthorizedeinmalrequireLogin()(beifalse→idle) und erneutdoSave(); Erfolg →saved; Fehler →errormit „Speichern fehlgeschlagen – “ +errorMessage(err, 'save'). -
Editor
doSave: ohneimageBlob→onStatus('error', 'Bitte zuerst ein Bild wählen.')und Ende; erstawait getCurrentUser()(Cache warm); Vorschau perexportCard(config, assets, { width: 480, type: 'image/webp', quality: 0.85 });previous: store.get().saved; nach Erfolgsaved= Ergebnis,imageChanged: false. Knopf zeigt „Speichert …“ (deaktiviert), danach 2 s „Gespeichert“. -
Laden
/?karte=<id>überwithLogin:service.get→loadImage→decodeImage→setImage, danachconfig.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 auserrorMessageund 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 perloadPreview→ Object-URL, beim Verlassen freigeben; Name; Datumdd.mm.yyyy). Aktionen: Öffnen (navigate('/?karte=<id>')), Herunterladen (Bild laden →drawCard→ PNG mitdownloadFileName), Umbenennen (Inline-Eingabe, Enter speichert, Esc bricht ab), Löschen (eigener Dialog „Karte „“ löschen?“, keinwindow.confirm).list,rename,remove,loadImage,loadPreviewlaufen überwithLogin. -
Step 1: Tests schreiben
cards.test.tsmit Fake-CardsApi(zeichnet Aufrufe auf, pro Methode einmal fehlschlagen lassen):- neu: zwei
createFilemitownerPermissions('u1'), Dateinamenimage.png/preview.webp(je nach Blob-Typ; JPEG →image.jpg);createRowmitserializeConfig(c); Ergebnis mit geparster Config. - neu,
createRowwirft → beide neuen Dateien gelöscht, Fehler kommt an. - neu,
userIdwirft → keincreateFile. - bestehend ohne Bildwechsel: ein
createFile(Vorschau),updateRowmit neuerpreviewFileId, danachdeleteFile(alteVorschau), altes Bild bleibt. - bestehend mit Bildwechsel: zwei
createFile, nachupdateRowalte Dateien gelöscht. - bestehend,
updateRowwirft → nur neue Dateien gelöscht. - bestehend,
deleteFile(alteVorschau)wirft →savelöst trotzdem auf. remove:deleteRowvordeleteFile;deleteFilewirft → löst auf.list: Zeile mit kaputtemconfig→DEFAULT_CONFIG.toUploadFile(new Blob([], {type:'image/gif'}), 'image')→ wirft.normalizeName:' Mein Urlaub '→'Mein Urlaub';' '→'Meine Karte'; 100 ×'😀'→ 80 Codepoints; 81 ASCII-Zeichen → 80.
- neu: zwei
with-login.test.ts:fnwirft{code:401},requireLogin → true→fnzweimal, Ergebnis kommt an;requireLogin → false→LoginCancelled; anderer Fehler → durchgereicht, keinrequireLogin.save.test.ts: zwei schnelle Aufrufe →doSaveeinmal; ohne Nutzer +requireLogin → false→doSavenie, letzter Statusidle;doSavewirft{code:401},requireLogin → true→requireLogineinmal,doSavezweimal, Statussaved; 401 undrequireLogin → false→idle; anderer Fehler →errormit Präfix „Speichern fehlgeschlagen – “.
-
Step 2: Tests laufen lassen → FAIL.
-
Step 3: Implementieren.
-
Step 4: Tests + Build – PASS;
npm run buildok. -
Step 5: Commit + Push –
git commit -m "feat(cards): Karten speichern und Meine Karten"; Push nachmain, Build abwarten. -
Step 6: Live prüfen (Claude in Chrome in Kensos angemeldeter Testsitzung): Karte gestalten → Speichern → neu laden →
/kartenzeigt 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 Fensterfetch('/v1/tablesdb/aircard/tables/cards/rows/<ID Konto 1>', {headers: {'X-Appwrite-Project': 'aircard'}})→ 404row_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.“,/kartenzeigt „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.mdaktualisieren.