η-0: De-Hybrid — WKWebView raus, native Tabs mit Platzhaltern

Lift von Hybrid (WKWebView für Lesen/Erkunden) auf fully-native ist
beschlossen. Diese Phase entfernt die WebShell-Infrastruktur; das volle
native Read-Surface folgt in η-2..η-5 nach docs/NATIVE_LIFT_PLAN.md.

- ManaWebShell-Dep raus aus project.yml
- Sources/Core/WebShell/CookieBridge.swift gelöscht
- RootView auf vier native Tabs (Lesen + Erkunden = Platzhalter,
  Submit + Konto unverändert nativ)
- DocComments in DeepLinkRouter / AppConfig / Account / Settings von
  WebView-Verweisen befreit
- CLAUDE.md Invarianten von Hybrid auf η umgestellt (13 Invarianten,
  pure SwiftUI + Offline-first + SafariView-Ausnahme für Legal)
- PLAN.md auf η-0 + Phasenübersicht η-0..η-10
- AppConfigTests.test_keychainService_matchesSharedGroup auf
  ManaSharedKeychainGroup aktualisiert (war drift seit Cross-App-SSO)

Verifikation:
- xcodebuild iOS-Simulator iPhone 16e: BUILD SUCCEEDED
- nm ZitareNative | grep WKWebView: 0 Referenzen
- otool -L: kein WebKit-Framework-Link
- 20/20 Tests grün

Cross-Repo-Follow-up (η-1 Blocker):
- zitare/apps/zitare/ muss index-full.json + 7 Stammdaten-JSONs liefern
- zitare/apps/api/ Volltext-Search-Endpoint bestätigen/ergänzen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-22 12:32:05 +02:00
parent 58eb2807c7
commit 1d770123f5
11 changed files with 619 additions and 365 deletions

160
CLAUDE.md
View file

@ -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.

170
PLAN.md
View file

@ -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=<query>` 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.

View file

@ -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/<slug>`, `/a/<slug>`, `/c/<slug>`, `/thema/<slug>` 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`.

View file

@ -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 <html>.
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/<slug>` / `/a/<slug>` 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/<slug>`, `/a/<slug>`, `/c/<slug>` 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/<slug>` wird auf
/// `https://zitare.com/q/<slug>` 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)
}
}

View file

@ -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

View file

@ -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"
])
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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() {

306
docs/NATIVE_LIFT_PLAN.md Normal file
View file

@ -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/<slug>` | `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/<page>` |
| `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/<slug>`: Vollansicht, Author-
Link, Source-Link, Theme-Chips, Quelle-Metadaten, Lizenz-Hinweis.
- `AuthorDetailView` für `/a/<slug>`: 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/<slug>`.
- `ThemeListView`, `ThemeDetailView` für `/thema/<slug>`.
- `PlaceListView` (Regionen) für `/region/<slug>`.
- 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=<query>` (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.

View file

@ -42,8 +42,6 @@ targets:
product: ManaTokens
- package: ManaSwiftUI
product: ManaAuthUI
- package: ManaSwiftUI
product: ManaWebShell
- target: ZitareWidgetExtension
embed: true
platformFilter: iOS