Cards is the first app on the new 12-token mana-vereinsweite
theming system (mana/docs/THEMING.md). Forest-Variant aus
@mana/themes/variants/forest.css konsumiert via app.css-Import,
data-theme="forest" in app.html.
Token-Welt umgestellt — 158 renames + 304 hsl-wraps in 17 Files
(Python-Refactor, BSD-sed war zu unzuverlässig):
- --color-bg → --color-background
- --color-fg → --color-foreground
- --color-muted → --color-muted-foreground
- --color-primary-fg → --color-primary-foreground
- --color-danger → --color-error
- bare var(--color-X) → hsl(var(--color-X)) durchgängig
Bridge-Aliase in app.css mappen die shared-ui@0.1.x-Erwartungen
(card, accent, surface-elevated-*, …) auf das 12er-Set. Mit
shared-ui@2.0-Refactor entfällt diese Sektion. --brand-cards-forest
als App-Identitäts-Hex separiert von Theme-Tokens.
Header konsumiert PillTabGroup aus @mana/shared-ui@0.1.1 für die
Routen-Navigation (Decks/Lernen/Library/Import/Stats) und den
DE/EN-Sprach-Switcher — visuell konsistent mit Vereins-Standard.
Cards' primary-Grün wurde dabei von 142 76% 36% (alter Live-Stand)
auf 142 76% 28% verdunkelt, damit primary-foreground/primary-
Kontrast WCAG-AA-konform (≥4.5) ist. Der alte Live-Stand hatte
Ratio 3.35.
i18n: deck_stack.aria_label, deck_detail.fan_aria, deck_detail.
card_open, decks.card_count_more, study_session.manage_link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eigener cards-minio-Container im docker-compose (9100/9101 — Plattform
auf 9000/9001 bleibt isoliert). cardsadmin/cardsadmin als Dev-Default,
prod via env-Vars (CARDS_S3_*).
apps/api/src/services/storage.ts — schmaler StorageService um den
minio-Client. ensureBucket() ist idempotent (auto-create beim ersten
Upload). removeObjectsByPrefix() implementiert den DSGVO-Bucket-Sweep,
weil die S3-API kein Cascade kennt.
Neue Tabelle media_files in pgSchema('cards'):
id, user_id, object_key, mime_type, original_filename, size_bytes,
kind, created_at — kein FK auf cards (ein File kann mehreren Karten
gehören). objectKey-Format <userId>/<ulid>.<ext> für Bucket-Prefix-
Sweep beim DSGVO-Delete. Legacy mediaRefs bleibt als Slot.
Neuer Router /api/v1/media:
POST /upload — multipart, 25 MiB Default-Limit, image/audio/video
only (415 sonst), schreibt media_files-Row + speichert
in MinIO unter <userId>/<ulid>.<ext>
GET /:id — streamt aus MinIO mit Cache-Control: private,
immutable. Cross-User → 404 (nicht 403, anti-enumeration).
GET / — listet alle eigenen Files
DSGVO-Pfade (Service-Key + /me/delete) räumen jetzt auch media_files
+ MinIO-Bucket-Prefix mit ab. Storage-Sweep ist non-fatal — DB ist erst
konsistent gelöscht, dead bytes wären die schlimmstmögliche Folge.
Anki-Import: parse.ts sanitizeAnkiHtml akzeptiert wieder eine
Filename→URL-Map (war in Phase 8c gedroppt). import.ts lädt vor den
Karten alle referenzierten Media-Files via uploadMedia() in MinIO,
sammelt URLs, ersetzt Anki-Filenames durch /api/v1/media/<id>-Pfade
in `<img>` (Markdown) und `[sound:…]` (HTML <audio>). 4-fache Worker-
Concurrency.
apps/web/src/lib/markdown.ts: DOMPurify lässt jetzt <audio>/<video>/
<source> mit src/controls/preload-Attributen durch — sonst würden die
Audio-Tags aus dem Anki-Import gestrippt.
i18n-Strings (DE/EN) auf Media-Stage erweitert: stage_media,
done_media, what_works_media, dropzone_hint, preview_media.
import.what_skipped_media wird zur Bestätigung dass Media seit
Sprint 9k mit übernommen wird.
Manueller E2E-Smoke gegen lokale MinIO (cards-minio :9100):
- 1×1-PNG hochgeladen → 201 mit ID + URL
- /api/v1/media/<id> streamt 200 image/png 69 bytes (file-Identifikation
bestätigt)
- Cross-User → 404, ohne X-User-Id → 401, text/plain → 415
53 API-Tests grün (+4 neue media-Auth-Gate-Tests), 7 Web-Tests,
51 Domain-Tests, type-check + svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neuer Domain-Helper cardContentHash({ type, fields }) — SHA-256 über
canonisierten JSON ({type, sorted-fields}), pure Web-Crypto. Field-
Reihenfolge ist invariant; Whitespace + Cloze-Markup zählen mit
(zwei Karten mit identischem Text aber unterschiedlichem
{{c1::…}}-Markup sind verschiedene Karten).
cards-API POST schreibt content_hash automatisch in den schon
existierenden Schema-Slot. Neuer Endpoint GET /api/v1/cards/hashes
liefert die kompakte Hash-Liste des Users (ohne Card-Body) — eine
Anfrage pro Anki-Import statt pro Karte.
apps/web/src/lib/anki/import.ts holt die Hashes vor dem Loop und
prüft pro Karte clientseitig. Duplikate werden gezählt
(cardsSkippedDuplicate) und übersprungen, der Counter erscheint
in der AnkiImport-Done-View. Same-File-Drift (Anki-interne
Doppel-Notes) wird auch erkannt — nach erfolgreichem Insert
landet der Hash sofort im Set.
Fallback: wenn /hashes fehlschlägt (älterer Server), bleibt das
Dedupe-Set leer und Karten werden eingefügt wie zuvor — kein
Hard-Bruch.
Pre-Phase-9j-Karten haben null content_hash (Hashes-Endpoint
filtert sie weg) — sie können also irrtümlich erneut eingespielt
werden, falls noch im Anki-File. Pragmatisch akzeptiert: ein
Backfill-Script wäre Phase-10-Polish, sobald Live-User da sind.
5 neue Domain-Tests, 1 neuer API-Auth-Gate-Test (105 grün ges.:
51 + 49 + 5). svelte-check 380 files 0 errors. E2E gegen lokale
Postgres bestätigt: neue Karte hat content_hash (64-char-hex),
/hashes listet sie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schmale Eigen-Lösung statt svelte-i18n: $lib/i18n mit de.ts + en.ts
(je ~150 Strings, flat-with-nesting), index.svelte.ts liefert reaktiven
i18n-Store als Svelte-5-Rune plus t()/tn()-Helper. Locale wird in
localStorage persistiert, Default = navigator.language (DE/EN-Erkennung),
Fallback = de.
Header bekommt einen DE/EN-Toggle-Switcher (role=group, aria-pressed).
Alle Routes (decks, decks/[id], decks/new, cards/new, cards/[id]/edit,
study, study/[deckId], import, account, stats, +page) und alle
Komponenten (Header, AnkiImport, InboxBanner) ziehen jetzt durch t().
tn(key, n) wählt zwischen `<key>_one` (n=1) und `<key>` — minimaler
Plural-Helper, reicht für DE/EN-MVP. Komplexere Pluralregeln (FR/IT)
bräuchten Intl.PluralRules, kommen wenn die Locales dazukommen.
Begleitend ein paar A11y-Vorbereitungen mitgenommen: aria-label am
Locale-Switcher, role=group für Grade-Buttons, role=button +
keyboard-handler für die Anki-Dropzone, role=progressbar mit
aria-valuemin/max/now, role=alert für Error-States,
aria-live=polite für Lade-Meldungen, sr-only-Heading im
Study-Card-Render, aria-hidden für rein dekorative Elemente.
svelte-check 379 files 0 errors, 94 Tests grün (41 Domain + 48 API
+ 5 Web).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strategie-B-Ausnahme: parse.ts (Anki-Format-Parser via JSZip + sql.js)
und AnkiImport.svelte (UI-Stages) sind aus mana-monorepo portiert,
mit Source-Comment-Header dokumentiert. Anki-Format ist standalone
Parser-Logik, kein Architektur-Schmuggel.
Neuer server-authoritative import.ts schreibt direkt gegen die
cards-api ($lib/api/decks + cards) — keine Stores, keine Dexie.
Anki "::"-Hierarchie wird zu " / "-Strings flach. Fallback-Deck
"Anki-Import" für Karten ohne explizites Deck. Cloze-Karten kommen
first-class durch (Sub-Index pro Cluster, Sprint 8a/8b).
Phase-8-MVP-Scope: Bilder + Audio werden gedroppt (Option A) — der
sanitizeAnkiHtml entfernt <img> und [sound:…] ersatzlos. Späterer
Media-Pfad (lokaler Cards-Upload oder mana-media nach Phase 2) ist
additiv.
Neue Route /import + Top-Nav-Link. Hermetic Vitest (5 Cases): baut
zur Laufzeit ein Mini-.apkg via sql.js + JSZip und prüft den
Parser-Output (basic, basic-reverse, cloze, sanitize, dedupe auf
Note-Ebene). svelte-check 0 errors, prod-Build sauber.
sql-wasm.wasm liegt in static/ (660kB) — fix für sql.js 1.14.1, vom
Browser einmal geladen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>