diff --git a/CLAUDE.md b/CLAUDE.md index 7e60b1c..b029a78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,9 @@ Guidance für Claude Code in diesem Repository. > **Wenn du gerade neu bist:** lies zuerst [`PLAN.md`](PLAN.md) und -> [`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md). -> Dieses CLAUDE.md ist die Konventions- und Cross-Repo-Referenz. +> [`docs/NATIVE_LIFT_PLAN.md`](docs/NATIVE_LIFT_PLAN.md). Das ältere +> Hybrid-Playbook in `../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md` +> ist seit 2026-05-22 **überholt** — nur als historische Referenz lesen. ## Was dieses Repo ist @@ -14,66 +15,74 @@ macOS) für **Zitare**, den öffentlichen Zitat-Korpus des Vereins ``` HTTPS ┌──────────────────┐ - zitare.com ◄──────────── │ zitare-native │ WKWebView (Lesen) - (statisch, public) │ (this repo) │ SwiftUI (Submit) - │ ev.mana.zitare │ WidgetKit - zitare-api ◄──────────── │ │ SwiftData (Snapshot-Cache) - zitare-api.mana.how │ │ CoreSpotlight + zitare.com ◄──────────── │ zitare-native │ pure SwiftUI + (Snapshot + AASA) │ (this repo) │ SwiftData (voller Korpus) + │ ev.mana.zitare │ WidgetKit + ShareExt + Spotlight + api.zitare.com ◄──────────── │ │ SafariView (nur Legal) + (Read + Submit) │ │ └──────────────────┘ ``` ## Status -**Phase ζ-0 — Setup.** Repo-Skelett, `project.yml`, leerer Build im -Simulator. Phasen ζ-1 bis ζ-7 in -[`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md). +**Phase η-0 — De-Hybrid (2026-05-22, in Arbeit).** Hybrid-Strategie +(WKWebView für Read-Surfaces) wurde aufgegeben. Plan + +Phasen-Übersicht in [`docs/NATIVE_LIFT_PLAN.md`](docs/NATIVE_LIFT_PLAN.md). +Status-Spur in [`PLAN.md`](PLAN.md). -## Leitprinzip: Verteilungs-USP, nicht Funktions-USP +## Leitprinzip: pure-native + Offline-first -Anders als die anderen drei nativen Apps (cards/memoro/manaspur) gibt -es bei Zitare **keinen Hardware-Vorteil** gegenüber dem Browser. Die -Web-App ist mobile-responsive, statisch prerendered, hat Pagefind- -client-Suche. Native bringt: +Das Verteilungs-USP-Argument trägt allein nicht; die App soll auch +Funktion + Lese-Komfort eigenständig liefern, ohne den Browser-Schwester- +Pfad. Native bringt: -1. **Home-Screen-Widget** „Zitat des Tages" -2. **ShareExtension** als Ziel für markierten Text -3. **Spotlight-Index** für system-weite Suche -4. **Native Submit-View** mit ManaAuthUI +1. **Voller Korpus offline** in SwiftData (Snapshot-Sync `index-full.json`). +2. **Lokale FTS** + Server-Fallback bei seltenen Queries. +3. **Home-Screen-Widget** „Zitat des Tages". +4. **ShareExtension** als Ziel für markierten Text. +5. **Spotlight-Index** für system-weite Volltext-Suche. +6. **Native Submit** mit ManaAuthUI. +7. **SafariView** nur für statische Verein-Recht-Seiten (Impressum, + Datenschutz, Lizenz) — kein `WKWebView` im Binary. -Alles andere (Lesen, Filtern, Search, Edit, Moderation) bleibt im -`WKWebView` gegen `zitare.com` / `zitare.mana.how`. +## Architektonische Invarianten (η) -## Architektonische Invarianten +Beschlossen 2026-05-22. Nicht ohne explizite Diskussion antasten. -Beschlossen. Nicht ohne explizite Diskussion antasten. - -1. **Hybrid ausnahmsweise.** Lese-Surfaces via `WKWebView`, Native- - Surfaces (Widget, ShareExt, Submit, Spotlight) pure SwiftUI. Diese - Trennung ist **fest** — keine schleichende Native-Re-Implementation - von Read-Routes. -2. **Read-only via Web, Submit via SwiftUI.** Submit ist der einzige - schreibende Pfad in v1. Edit, Moderation, History bleiben Web. -3. **Snapshot lokal gespiegelt für Widget + Spotlight.** Beim Launch - `https://zitare.com/index-min.json` pullen, in SwiftData - persistieren, App-Group `group.ev.mana.zitare` reicht es an Widget - + ShareExtension durch. **Nicht** für den WebView-Pfad — der lädt - live. -4. **mana-auth via ManaCore + ManaAuthUI.** Submit-Pfad nutzt +1. **Pure SwiftUI für alle Surfaces.** Kein `WKWebView` im App-Binary. + `ManaWebShell`-Dep ist raus aus `project.yml`. +2. **Offline-first Lesen.** SwiftData ist Primary-Store, API ist + Fallback bei Cache-Miss + Sync-Quelle. Flugmodus-Test ist Akzeptanz- + Kriterium ab η-2. +3. **Quote-Korpus ist Snapshot.** Beim Launch + Background-Refresh + wird `index-full.json` gepullt, ETag-aware, in SwiftData persistiert, + per App-Group an Widget + ShareExt + Spotlight durchgereicht. +4. **Server gewinnt bei Schema-Konflikt.** SwiftData-Modelle sind eine + abgeleitete Spiegelung des Drizzle-Schemas in `zitare/apps/api/`. +5. **Search lokal-first.** Lokale FTS aus SwiftData; bei < 3 Treffern + oder leerem Result Server-Fallback (`GET /api/v1/quotes?q=`). +6. **Legal-Seiten via SafariView.** Impressum / Datenschutz / Lizenz + öffnen `SFSafariViewController`. Das ist die **einzige** zugelassene + Browser-Brücke und nur für statisches Verein-Recht-Material. +7. **Schreiben bleibt Submit-only in v1.** Edit / Moderation / Flags- + Verwaltung kommen NICHT in die App — bleiben Web. +8. **Universal-Links auf `zitare.com` lösen ins native Surface.** AASA + bleibt, `onContinueUserActivity` routet auf SwiftUI-Views, nicht + in einen WebView-Tab. +9. **mana-auth via ManaCore + ManaAuthUI.** Submit + Konto nutzen `AuthClient` und die fertigen Views aus ManaAuthUI. Keine eigene - Auth. WebView gegen `zitare.mana.how` bekommt JWT per Cookie- - Injection (`mana.access` auf `.mana.how`). -5. **Universal-Link-Domain: `zitare.com`.** AASA auf - `https://zitare.com/.well-known/apple-app-site-association`. - `zitare.mana.how` ist *kein* applinks-Ziel. -6. **Theme: `paper`-Variant default.** Werte aus `@mana/themes/paper`, - lokal als `ZitareTheme.swift` nachgebaut. -7. **Bundle-ID `ev.mana.zitare`.** Reverse-Domain mana-ev.ch. - App-Display-Name: „Zitare". Category: `public.app-category.reference`. -8. **Pure SwiftUI für Native-Surfaces.** WKWebView ist die einzige - UIKit-Bridge. -9. **Web gewinnt bei Konflikt.** Funktion wandert zuerst in - `zitare/apps/zitare/` oder `zitare/apps/api/`, dann ins WebView- - bzw. Native-Surface hier. + Auth, keine Cookie-Bridge mehr (entfällt mit `WKWebView`). +10. **Universal-Link-Domain: `zitare.com`** (+ `app.zitare.com` als + AASA-Redundanz). AASA auf + `https://zitare.com/.well-known/apple-app-site-association`. +11. **Theme: `paper`-Variant default.** Werte aus `@mana/themes/paper`, + lokal als `ZitareTheme.swift` nachgebaut. +12. **Bundle-ID `ev.mana.zitare`.** Reverse-Domain mana-ev.ch. + App-Display-Name: „Zitare". Category: `public.app-category.reference`. +13. **Web gewinnt bei Konflikt im Datenmodell.** Funktion wandert + zuerst in `zitare/apps/zitare/` oder `zitare/apps/api/`, dann ins + native Surface hier — kein einseitiger Native-Vorlauf, der das Web + abhängt. ## Konventionen @@ -202,32 +211,45 @@ swiftlint --strict ## Phasen-Disziplin -Jede Phase aus dem Greenfield-Plan hat ein verifizierbares -Erfolgskriterium. Nicht in die nächste Phase reinarbeiten, bevor die -vorherige abgeschlossen ist: +Jede Phase aus dem Lift-Plan hat ein verifizierbares Erfolgskriterium. +Nicht in die nächste Phase reinarbeiten, bevor die vorherige +abgeschlossen ist. Vollständige Phasen-Tabelle + +Erfolgs-Kriterien in [`docs/NATIVE_LIFT_PLAN.md`](docs/NATIVE_LIFT_PLAN.md). -- ζ-0: leerer Build + ManaCore-Login + Healthz-Probe (**JETZT**) -- ζ-1: WebShellView + Universal-Links + Cookie-SSO-Bridge -- ζ-2: Snapshot-Sync + DailyQuoteWidget auf realem Gerät -- ζ-3: Submit-View nativ mit ManaAuthGate -- ζ-4: Spotlight + ShareExtension + App Intents -- ζ-5: Polish, Theme-Sync, iPad-Split-Layout, Accessibility -- ζ-6: App-Store-Submission +Übersicht: + +- ζ-0..ζ-2: erledigt unter Hybrid-Annahme (Setup, WebShell, Snapshot- + Stub + Widget-Code). WebShell entfällt jetzt; Widget + Snapshot- + Code bleiben, werden in η-1 erweitert. +- η-0: De-Hybrid (ManaWebShell raus, RootView vier native Tabs) + (**JETZT**) +- η-1: Snapshot v2 + volles SwiftData-Schema (Cross-Repo-Blocker: + `zitare/apps/zitare/` muss `index-full.json` + 7 Stammdaten-JSONs + liefern) +- η-2: Read-Core nativ (Heute, Quote-Detail, Author-Detail) +- η-3: Read-Browse (Source / Theme / Place / Role / Epoch / Language) +- η-4: Explore + Filter +- η-5: Search (lokal-first + Server-Fallback) +- η-6: Account voll nativ +- η-7: Legal-Sheets via SafariView +- η-8: Submit / ShareExt / Spotlight an Snapshot v2 anpassen +- η-9: Polish (iPad-Split, Accessibility, macOS-Layout) +- η-10: TestFlight Bei Phasen-Wechsel: PLAN.md aktualisieren + Memory-Eintrag -`project_zitare_native.md` nachziehen (sobald angelegt). +`project_zitare_native_dehybrid.md` nachziehen. ## Don't do -- **Keine Native-Re-Implementation der Read-Routes.** Wenn dir die - Web-Quote-Ansicht im WebView nicht gefällt, fixe sie in - `../zitare/apps/zitare/src/routes/(read)/`. Nicht hier eine zweite - bauen. +- **Kein `WKWebView` im App-Binary.** SafariView (SFSafariViewController) + ist die einzige zugelassene Browser-Brücke, und nur für statisches + Verein-Recht (Impressum, Datenschutz, Lizenz). Wenn du WKWebView + brauchen würdest, baust du es als native View nach. - **Kein eigener FSRS-Port, kein eigener Pagefind-Klon.** Existiert - beides nicht für Zitare und soll nicht entstehen. + beides nicht für Zitare. Lokale FTS in η-5 ist SwiftData-Predicate. - **Keine Push-Notification-Pipeline.** Widget reicht. - **Keine externen UI-Libs / kein Sentry / kein Crash-Reporting- SaaS.** OSLog only (Compliance). -- **Keine offline-Volltext-Funktion im WebView.** Wenn Offline- - Lesen Use-Case wird → PWA-Pfad (Option A in `zitare/CLAUDE.md`), - nicht hier. +- **Kein Edit / Moderation / Admin in der App.** Web-Surface + (`app.zitare.com`) bleibt SOT für schreibende Pfade jenseits von + Submit. Wer das braucht, öffnet Safari. diff --git a/PLAN.md b/PLAN.md index 1886595..f111352 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,108 +1,118 @@ -# Plan — zitare-native (SwiftUI Hybrid) +# Plan — zitare-native (η = fully native) -**Stand: 2026-05-14 — Phase ζ-0 Setup.** Repo-Skelett, `project.yml`, -`CLAUDE.md`, leerer Build steht aus. Vollständige Phasen-Begründung -in [`../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md). +**Stand: 2026-05-22 — Phase η-0 in Arbeit.** Hybrid-Strategie ist +revidiert. Lese-Surfaces werden nativ. Vollständige Phasen-Begründung ++ Erfolgs-Kriterien in [`docs/NATIVE_LIFT_PLAN.md`](docs/NATIVE_LIFT_PLAN.md). -> **SOT:** das Greenfield-Playbook. Dieses File ist nur die App-lokale +> **SOT:** der NATIVE_LIFT_PLAN. Dieses File ist nur die App-lokale > Status-Spur. -## Aktueller Stand +## Archiv: Phasen ζ-0 bis ζ-2 (Hybrid, abgeschlossen) -🚧 **ζ-0 — Setup (2026-05-14, in Arbeit)** +Code aus diesen Phasen lebt teils weiter, teils ist er entfernt: -- [x] Repo-Skelett unter `~/Documents/Code/zitare-native/` -- [x] `project.yml` mit Bundle `ev.mana.zitare`, drei Targets - (App + Widget + ShareExt), ManaSwiftCore + ManaSwiftUI via - `path: ../mana-swift-core` / `path: ../mana-swift-ui` -- [x] `.swiftformat`, `.swiftlint.yml`, `.gitignore` -- [x] `CLAUDE.md`, `README.md`, `PLAN.md` -- [x] Source-Stubs (App, RootView, AppConfig, ZitareAPI, Log, - ZitareTheme, Resources) -- [x] `xcodegen generate` lokal grün (2026-05-14) -- [x] `swiftlint --strict` 0 violations in 14 files -- [x] Leerer Build im iOS-Simulator (iPhone 16e, Xcode 26.2 SDK, - `xcodebuild ... -destination 'platform=iOS Simulator,name=iPhone 16e'`) -- [x] Unit-Tests grün (6/6 AppConfigTests) + UI-Smoke grün -- [ ] Leerer Build auf macOS — **blockiert auf Apple-Developer-Portal- - Setup.** App-Group `group.ev.mana.zitare` muss im Portal - registriert werden, sonst Provisioning-Profile-Fehler. Same - Blocker wie cards-native ζ-7 / memoro-native (siehe Memory - `project_memoro_native.md` „Apple-Dev-Portal App-Group- - Aktivierung nötig vor Test"). -- [ ] ManaCore-Login mit Founder-Account, JWT im Keychain (manueller - Test im Simulator, nicht im CI) -- [x] `/healthz`-Probe gegen `zitare-api.mana.how` loggt 200 (live - gegen Mac-Mini-API verifiziert 2026-05-14 12:14, HTTP/2 200, - OSLog „Healthz: OK") -- [ ] AASA auf `https://zitare.com/.well-known/apple-app-site-association` - (Aufgabe ans Zitare-Web-Repo, blockiert ζ-1 nicht ζ-0) -- [ ] Git-Repo `git.mana.how/till/zitare-native` (push) +- ✅ **ζ-0 Setup** — Repo-Skelett, `project.yml`, iOS-Simulator-Build, + ManaCore-Login + Healthz-Probe live (2026-05-14). +- ✅ **ζ-1 WebShellView + Cookie-Bridge** — gebaut, dann in η-0 (heute) + wieder gelöscht. `CookieBridge.swift` weg, `ManaWebShell`-Dep raus + aus `project.yml`. +- 🚧 **ζ-2 Snapshot-Sync + DailyQuoteWidget (Code-Done)** — Widget-Code + + SwiftData-Stub-Modelle existieren, Endpoint im Web-Repo (Snapshot + v1 `index-min.json`) + Apple-Dev-Portal-App-Group fehlen für E2E. + Wird in η-1 auf Snapshot v2 erweitert. -### ζ-0 Verifikations-Log +## Aktuell -``` -2026-05-14 12:08 xcodebuild iOS Simulator iPhone 16e — BUILD SUCCEEDED - (nach Fix: .iso8601withFractional war cards-native- - local; in zitare-native auskommentiert, ζ-3 Port-TODO) -2026-05-14 12:10 Unit-Tests: 6/6 AppConfigTests passed in 0.03s -2026-05-14 12:11 UI-Smoke: test_appLaunches passed in 5.79s - (nach Fix: Test suchte "Zitare" das nur im Account- - Tab ist, jetzt auf Default-Tab "Lesen" geändert) -2026-05-14 12:09 xcodebuild macOS — BUILD FAILED, blockiert auf - Apple-Dev-Portal App-Group-Registrierung -2026-05-14 12:14 iPhone 16e Simulator: App-Launch + Live-Healthz - gegen zitare-api.mana.how → HTTP/2 200, OSLog - "[ev.mana.zitare:app] Zitare starting — auth - status: signedOut" und "[ev.mana.zitare:api] - Healthz: OK" (Fix: AuthenticatedTransport rejecte - notSignedIn auf public Endpoint, jetzt direkter - URLSession-Call für /healthz) -``` +✅ **η-0 — De-Hybrid (2026-05-22, abgeschlossen)** -## Phasen-Übersicht +- [x] `Sources/Core/WebShell/CookieBridge.swift` gelöscht + + WebShell-Verzeichnis entfernt. +- [x] `ManaWebShell`-Dep aus `project.yml` raus. +- [x] `RootView.swift` auf vier native Tabs umgestellt; Lesen + + Erkunden zeigen Platzhalter-Views (η-2/η-4-Hinweis). +- [x] DocComments in `DeepLinkRouter.swift`, `AppConfig.swift`, + `AccountView.swift`, `SettingsView.swift` von WebView-Verweisen + befreit. +- [x] `CLAUDE.md` Invarianten ersetzt (Hybrid → η). +- [x] `xcodegen generate` + iOS-Simulator-Build grün (iPhone 16e, Xcode + 26.2 SDK). +- [x] `nm ZitareNative` zeigt 0 WKWebView-Referenzen; `otool -L` + kein WebKit-Framework-Link. Binary ist WebView-frei. +- [x] Tests grün: 20/20 (AppConfigTests + DeepLinkRouterTests + + SnapshotSyncTests + SmokeUITests). Pre-existing Keychain-Service- + Test auf `ManaSharedKeychainGroup` aktualisiert (war out-of-sync + seit Cross-App-SSO-Konsolidierung). +- [x] Greenfield-Playbook `mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md` + als überholt markiert (Banner + status-Header). + +## Phasen-Übersicht (η) | Phase | Ziel | Erfolg | Status | |---|---|---|---| -| ζ-0 | Setup, leerer Build, Login | iOS-Build ✅, Tests ✅, Healthz Live ✅ | ✅ (Mac + Git-Push offen) | -| ζ-1 | WebShellView + Universal-Links | WebView rendert, UL-Routing testbar, Web-Header ausgeblendet | ✅ | -| ζ-2 | Snapshot-Sync + DailyQuoteWidget | Code + Tests grün, Endpoint im Web-Repo + Apple-Dev-Portal-App-Group fehlen für E2E | 🚧 (Code-Done) | -| ζ-3 | Submit-View nativ | Founder submittet Quote, Draft in /admin/queue | ⏳ | -| ζ-4 | Spotlight + ShareExt + App Intents | Spotlight findet, ShareExt POSTet | ⏳ | -| ζ-5 | Polish (Theme-Sync, iPad, A11y) | Eine Woche ohne Safari-Tab nutzbar | ⏳ | -| ζ-6 | App-Store-Submission | Approved | ⏳ | +| η-0 | De-Hybrid | Build grün, kein WKWebView im Binary | ✅ | +| η-1 | Snapshot v2 + volles SwiftData-Schema | Cold-Start lädt vollen Korpus, ETag spart Bytes | ⏳ (Web-Repo-Blocker) | +| η-2 | Read-Core (Heute, Quote, Author) | Flugmodus + UL aus Mail-App zeigt Quote nativ | ⏳ | +| η-3 | Read-Browse (Source/Theme/Place/Role/Epoch/Lang) | Alle Deep-Link-Pattern aus app-manifest.json nativ | ⏳ | +| η-4 | Explore + Filter | „Roman + DE + lang" Filter live ohne Server | ⏳ | +| η-5 | Search (lokal + Server-Fallback) | Offline-Treffer + Online-Ergänzung | ⏳ | +| η-6 | Account nativ | Sign-In/Out + Submissions-Liste nativ | ⏳ | +| η-7 | Legal via SafariView | Impressum/Datenschutz/Lizenz als SFSafariViewController | ⏳ | +| η-8 | Submit + ShareExt + Spotlight an Snapshot v2 | Spotlight findet via Volltext | ⏳ | +| η-9 | Polish (iPad / Accessibility / macOS) | Solo-Nutzung ohne Browser-Tab eine Woche | ⏳ | +| η-10 | TestFlight | Approved | ⏳ | ## Externe Blocker (nicht von Native-Code lösbar) -- [ ] **Apple-Dev-Portal: App-Group `group.ev.mana.zitare`** registrieren - — sonst kein macOS-Build und kein Widget auf realem Gerät. Selbe - Aktion wie für cards-/memoro-native nötig. +- [ ] **Apple-Dev-Portal: App-Group `group.ev.mana.zitare`** + registrieren — sonst kein macOS-Build und kein Widget auf realem + Gerät. Selbe Aktion wie für cards/memoro/moodlit/herbatrium nötig. - [ ] **Cloudflare:** DNS-CNAME für `zitare.com` (Apex) auf `1435166a-0e3f-4222-8de6-744f32cea5c9.cfargotunnel.com` proxied. Plus Cleanup des versehentlichen - `zitare.com.mana.how`-CNAME. Vollständige Anleitung in + `zitare.com.mana.how`-CNAME. Anleitung in [`docs/CLOUDFLARE_TODO.md`](docs/CLOUDFLARE_TODO.md). -- [ ] **Forgejo-Repo `git.mana.how/till/zitare-native`** anlegen + - Push — ✅ erledigt 2026-05-14, 5 Commits live. ## Web-Vorbedingungen (Aufgabe an `../zitare/`) -- [ ] AASA-Eintrag auf `https://zitare.com/.well-known/apple-app-site-association` - mit `appID: QP3GLU8PH3.ev.mana.zitare` -- [ ] `index-min.json` als versionierter, ETag-versehener Endpoint - (oder einigen, dass die Build-Output-Datei stabil bleibt) -- [ ] `POST /api/v1/share/receive` mit `mana/text`-Envelope-Handler - (Manifest registriert, Code TBD) -- [ ] `zitare.com` Cloudflare-Zone-Onboarding (steht im - `zitare/STATUS.md` als offen) -- [ ] Cookie-SSO-Compat auf `zitare.mana.how` end-to-end testen - (Phase 2.G im Web-Repo code-fertig, Live-Test offen) +η-1-Blocker — ohne diese Endpoints kein Offline-Lesen: + +- [ ] `https://zitare.com/index-full.json` mit allen Quote-Feldern + inkl. Volltext. +- [ ] Stammdaten-Collections: `authors.json`, `sources.json`, + `themes.json`, `places.json`, `epochs.json`, `roles.json`, + `languages.json` auf `zitare.com/`. +- [ ] ETag-Header für jeden dieser Endpoints (Cache-Control: 1h). + +η-5-Voraussetzung: + +- [ ] `GET /api/v1/quotes?q=` Volltext-Search bestätigt / + ergänzt in `zitare/apps/api/`. + +η-6-Voraussetzung: + +- [ ] `GET /api/v1/me/submissions` (eigene Einreichungen) bestätigt / + ergänzt. + +Restliche Web-Vorbedingungen (orthogonal): + +- [ ] AASA auf `https://zitare.com/.well-known/apple-app-site-association` + mit `appID: QP3GLU8PH3.ev.mana.zitare`. +- [ ] `zitare.com` Cloudflare-Zone-Onboarding (steht in + `zitare/STATUS.md` als offen). ## Verifikations-Lücken -Wird nach jedem ζ-Schritt befüllt. +η-0: keine offenen. Pre-existing 4 swiftlint-strict-Violations in +`ZitareNativeApp.swift` + `SubmitQuoteView.swift` bleiben — sind aus +ζ-3 (Submit-Phase) und nicht η-0-Scope. ## Quirks -Wird im Verlauf befüllt. Format-Vorbild: `../zitare/STATUS.md` Sektion -„Quirks". +- `swiftformat` läuft aggressiver als die letzte ζ-Phase: entfernt + `Sendable`-Konformanz aus `DropRecord`/`QuoteDraft`/`SubmittedQuote` + und `self.`-Prefix in `OS_log_t.info(...)`-`@autoclosure`-Calls. + Beides führt zu Strict-Concurrency-Errors. In η-0 wurden die + Kollateral-Edits zurückgerollt; `swiftformat` muss vor η-1 mit + expliziter Exclude-Liste oder einer .swiftformat-Regel-Anpassung + gezähmt werden, sonst zerstört der nächste Format-Lauf wieder + Submit-Code. diff --git a/Sources/App/DeepLinkRouter.swift b/Sources/App/DeepLinkRouter.swift index c81b23b..5b381fc 100644 --- a/Sources/App/DeepLinkRouter.swift +++ b/Sources/App/DeepLinkRouter.swift @@ -1,11 +1,16 @@ import Foundation /// Routet sowohl Custom-Scheme- (`zitare://`) als auch Universal-Link-URLs -/// (`zitare.com/...`) auf eine konkrete `WebTarget` + Ziel-Tab. +/// (`zitare.com/...`) auf eine normalisierte URL + Ziel-Tab. +/// +/// **η-0 (de-Hybrid):** Die URL ist nicht mehr WebView-Target, sondern +/// Eingabe für das native `NavigationPath`-Routing in η-2. Path-Struktur +/// (`/q/`, `/a/`, `/c/`, `/thema/` etc.) bleibt +/// 1:1 wie im Web-Repo — die Native-Views matchen die gleichen Slugs. /// /// Pure-Logic, kein State — easy testbar. enum DeepLinkRouter { - /// Mapt eine externe URL auf eine WebShell-URL. + /// Normalisiert eine externe URL auf den kanonischen `https://`-Pfad. /// `zitare://quote/x` → `https://zitare.com/q/x`, /// `zitare://author/x` → `https://zitare.com/a/x`, /// `zitare://collection/x` → `https://zitare.com/c/x`. diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index d5a6f6f..546ac3c 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -1,67 +1,29 @@ import ManaAuthUI import ManaCore -import ManaWebShell import SwiftUI -@MainActor -private func makeWebShellConfig() -> WebShellConfig { - WebShellConfig( - allowedHosts: [ - "zitare.com", - "www.zitare.com", - "*.mana.how", - ], - userAgent: AppConfig.userAgent, - backgroundColor: ZitareTheme.background, - progressTint: ZitareTheme.primary, - errorBackgroundColor: ZitareTheme.muted, - errorForegroundColor: ZitareTheme.foreground, - errorIconColor: ZitareTheme.warning, - userScripts: [ - // Syncs System-Dark-Mode in den WebView; zitare-web liest - // `localStorage['zitare-mode']` beim First Paint und toggelt - // dann `.dark` auf . - WebShellScripts.syncDarkMode(localStorageKey: "zitare-mode"), - // Versteckt den zitare-web-Header (Brand-Logo + Nav), weil - // die native TabBar bereits global navigiert. - WebShellScripts.hideElements( - selectors: [ - "header[data-app-nav]", - "body header:has(a.brand)", - "body > header:first-of-type", - "body > div > header:first-of-type", - ], - tagName: "hide-web-header" - ), - ] - ) -} - -/// Top-Level-View: TabView mit drei Tabs. +/// Top-Level-View: TabView mit vier nativen Tabs. /// -/// **Phase ζ-1:** Lesen + Erkunden laden `zitare.com` via `WebShellView`. -/// Universal-Links auf `zitare.com/q/` / `/a/` etc. öffnen -/// die App und routen in den passenden Tab. +/// **η-0 (de-Hybrid 2026-05-22):** `WebShellView` ist raus. Lesen + +/// Erkunden zeigen jetzt Platzhalter-Views; das volle native Read- +/// Surface kommt in η-2 (Heute / Quote-Detail / Author-Detail) bzw. +/// η-4 (Explore + Filter). Universal-Links werden hier vorerst nur +/// geloggt und in den Lesen-Tab gepinnt, bis `NavigationStack`-Routing +/// in η-2 steht. Submit + Konto sind schon nativ. struct RootView: View { @Environment(AuthClient.self) private var auth @Environment(ManaAuthGate.self) private var authGate @State private var selectedTab: AppTab = .read - @State private var readTarget = WebTarget(url: AppConfig.webBaseURL) - @State private var exploreTarget = WebTarget( - url: AppConfig.webBaseURL.appendingPathComponent("explore") - ) - @State private var reloadCounter: Int = 0 + @State private var pendingDeepLink: URL? @State private var healthStatus: HealthStatus = .unknown - private var webShellConfig: WebShellConfig { makeWebShellConfig() } - var body: some View { TabView(selection: $selectedTab) { - WebShellView(target: readTarget, config: webShellConfig) + ReadPlaceholderView(pendingDeepLink: pendingDeepLink) .tabItem { Label("Lesen", systemImage: "book") } .tag(AppTab.read) - WebShellView(target: exploreTarget, config: webShellConfig) + ExplorePlaceholderView() .tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") } .tag(AppTab.explore) @@ -73,35 +35,31 @@ struct RootView: View { .tabItem { Label("Konto", systemImage: "person.circle") } .tag(AppTab.account) } - // Mac-Window-Hintergrund auf Paper-Theme setzen, damit der - // TabBar-/Title-Bar-Bereich oben nicht mit dem System-Grau - // gegen das Paper-Theme ausreißt. `windowToolbar`-Placement - // ist macOS-only. .background(ZitareTheme.background.ignoresSafeArea()) #if os(macOS) .toolbarBackground(ZitareTheme.background, for: .windowToolbar) .toolbarBackground(.visible, for: .windowToolbar) #endif - .manaBrand(ZitareBrand.manaBrand) - .manaAuthGate(authGate) { - NavigationStack { - ManaLoginView( - auth: auth, - onSignUpTapped: {}, - onForgotTapped: {} - ) - .manaBrand(ZitareBrand.manaBrand) + .manaBrand(ZitareBrand.manaBrand) + .manaAuthGate(authGate) { + NavigationStack { + ManaLoginView( + auth: auth, + onSignUpTapped: {}, + onForgotTapped: {} + ) + .manaBrand(ZitareBrand.manaBrand) + } + } + .task { + await probeHealth() + } + .onOpenURL { url in + handle(url: url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { handle(url: url) } } - } - .task { - await probeHealth() - } - .onOpenURL { url in - handle(url: url) - } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - if let url = activity.webpageURL { handle(url: url) } - } } private func probeHealth() async { @@ -118,31 +76,18 @@ struct RootView: View { } } - /// Universal-Link- und Custom-URL-Routing. Wird sowohl von - /// `onOpenURL` (Custom-Scheme `zitare://...`) als auch von - /// `onContinueUserActivity` (Universal-Links auf `zitare.com/...`) - /// aufgerufen. + /// Deep-Link- + Universal-Link-Routing. /// - /// Routing-Regeln (gespiegelt zu `app-manifest.json#link_patterns`): - /// - `/q/`, `/a/`, `/c/` → Lesen-Tab - /// - `/heute`, `/random`, `/feed.rss` → Lesen-Tab - /// - `/explore`, `/region/...`, `/thema/...`, `/rolle/...`, - /// `/epoche/...`, `/sprache/...`, `/search`, `/t/...` → Erkunden-Tab - /// - alles andere unter `zitare.com` → Lesen-Tab, Root-Pfad - /// - /// Custom-Scheme `zitare://quote/` wird auf - /// `https://zitare.com/q/` umgemappt. + /// η-0: Routing-Logik (URL-Normalisierung + Tab-Auswahl) bleibt + /// erhalten, das eigentliche Ansteuern einer Quote/Author/Source- + /// Detail-View kommt in η-2 mit `NavigationPath`. Solange parken + /// wir die URL in `pendingDeepLink`, die Placeholder-View zeigt + /// sie zur Diagnose an. private func handle(url: URL) { Log.app.info("Deep-Link empfangen: \(url.absoluteString, privacy: .public)") let routed = DeepLinkRouter.route(url, base: AppConfig.webBaseURL) - reloadCounter += 1 - if routed.isExplore { - exploreTarget = WebTarget(url: routed.url, reloadToken: reloadCounter) - selectedTab = .explore - } else { - readTarget = WebTarget(url: routed.url, reloadToken: reloadCounter) - selectedTab = .read - } + pendingDeepLink = routed.url + selectedTab = routed.isExplore ? .explore : .read } } @@ -158,3 +103,55 @@ enum HealthStatus { case ok case down } + +/// η-0 Platzhalter — wird in η-2 durch HeuteView + QuoteFeedView ersetzt. +private struct ReadPlaceholderView: View { + let pendingDeepLink: URL? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "book") + .font(.system(size: 56)) + .foregroundStyle(ZitareTheme.mutedForeground) + Text("Lesen") + .font(.title2) + .fontWeight(.semibold) + Text("Native Read-Surface kommt in η-2 (Heute, Quote-Detail, Author-Detail).") + .font(.callout) + .foregroundStyle(ZitareTheme.mutedForeground) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + if let url = pendingDeepLink { + Text("Deep-Link wartet auf η-2:\n\(url.absoluteString)") + .font(.caption.monospaced()) + .foregroundStyle(ZitareTheme.mutedForeground) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.top, 8) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ZitareTheme.background) + } +} + +/// η-0 Platzhalter — wird in η-4 durch ExploreView (Facet-Filter) ersetzt. +private struct ExplorePlaceholderView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "sparkle.magnifyingglass") + .font(.system(size: 56)) + .foregroundStyle(ZitareTheme.mutedForeground) + Text("Erkunden") + .font(.title2) + .fontWeight(.semibold) + Text("Filter (Sprache, Länge, Thema, Region, Epoche, Rolle) + lokale Suche kommen in η-4 und η-5.") + .font(.callout) + .foregroundStyle(ZitareTheme.mutedForeground) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(ZitareTheme.background) + } +} diff --git a/Sources/Core/Auth/AppConfig.swift b/Sources/Core/Auth/AppConfig.swift index bf6f263..438e927 100644 --- a/Sources/Core/Auth/AppConfig.swift +++ b/Sources/Core/Auth/AppConfig.swift @@ -4,8 +4,9 @@ import ManaCore /// App-spezifische Konfiguration für Zitare. Implementiert /// `ManaAppConfig` aus ManaCore und ergänzt die Zitare-eigene /// `apiBaseURL` (api.zitare.com, getrennt von mana-auth) sowie -/// `webBaseURL` (zitare.com, für WKWebView und Universal-Links) -/// und `appBaseURL` (app.zitare.com, für eingeloggte Pfade). +/// `webBaseURL` (zitare.com, für Universal-Links + Snapshot-Pull + +/// SafariView auf Legal-Seiten) und `appBaseURL` (app.zitare.com, +/// historisch SPA-Surface — η-0+ nicht mehr vom Native-Client geladen). /// /// Cutover zu .zitare.com-Subdomains am 2026-05-20. zitare.mana.how /// ist abgeschaltet; zitare-api.mana.how bleibt als Back-Compat- @@ -36,18 +37,19 @@ enum AppConfig { /// Edit, Admin, Me). static let appBaseURL = URL(string: "https://app.zitare.com")! - /// Default-URL für den WebView (öffentliches Lese-Surface). + /// Default-Web-URL (öffentliches Lese-Surface, Snapshot, AASA). + /// η-0+: kein WebView mehr, aber bleibt Universal-Link-Domain und + /// SafariView-Ziel für Legal-Seiten. static let webBaseURL = publicWebURL - /// Endpoint für den Korpus-Snapshot (Phase ζ-2). Heute noch nicht - /// als statische HTTP-Datei publiziert — Aufgabe im Web-Repo: - /// `apps/zitare/static/index-min.json` aus dem Snapshot-Job - /// zusätzlich rauskopieren. Bis dahin schlägt der Pull mit 404 - /// fehl und `SnapshotSync.tryRefresh()` macht fail-soft no-op. + /// Endpoint für den Korpus-Snapshot (heute Phase ζ-2: `index-min.json`, + /// in η-1 ersetzt durch `index-full.json` + Stammdaten-Collections, + /// siehe `docs/NATIVE_LIFT_PLAN.md`). Bis das im Web-Repo gebaut ist, + /// schlägt der Pull mit 404 fehl und `SnapshotSync.tryRefresh()` macht + /// fail-soft no-op. static let snapshotURL = webBaseURL.appendingPathComponent("index-min.json") - /// User-Agent-Suffix für WKWebView (ManaWebShell). WKWebView hängt - /// das an seinen Standard-UA an, ersetzt ihn nicht. + // User-Agent-Suffix für URLSession-API-Calls. #if os(macOS) static let userAgent = "ZitareNative/0.1 (macOS)" #else diff --git a/Sources/Core/WebShell/CookieBridge.swift b/Sources/Core/WebShell/CookieBridge.swift deleted file mode 100644 index c13211d..0000000 --- a/Sources/Core/WebShell/CookieBridge.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation -import ManaCore -import WebKit - -/// Reicht den mana-auth-JWT als Cookie an den `WKWebView` weiter, sodass -/// eingeloggte `(app)`-Routen auf `app.zitare.com` ohne zweiten Login -/// erreichbar sind. -/// -/// **Phase ζ-1: Skeleton.** Methoden existieren, werden aber heute -/// nicht aufgerufen — bevor sie scharfgeschaltet werden, muss der -/// Cookie-SSO-Pfad auf der Web-Seite (`zitare/apps/api/src/auth/` -/// und `apps/zitare/src/lib/auth/token-helper.ts`) gegen einen -/// *echten* mana-auth-Token End-to-End getestet sein (Verifikations- -/// Lücke in `zitare/STATUS.md`). -/// -/// **Cross-Domain-Flow (Cutover 2026-05-20):** Der WKWebView lädt -/// `app.zitare.com` (Brand-Domain). Die SvelteKit-App ruft beim -/// Boot Cross-Origin `POST auth.mana.how/api/v1/auth/refresh` mit -/// `credentials: 'include'` und erwartet einen Refresh-Cookie auf -/// `.mana.how`. Der Cookie-Domain-Wert hier (`.mana.how`) ist -/// genau richtig — der Browser sendet ihn an das XHR-Ziel -/// (auth.mana.how), unabhängig vom Source-Page-Host. Identisches -/// Pattern wie im Web-Client (`apps/zitare/src/lib/auth.ts`). -/// -/// **Cookie-Schema** (gespiegelt zu mana-auth `better-auth.config.ts`): -/// - Name: `mana.access` (JWT) und optional `mana.refresh` (Opaque) -/// - Domain: `.mana.how` (Cookie wird an auth.mana.how-XHR mitgesendet) -/// - Path: `/` -/// - Secure: true, HTTPOnly: false (WebView muss lesen können) -/// - SameSite: **None** — mana-auth setzt für Cross-Subdomain-SSO -/// `sameSite: 'none'` (better-auth.config.ts), ohne das wird der -/// Cookie bei Cross-Origin-POST nicht mitgesendet. Foundation -/// `HTTPCookieStringPolicy` hat dafür keinen Konstanten-Wert → -/// `cookieAttributesNone` als Roh-String über die initWithProperties- -/// `String`-Variante. -enum CookieBridge { - /// Setzt den `mana.access`-Cookie im geteilten `WKHTTPCookieStore`, - /// wenn der `AuthClient` einen gültigen JWT hält. No-op sonst. - @MainActor - static func installManaAccess(from auth: AuthClient) async { - guard case .signedIn = auth.status, let token = currentAccessToken(from: auth) else { - Log.web.debug("CookieBridge: kein signedIn-Token, no-op") - return - } - guard let cookie = makeAccessCookie(token: token) else { - Log.web.warning("CookieBridge: konnte Cookie-Properties nicht bauen") - return - } - let store = WKWebsiteDataStore.default().httpCookieStore - await store.setCookie(cookie) - Log.web.info("CookieBridge: mana.access für .mana.how gesetzt") - } - - /// Entfernt den `mana.access`-Cookie wieder — etwa nach Logout. - @MainActor - static func removeManaAccess() async { - let store = WKWebsiteDataStore.default().httpCookieStore - let cookies = await store.allCookies() - for cookie in cookies where cookie.name == "mana.access" { - await store.deleteCookie(cookie) - } - Log.web.info("CookieBridge: mana.access entfernt") - } - - private static func currentAccessToken(from auth: AuthClient) -> String? { - // ManaCore hält den JWT im Keychain. In ζ-3 ersetzt durch die - // tatsächliche `auth.currentAccessToken()`-API; heute nur - // Skelett-Hook, damit Cookie-Setup und API in Reichweite sind. - // Linter beruhigen ohne unused warning: - _ = auth - return nil - } - - private static func makeAccessCookie(token: String) -> HTTPCookie? { - // SameSite=None: Foundation hat keinen Konstanten-Wert für - // "None", aber HTTPCookie akzeptiert beliebige Strings für - // .sameSitePolicy. Cross-Origin-POST von app.zitare.com → - // auth.mana.how braucht None, sonst kein Cookie-Versand. - HTTPCookie(properties: [ - .name: "mana.access", - .value: token, - .domain: ".mana.how", - .path: "/", - .secure: true, - .sameSitePolicy: "None" - ]) - } -} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index 98e2fa2..9522dad 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -3,9 +3,10 @@ import ManaCore import SwiftUI /// Phase ζ-0 minimal: zeigt Auth-Status und Healthz-Probe-Ergebnis. -/// Phase ζ-3 erweitert um Submission-History-Link (via WebShell auf -/// `zitare.mana.how/me`). Login-Sheet schon hier, damit Guests einen -/// Anmelden-Button finden. +/// Phase η-6 erweitert um native Submission-History (eigene +/// Submissions-Liste via `GET /api/v1/me/submissions`) + Link auf +/// Web-Konto-Verwaltung via SafariView. Login-Sheet schon hier, damit +/// Guests einen Anmelden-Button finden. struct AccountView: View { @Environment(AuthClient.self) private var auth @Environment(ManaAuthGate.self) private var authGate @@ -185,13 +186,12 @@ struct AccountView: View { private var aboutCard: some View { VStack(alignment: .leading, spacing: 8) { - Text("Phase ζ-0 — Setup") + Text("Phase η-0 — De-Hybrid") .font(.caption) .foregroundStyle(ZitareTheme.mutedForeground) Text( - "Diese App ist noch im Aufbau. Web-App live auf " - + "zitare.com und zitare.mana.how. " - + "Plan in mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md." + "Diese App wird nativ ausgebaut. Web-App weiter live auf " + + "zitare.com. Plan in zitare-native/docs/NATIVE_LIFT_PLAN.md." ) .font(.footnote) .foregroundStyle(ZitareTheme.foreground) diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index 5a67fba..f83cbe8 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -1,19 +1,18 @@ import SwiftUI -/// Phase ζ-5 Placeholder. +/// Phase η-9 Placeholder. /// -/// Aufgabenliste in ζ-5: +/// Aufgabenliste in η-9: /// -/// - Theme-Toggle (System / Light / Dark) — propagiert per -/// `localStorage['zitare-mode']` an den WebView. -/// - Reader-Schriftgröße (S/M/L/XL) — per JS-Bridge an die Web-CSS- -/// Variable `--zit-reader-size`. -/// - DSGVO-Daten-Export (öffnet `zitare.mana.how/me` Export-Page im -/// WebView). -/// - About / Impressum / Lizenz (CC-BY-SA-4.0). +/// - Theme-Toggle (System / Light / Dark) — direkt nativ via +/// `@AppStorage("zitare-mode")` → `ZitareTheme.applyMode`. +/// - Reader-Schriftgröße (S/M/L/XL) — `@AppStorage("zitare-reader-size")`, +/// propagiert an `QuoteDetailView` (η-2). +/// - DSGVO-Daten-Export — SafariView auf `https://app.zitare.com/me/export`. +/// - About / Impressum / Lizenz (CC-BY-SA-4.0) — SafariView-Sheets (η-7). struct SettingsView: View { var body: some View { - Text("Einstellungen — ζ-5 TODO") + Text("Einstellungen — η-9 TODO") .foregroundStyle(.secondary) } } diff --git a/Tests/UnitTests/AppConfigTests.swift b/Tests/UnitTests/AppConfigTests.swift index e2beb0b..c358709 100644 --- a/Tests/UnitTests/AppConfigTests.swift +++ b/Tests/UnitTests/AppConfigTests.swift @@ -1,9 +1,9 @@ +import ManaCore import XCTest @testable import ZitareNative -/// Phase ζ-0 Basis-Tests: Konfigurations-Konstanten sind konsistent -/// mit dem mana-Plattform-Setup (Bundle-ID, Keychain-Service, -/// Endpoint-Domains). +/// Basis-Tests: Konfigurations-Konstanten sind konsistent mit dem +/// mana-Plattform-Setup (Bundle-ID, Keychain-Service, Endpoint-Domains). final class AppConfigTests: XCTestCase { func test_authBaseURL_pointsToManaAuth() { XCTAssertEqual( @@ -12,8 +12,11 @@ final class AppConfigTests: XCTestCase { ) } - func test_keychainService_matchesBundle() { - XCTAssertEqual(AppConfig.manaAppConfig.keychainService, "ev.mana.zitare") + func test_keychainService_matchesSharedGroup() { + // Zitare-native teilt die Keychain-Group mit den 11 anderen + // nativen mana-Apps (Cross-App-SSO via + // QP3GLU8PH3.ev.mana.session, siehe mana-swift-core). + XCTAssertEqual(AppConfig.manaAppConfig.keychainService, ManaSharedKeychainGroup) } func test_apiBaseURL_pointsToZitareApi() { diff --git a/docs/NATIVE_LIFT_PLAN.md b/docs/NATIVE_LIFT_PLAN.md new file mode 100644 index 0000000..0fe35fd --- /dev/null +++ b/docs/NATIVE_LIFT_PLAN.md @@ -0,0 +1,306 @@ +# Native-Lift Plan — zitare-native (ζ → η) + +**Stand: 2026-05-22 — Planung.** Drei Antworten von Till liegen vor +(Offline-first + lokal-first-Suche + SafariView für Legal). Dieser Plan +ersetzt die Hybrid-Strategie aus dem Greenfield-Playbook +[`../../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md) +für die Read-Surfaces. Submit / ShareExt / Widget / Spotlight bleiben +in ihrer schon-nativen Form. + +> SOT für die Status-Spur: dieses File + `PLAN.md`. Die ζ-Phasen sind +> historisch (ζ-0/ζ-1/ζ-2 erledigt), die η-Phasen sind der Lift. + +## Was sich ändert vs. Hybrid + +| Bereich | Vorher (ζ) | Nachher (η) | +|---|---|---| +| Lesen-Tab | `WebShellView` auf `zitare.com` | `QuoteFeedView` (SwiftUI), Daten aus SwiftData | +| Erkunden-Tab | `WebShellView` auf `zitare.com/explore` | `ExploreView` (SwiftUI) mit Facet-Filter | +| Quote-Detail | Web-Route `/q/` | `QuoteDetailView` (nativ), Deep-Link-Target | +| Author/Source/Thema | Web-Route | Native Detail-Views | +| Search | `Pagefind` im Web | SwiftData-FTS lokal + API-Fallback | +| Account | (heute schon Stub) | `AccountView` voll nativ, ManaAuthUI | +| Impressum/Datenschutz/Lizenz | Web-Routen | `SFSafariViewController`-Sheet auf `zitare.com/` | +| `ManaWebShell`-Dep | benutzt | **wird komplett entfernt** | +| `CookieBridge.swift` | benötigt | **entfällt** (kein WebView, kein Cookie-SSO mehr) | +| Cookie-SSO `mana.access` → WebView | Phase 2.G | **gestrichen** | +| AASA `applinks` | `zitare.com` + `app.zitare.com` | bleibt; Routen jetzt nativ | +| Snapshot `index-min.json` | nur Meta (11 Quotes, Slugs+Tags) | **erweitert** auf Volltext + Author-/Source-/Themen-Stammdaten | +| Datenmodell | nur `SnapshotQuote` (Stub) | volles SwiftData-Schema gespiegelt zu zitare-DB | + +## Architektonische Invarianten (η) + +Diese ersetzen die alten Hybrid-Invarianten in `CLAUDE.md`: + +1. **Pure SwiftUI für alle Surfaces.** Kein `WKWebView` mehr im App- + Binary. `ManaWebShell`-Dep raus aus `project.yml`. +2. **Offline-first Lesen.** SwiftData ist Primary-Store, API ist + Fallback bei Cache-Miss + Sync-Quelle. Flugmodus-Test ist Akzeptanz- + Kriterium für η-2. +3. **Quote-Korpus ist Snapshot.** Beim Launch + Background-Refresh + wird `index-full.json` gepullt, ETag-aware, in SwiftData persistiert, + per App-Group an Widget + ShareExt + Spotlight durchgereicht. +4. **Server gewinnt bei Schema-Konflikt.** SwiftData-Modelle sind eine + abgeleitete Spiegelung des Drizzle-Schemas in `zitare/apps/api/`. + Bei Drift: Server-Schema, dann Snapshot-Format, dann Native-Modell. +5. **Search lokal-first.** Lokale FTS aus SwiftData; bei < 3 Treffern + oder leerem Result Server-Fallback (`GET /api/v1/quotes?q=`). +6. **Legal-Seiten via SafariView.** Impressum / Datenschutz / Lizenz + öffnen `SFSafariViewController`. Das ist die **einzige** zugelassene + Browser-Brücke und nur für statisches Verein-Recht-Material. +7. **Schreiben bleibt Submit-only in v1.** Edit / Moderation / Flags- + Verwaltung kommen NICHT in die App. Bleiben Web (`app.zitare.com`). + Wer das braucht, öffnet Safari. +8. **Universal-Links auf `zitare.com` lösen ins native Surface.** AASA + bleibt, aber `onContinueUserActivity` routet auf SwiftUI-Views, nicht + in einen WebView-Tab. +9. **Keine Web-Re-Implementation von Web-Funktion.** Wenn eine Read- + Ansicht in der App fehlt, die im Web existiert: erst Web im SOT + prüfen, dann nativ nachziehen. Kein einseitiger Native-Vorlauf, der + das Web abhängt. + +## Phasen-Plan + +Erfolgskriterium ist je **eine** Sache. Nicht in die nächste Phase +arbeiten, bevor die vorherige geschlossen ist. + +### η-0 — De-Hybrid (Vorbereitung) + +- `CLAUDE.md` Invarianten 1, 2, 8 ersetzen durch die η-Liste oben. +- `ManaWebShell`-Dep aus `project.yml` entfernen. +- `Sources/Core/WebShell/CookieBridge.swift` löschen. +- `RootView.swift` auf vier native Tabs umstellen (Lesen / Erkunden / + Einreichen / Konto) — Lesen + Erkunden zeigen erstmal Platzhalter + („wird in η-2/η-4 implementiert"). +- `Features/Settings/SettingsView.swift` + `Features/Account/AccountView.swift` + von WebView-Verweisen befreien (Settings-„Hilfe → öffne Browser" + bleibt, aber als `SafariView`). +- Submit / ShareExt / Widget bleiben unangetastet. + +**Erfolg:** Build grün auf iOS-Simulator, kein `WKWebView`-Symbol im +Binary (`nm | grep WKWebView` leer), App startet, alle vier Tabs +sichtbar, Lesen + Erkunden zeigen Platzhalter-View. + +### η-1 — Snapshot v2 + SwiftData-Schema + +**Cross-Repo: zitare-web muss liefern, bevor η-2 startet.** + +Web-Repo-Aufgaben (`../zitare/apps/zitare/`): +- Build-Script erweitern: `index-full.json` mit allen Quote-Feldern + inkl. `text`, `language`, `lengthBucket`, `form`, `createdAt`, `author`, + `source`, `tags[]`, `themes[]`, plus Author-/Source-/Theme-/Place-/ + Epoch-/Role-Stammdaten als eigene Collections (`authors.json`, + `sources.json`, `themes.json`, `places.json`, `epochs.json`, + `roles.json`). +- ETag-Header für jeden dieser Endpoints (Cache-Control: 1h). +- Stabile URL: `https://zitare.com/index-full.json` (Apex), nicht + über mana.how. +- Größen-Budget: Korpus < 5 MB unkomprimiert für die nächsten ~5000 + Quotes (Pagination dann in η-5 nachgedacht, heute 11 Quotes). + +Native-Aufgaben: +- SwiftData-Modelle in `Sources/Core/Snapshot/Models/`: + `SDQuote`, `SDAuthor`, `SDSource`, `SDTheme`, `SDPlace`, `SDEpoch`, + `SDRole`, `SDTag`. Relations 1:1 mit Drizzle. +- `SnapshotSync.swift`: lädt parallel die sieben Collections, diffed + gegen lokalen Store, persistiert atomar. +- App-Group-Container statt nur-App-Container, damit Widget + + ShareExt + Spotlight denselben Store lesen. +- ETag im UserDefaults persistieren, `If-None-Match` schicken. + +**Erfolg:** Nach Cold-Start lädt App den vollen Korpus, SwiftData- +Store hat erwartete Row-Counts, ETag spart Bytes beim Reload. + +### η-2 — Read-Core nativ (Heute, Quote-Detail, Author-Detail) + +- `QuoteCard`-Komponente (Text, Author, Theme-Chips, lengthBucket). +- `HeuteView`: deterministisches Tages-Quote (Hash über Datum + Korpus- + Count). Match-Logik mit Widget abgleichen. +- `QuoteDetailView` für Deep-Link `/q/`: Vollansicht, Author- + Link, Source-Link, Theme-Chips, Quelle-Metadaten, Lizenz-Hinweis. +- `AuthorDetailView` für `/a/`: Bio (Name, Native-Name, Places, + Epochs, Roles), Quote-Liste des Authors. +- `DeepLinkRouter` umverdrahten: keine WebShell-Targets mehr, sondern + `NavigationPath`-Push. + +**Erfolg:** Flugmodus, frische Install, App zeigt Heute-Quote + +Quote-Detail nach Universal-Link aus Mail-App. + +### η-3 — Read-Browse (Source, Theme, Place, Role, Epoch, Language) + +- Detail-Views je Entity mit Listing der zugehörigen Quotes. +- `SourceDetailView` für `/c/`. +- `ThemeListView`, `ThemeDetailView` für `/thema/`. +- `PlaceListView` (Regionen) für `/region/`. +- Analog Roles / Epochs / Languages. +- Konsistente `EntityListSection`-Komponente (Title, Count, Chip-Grid). + +**Erfolg:** Alle Web-Deep-Link-Pattern aus `app-manifest.json` rufen +die richtige native View auf. + +### η-4 — Explore + Filter + +- `ExploreView` als kombinierte Filter-Oberfläche: + Sprache, Length-Bucket, Form, Theme, Region, Epoche, Role. +- Filter-State als `@Observable`-Klasse, persistierbar in + UserDefaults. +- Live-Result-Counter über SwiftData-`@Query` mit dynamischem + `Predicate`. +- iPad: NavigationSplitView mit Filter-Sidebar + Result-List. + +**Erfolg:** Filter „Roman + DE + lang" zeigt korrekte Subset live, +ohne Server-Call. + +### η-5 — Search (lokal + Server-Fallback) + +- Lokale FTS: + - Bei Snapshot-Import: pro Quote `normalizedText` (lowercase, + diakritika-frei) ableiten, in `SDQuote.searchTokens` als + `[String]` (oder als `String` mit Space-Separator) speichern. + - Query-Splitting: Whitespace + Diakritika-Normalisierung. + - SwiftData-Predicate mit `contains` über `searchTokens`. +- Server-Fallback: + - Wenn lokaler Result-Count < 3 ODER Query-Länge < 3 Token: + parallel `GET /api/v1/quotes?q=` (Endpoint muss in + zitare-api vorhanden sein — prüfen, ggf. ergänzen). + - Server-Results mit Marker „Online-Treffer" einsortieren, nicht + persistieren. +- UI: `SearchView` mit `searchable()`, Result-List mit Source-Marker + (lokal vs. online), Empty-State. + +**Erfolg:** Begriff der existiert lokal kommt offline; seltener +Begriff bringt Online-Treffer dazu, der vom Sync nicht abgedeckt war. + +### η-6 — Account nativ (vollständige Auth-Surface) + +- `AccountView` heute hat Health-Status + Sign-In-Button als Stub. +- Voll bauen: Profil-Info, Gerätesitzungen, Sign-Out-Button, Link auf + Web-Konto-Verwaltung via SafariView. +- Eigene Submissions-Liste (`GET /api/v1/me/submissions`, falls + vorhanden — sonst Cross-Repo-Aufgabe). +- Keine WebView-Verlinkung mehr. + +**Erfolg:** Sign-In + Sign-Out vollständig nativ, JWT im Keychain +verifizierbar. + +### η-7 — Legal via SafariView + +- `LegalSheet.swift`: `UIViewControllerRepresentable` um + `SFSafariViewController` (iOS) / `NSWorkspace.open` (macOS). +- Drei Links im Settings-Tab: Impressum, Datenschutz, Lizenz. +- macOS: öffnet System-Browser, kein SafariView dort. + +**Erfolg:** Tap auf „Impressum" öffnet `https://zitare.com/impressum` +im SFSafariViewController-Sheet, nicht im App-WebView (gibt's nicht +mehr). + +### η-8 — Submit + ShareExt + Spotlight an Snapshot v2 anpassen + +- Submit-Author-Autocomplete: heute über `ZitareAPI.searchAuthors` + (live API). Jetzt erstmal lokal aus Snapshot (instant), bei < 3 + Treffern Server-Fallback. +- ShareExt: sollte schon nativ sein, prüfen ob es WebShell-Imports + hat (`grep WebShell ShareExtension/`). +- Spotlight: jetzt mit Volltext indexieren statt nur Meta — + `searchableItem.contentDescription = quote.text.prefix(200)`. + +**Erfolg:** Spotlight findet Quote nach Volltext-Fragment, nicht nur +nach Author/Theme. + +### η-9 — Polish (iPad-Split, Accessibility, macOS-Layout) + +- iPad: NavigationSplitView für Quote-Listen. +- macOS: Window-Layout, Sidebar-NavSplit statt TabBar. +- Dynamic Type, VoiceOver-Labels für QuoteCard, Theme-Sync mit + System-Dark-Mode (ohne JS-Bridge, weil ohne WebView). +- Haptics für Heute-Quote-Refresh. + +**Erfolg:** Eine Woche Solo-Nutzung ohne offene Browser-Tabs zu +Zitare-Inhalten. + +### η-10 — TestFlight + +- App-Store-Connect: Build hochladen, Review-Notes, Screenshots. +- Apple-Dev-Portal-Blocker (App-Group) ist hier vermutlich + längst erledigt; falls nicht: nachziehen vor Upload. + +**Erfolg:** TestFlight-Tester können Korpus lesen + Quote einreichen. + +## Cross-Repo-Aufgaben + +### `../zitare/apps/zitare/` (Web-Build) + +- [ ] `index-full.json` Build-Script + Endpoint (η-1 Blocker) +- [ ] Stammdaten-Collections: `authors.json`, `sources.json`, + `themes.json`, `places.json`, `epochs.json`, `roles.json`, + `languages.json` (η-1 Blocker) +- [ ] ETag-Header auf allen Snapshot-Endpoints +- [ ] AASA bleibt wie heute (kein Change) + +### `../zitare/apps/api/` (API) + +- [ ] `GET /api/v1/quotes?q=...` Volltext-Search prüfen — wenn nicht + vorhanden, ergänzen (Drizzle + PG `to_tsvector` oder einfaches + `ilike` für Anfang). Search-Quality-Diskussion in η-5. +- [ ] `GET /api/v1/me/submissions` für AccountView prüfen, ggf. + ergänzen (η-6). +- [ ] Wire-Format-Check: Native-Codable-Structs gegen Hono-zod- + Response in η-2/η-3 abgleichen, Test-Fixtures aus echten + Server-Responses einchecken. + +### `../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md` + +- [ ] Status-Banner ergänzen: Read-Surface-Strategie revidiert + 2026-05-22, jetzt fully-native; SOT für laufende Arbeit ist + dieses File. + +### Apple Developer Portal + +- [ ] App-Group `group.ev.mana.zitare` registrieren — falls noch + offen aus ζ-0/ζ-2. Selbe Blocker-Lage wie cards/memoro/moodlit. + +## Offene Punkte + +- **Snapshot-Größe-Skalierung.** Heute 11 Quotes, Plan ist < 5 MB + unkomprimiert. Ab welcher Korpus-Größe kippt das? Wahrscheinlich + > 5000 Quotes. Antwort: Pagination + Sync-Deltas in η-5+. Heute + irrelevant. +- **Multilingual FTS.** Lokale Search splittet nach Whitespace, ist + diakritika-frei. Stemming (dt + en + andere) ist nicht im Plan. + Falls Such-Qualität schwach: in η-5 nachschärfen oder als „kennen + wir, später" akzeptieren. +- **Quote-Text-Formatierung.** Hat ein Quote-Text Markdown-artige + Zeichen (Zeilen­umbrüche, Hervorhebung)? Falls ja: in η-2 mit + `AttributedString` rendern. Web-SOT prüfen. +- **macOS-Layout.** Bisher steht macOS auf Apple-Dev-Portal-Blocker. + Sobald entblockt: SplitView statt TabBar. In η-9. +- **Submit-Pfad und Online-Pflicht.** Submit braucht Auth + API. + Wenn offline: queue + retry — ist heute schon so vorgesehen, + prüfen, ob die Queue noch passt nach SwiftData-Schema-Lift. +- **Schema-Versions-Tag im Snapshot.** SwiftData-Migrationen für + künftige Schema-Änderungen brauchen einen Versions-Marker. In η-1 + mit eingeplant (`SchemaV1.swift`), aber Migrations-Strategie ist + „lokal löschen + neu sync" für v0.x → v1.0. +- **Was passiert mit `/feed.rss`?** Deep-Link-Pattern existiert. + Native-Antwort: SafariView oder „nicht in App"-Toast? In η-2 + entscheiden. +- **`app.zitare.com` Universal-Link.** Heute AASA-Ziel. Wenn die + SPA dort wegfällt für native Nutzer, brauchen wir AASA dort + weiterhin (für Web-Fallback ja, für native Routing — als + identisch zu zitare.com behandeln)? In η-2 routen wir beide + Domains gleich, AASA bleibt redundant gepflegt. +- **Web-App auf `app.zitare.com` selbst.** Wenn native der primäre + Read-Pfad wird, wozu existiert die SPA dann noch? Antwort: + Browser-Nutzer + nicht-iOS/macOS-User + Moderation/Admin. Aber + Frage rechtfertigt sich, ob die SPA aktiv weiterentwickelt wird + oder einfriert. Nicht hier entscheiden, aber in `zitare/STATUS.md` + diskutieren. + +## Was außerhalb bleibt + +- **Push-Pipeline.** Widget reicht. +- **Eigener Pagefind-Klon.** Lokale FTS ist SwiftData-Predicate, + nicht Web-Pagefind portiert. +- **FSRS oder Lernen.** Nicht Zitare-Scope. +- **Crash-Reporting-SaaS.** OSLog only (Compliance). +- **Admin / Moderation / Edit in der App.** Bleiben Web. diff --git a/project.yml b/project.yml index 5698a11..0aeec20 100644 --- a/project.yml +++ b/project.yml @@ -42,8 +42,6 @@ targets: product: ManaTokens - package: ManaSwiftUI product: ManaAuthUI - - package: ManaSwiftUI - product: ManaWebShell - target: ZitareWidgetExtension embed: true platformFilter: iOS