Status (2026-08-28): lebend. Ist-Stand von Stack, Routen, i18n-Soll, Deploy, Admin-Gate und Crawler-Prerender; jede Änderung an
app/package.json,app/src/routes/paths.ts, den Cloudbuild-/nginx-Dateien,api/auth.pyoderapp/src/lib/seo/prerender.tszieht hier nach. Am 2026-08-03 gegen den Code geprüft und deckungsgleich (Admin-Routen nach dem Redesign „aus einem Guss":/adminVorlagen-Auswahl + die drei Ansichten Buchstaben · Übergänge · Wörter; Admin-Token-Regeln, PR #263). Am 2026-08-16 um die ausCLAUDE.mdhierher verschobenen Detailregeln ergänzt (Vier-Gesichter-Übersicht, Registrierungs-Regel, Kostenbudget, Cloud-Session-Betrieb u. a.) — Beschreibungsstand dieser Punkte: 2026-08-16.
Technische Spezifikation des Endnutzer-Frontends aus Vision §1 (Einstieg),
§2 (Lineatur-Konfigurator), §3 (Animation), §4 (Lesen üben), §5 (Lese-Hilfe
inkl. Lupe), §6 (Stil-Analyse-Upload + Hände-Vergleich), §7 (Open-Data) und
dem Zweisprachig-Leitprinzip (DE/EN). Ergänzt
architektur.md §16.
Kernprinzip: anyplot-Stil — das gleiche Setup wie
~/projects/anyplot/app/, das anyplot.ai auf Cloud Run trägt. Eine SPA,
gemeinsam für Endnutzer und Admin, mit Auth-Gate für sensible Routen.
| Komponente | Version | Zweck |
|---|---|---|
| React | 19.x | UI-Framework. Server Components nicht genutzt — wir bleiben Client-Only. |
Vite (mit @vitejs/plugin-react-swc) |
8.x | Build-Tool, schneller HMR. |
| MUI + Emotion | 9.x | Komponenten-Bibliothek. |
| React Router | 7.x | Client-Side-Routing. |
react-helmet-async |
geplant (P1) | SEO-Meta-Tags pro Route (Title, Description, Open Graph). Noch nicht installiert. |
react-i18next |
geplant (P1) | Internationalisierung DE/EN. Noch nicht installiert (siehe i18n unten). |
| TypeScript | 6.x | Typsicherheit. |
vite-plugin-compression2 |
geplant (P1) | Gzip + Brotli-Pre-Compression. Noch nicht installiert. |
Package Manager: npm (wie heute im Repo — app/package-lock.json ist
checked in; anyplot nutzt yarn, wir bewusst nicht).
Test-Abdeckung: Vitest misst seit 2026-09-02 über die ganze
SPA-Quelle (test.coverage.include: ['src/**/*.{ts,tsx}'] in
app/vite.config.ts, in Vitest 4 der Ersatz für das alte all: true) —
ohne diesen Block zählt nur, was ein Test zufällig importiert, was 82,7 %
meldete, wo über die ganze Quelle 19,2 % stehen. Eine Zahl, die ihre
eigene Testliste misst, ist schlechter als keine. Die Codecov-Ziele in
codecov.yml sind seither feste Böden je Flag statt auto.
Begründung gegen andere Stacks (Verworfen-Sektion):
- Astro mit Islands-Architektur — wäre für die SEO-Inhaltsseite ein
besserer Fit, aber das jetzige
/app/ist schon Vite+React+MUI. Ein zweiter Stack wäre Pflegeaufwand. SEO ist mitreact-helmet-async+ Googles JS-Rendering tragbar. - Next.js — Vercel-zentriert, Cloud Run möglich aber Reibung. Größerer Footprint. Für uns keine RSC-Bedarfsfall.
- Reine SSG — Inhalt ist teilweise dynamisch (User-Renders, Stil-Analyse, HTR-Job-Status). SSG-only ohne Client-State wäre Brokerei.
So liegen die Routen heute im Code (app/src/routes/paths.ts +
routes/sections/public.tsx; die IA mit den drei Bereichen und den zwei
Hub-Seiten ist in design-system.md §6
festgelegt). Die Pfade sind ohne Sprachpräfix notiert; im Ziel-Design
(P1, siehe i18n unten) wandern sie unter /de/… (Default) bzw. /en/… —
die englischen Slug-Varianten werden mit dem locales/en/-Bundle
definiert (P1-Arbeit).
| Pfad | Inhalt | Bereich |
|---|---|---|
/ |
Landing (der Hero schreibt das Markenwort engine-first: WrittenWord über /write/word, die Engine bekommt so lange sie braucht — Owner-Entscheidung 2026-08-27; nur ein echter Fehler fällt auf den GLKurrent-Clip-Path-Wisch zurück — §7) |
Einstieg |
/schriftkunde |
Überblick der deutschen Schreibschriften (der umbenannte frühere /lehrbuch) |
Schriftkunde |
/lesen |
Hub → Quiz, Tafel | Lesen |
/quiz |
Lese-Quiz (Buchstaben + ganze Wörter) | Lesen |
/tafel[?g=<key>] |
Schreibtafel (Vorlage); mit g der Buchstabe im Detail unter dem Bogen (sections/tafel/LetterDetail.tsx, Vision Ziel 3: Strichfolge mit nummerierten Zügen und Stepper „Zug n von m", Ansatz/Auslauf-Ringe, Zug um Zug in zwei Tempi, Verwechsler als SpecimenStrip, Sprung in die Federprobe mit einem Bankwort — ein Tipp auf einen Buchstaben des Bogens setzt g, teilbar); seit 2026-08-29 mit „Lesetafel als PDF" — alle drei Vorlagen auf A4, im Browser gebaut (lib/lesetafel.ts auf lib/pdf.ts: die nachgeschriebene Schrift als gefüllte Silhouetten auf Lineatur mit Antiqua-Beschriftung, die anderen als ihre gemeinfreie Originaltafel, per Canvas zu JPEG gerastert und als DCTDecode-XObject eingebettet) |
Lesen |
/lesen/vergleichen |
Lesart prüfen — eine Vermutung wird geschrieben, daneben die echten Wörter, die sich von ihr nur in Verwechslern unterscheiden (GET /lesarten?text=…: Verwechsler-Schlüssel + Rang aus core/lesarten, Vokabular lesart_forms = igerman98 ∪ Wortbank, geladen über tools.lesarten.sync; seit 2026-08-30 — davor Buchstabentausch ohne Wort dahinter), und die klassischen Verwechsler-Paare nebeneinander (?text= teilbar) |
Lesen |
/schreiben |
Hub → Übungsblatt, Federprobe | Schreiben |
/schreiben/uebungsblatt |
Übungsblatt-Generator (Lineatur-Konfigurator, PDF); seit 2026-08-30 mit Übungstext — die Vorschrift-Zeilen serverseitig komponiert wie in der Federprobe (/write/word), im Browser in die Lineatur gesetzt (lib/uebungstext.ts) und in Vorschau wie PDF gleich gezeichnet |
Schreiben |
/federprobe |
Live-Schreiber (Sütterlin-Synthese mit generierten Übergängen) | Schreiben |
/impressum |
Impressum, Datenschutz, Quellen | Footer |
/lehrbuch |
Redirect → /schriftkunde (alter Name) |
— |
Noch nicht gebaut — Ziel-Routen aus der Vision, kommen mit den Post-MVP-Phasen (architektur.md §10):
| Pfad | Inhalt | Vision-Bezug | Status |
|---|---|---|---|
/lernen |
Einstieg (Geschichte, Alphabet-Tafel, Lese-Regeln) | §1 | geplant (P1+) |
/animation |
Animierte Buchstaben-Tafel | §3 | geplant |
/lesen-ueben |
Beliebiger Text → Kurrent-Rendering | §4 | geplant (P2) |
/lese-hilfe |
Upload historischer Brief → HTR-Job | §5 | geplant (P1) |
/lese-lupe/:job |
Lese-Lupe für transkribierten Brief | §5 | geplant (P1+) |
/stil-analyse |
Upload Schrift-Probe → Statistik-Report | §6 | geplant (P3) |
/vergleich |
Hände vergleichen mit Heatmaps | §6 | geplant (P4) |
/open-data |
Daten-Export-Seite mit DOI-Verweis | §7 | zurückgestellt (Open-Core, architektur.md §17) |
/glossar |
Erklärungen (Rund-s, Ligaturen, Schwellzug…) | §1, §5 | geplant |
| Pfad | Inhalt | Status |
|---|---|---|
/admin |
Einstieg: die Vorlagen-Auswahl. Alles darunter gehört zu genau einer Quelle und ihrer Hand, also steht die Wahl am Anfang statt in einem Menü; die aktive Vorlage steht danach im Header und führt mit einem Klick hierher zurück (sections/admin/shell/StartView.tsx). Die Wahl merkt sich der Browser in localStorage; Vorgabe ist CONFIG.sourceId (app/src/global-config.ts) — dieselbe Konstante hat Doppeldienst: Sie ist die Quelle, aus der die ÖFFENTLICHEN Seiten rendern (heute die Sütterlin-Ausgangsschrift 1922), UND die Vorauswahl des Admins |
existiert |
/admin/buchstaben[?g=<key>] |
Buchstaben. Ohne g die Alphabet-Übersicht (ehemals /admin/vergleich-Tab): je Buchstabe VIER Flächen — Original (Chart-Crop) · Tafel-Form (Variante 0) · Laufform (Variante 100) · „Median & Vorkommen" (die H1-Aggregat-Skizze) — samt Kennzahlen und einem Sortier-Umschalter (Alphabet · Schlechteste zuerst), der das Raster zur Arbeitsliste macht (Details §7), mit g der einzelne Buchstabe mit allen Werkzeugen: Tafel-Ausschnitt + Einrichtungs-Wizard + Diagnose + aufklappbarem Chart-Editor (ehemals /admin/chart), Tafel-Form neben Laufform, die Vorkommen aus den Wörtern, die H1-Statistik samt Frische-Chip und Differenz-Skizze, die Absprünge zu Übergängen/Wörtern — und am Fuß, bewusst abgesetzt, der Laufform-Übernahme-Block mit Bestätigungsdialog (sections/admin/letters/, Issue #270) |
existiert |
/admin/uebergaenge[?l=<key>&r=<key>] |
Übergänge. Ohne Paar die Matrix aller Zweierkombinationen (ehemals /admin/paare) plus ein Freitextfeld für JEDE Kombination, mit Paar die komponierte Verbindung, die H2-Statistik „gemessen vs. komponiert", die dissezierten Vorkommen und — als letztes Mittel — der Paar-Editor (sections/admin/joins/) |
existiert |
/admin/woerter[?w=<text>&s=<specimen>] |
Wörter. Ohne w die Wortproben-Liste mit Scores (ehemals /admin/vergleich-Tabs Wörter/Andere Hand), mit w ein beliebiger Text: wie die Engine ihn schreibt, woraus er besteht (Buchstaben + Übergänge als Absprünge) und — wo eine Platte ihn enthält — die nachgefahrene Spur mit Vorkommens-Overlay, Score und Wort-Editor (ehemals /admin/belege + /admin/werkbank-Rückgrat; sections/admin/words/) |
existiert |
/admin/eigenhand |
Eigenhand. Die einzige Admin-Ansicht, die zu einer HAND gehört statt zu einer Vorlage: Bestand der eigenen Schreibprobe (Streifen belegt/unterwegs/geplant, Fassungen, Bögen; welche Zeichen und Übergänge belegt sind — gemessen an dem, was der Streifenplan hergibt, Groß-/Kleinbuchstaben, Ligaturen, Ziffern und Sonderzeichen getrennt) und der Bogendruck (Stapel erzeugen, PDF öffnen). Dazu das stehende Setup der Hand (Feder · Tinte · Papier · Gerät) und die GESCHRIEBENEN Streifen: jede gespeicherte Fassung auf Klick, samt Ausschnitt je Wort — admin-gesichert, private, no-store, nie im Repository. Die Scans bleiben lokal; hochgeladen wird hier nichts (sections/admin/eigenhand/, ../proposals/eigenhand-erfassung.md §7.1–§7.2) |
existiert |
/admin/sources |
Source-Verwaltung | post-MVP |
/admin/jobs |
HTR-Job-Monitor (Quote-Übersicht) | post-MVP |
Ausgeblendete Vorlagen. Die Auswahl bietet nur die Quellen an, die
CONFIG.hiddenSourceIds (app/src/global-config.ts) nicht ausblendet —
heute petzendorfer-1889, das ZWEITE Kurrent-Chart (eine andere Hand mit
~57° gegenüber Loths ~50°), im Voraus eingesät für die Kurrent-Ziffernzeile,
die Loth 1866 fehlt: Solange dieses Autoring nicht beginnt, machen zwei
beide mit „Kurrent" beschriftete Karten die Einstiegswahl nur mehrdeutig.
Das Ausblenden ist eine reine Client-Liste und wird in
context/AdminContext.tsx an genau ZWEI Stellen angewandt (der einen
Verengung der Quellenliste und beim Lesen der gemerkten Auswahl, damit eine
gespeicherte ausgeblendete Id den Admin nicht auf einer Vorlage stranden
lässt, von der keine Karte wegführt). Gelöscht wird NICHTS — keine
Migration, keine DB-Änderung; die Zeile, ihre Chart-Bytes und jede
API-Route bleiben genau so, wie sie sind, und die Id aus der Liste zu
nehmen bringt sie zurück.
Admin-Redesign 2026-08 (aus einem Guss): Die fünf Seiten mit ihren
Tabs und der Dauer-Sidebar sind zu drei Ansichten über einer Vorlage
zusammengezogen — damit ist das in
optimierungs-werkbank.md §2/§6
angekündigte Aufgehen von Vergleich, Paar-Matrix und Belegen in der
Werkbank vollzogen. Jede Ansicht folgt demselben Muster Übersicht ⇄
Detail; das Subjekt steht in der Query (sections/admin/shell/focus.ts),
damit jeder Quer-Absprung ein normaler Link ist, der Zurück-Knopf die
Inspektionsgeschichte läuft und ein Reload dort landet, wo gearbeitet
wurde. Header (drei Bereiche + Vorlage + Auftragskorb) und die geteilte
Datenschicht (shell/WorkbenchData.tsx) liegen ÜBER dem Outlet, also
kostet der Weg Buchstabe → Übergang → Wort keinen neuen Ladevorgang.
Die alten Pfade (/admin/chart · /vergleich · /paare · /belege ·
/werkbank · /edit/:glyphKey) bleiben als Redirects bestehen, damit
Lesezeichen und Notizen weiter tragen.
Status: Ziel-Design für P1, noch nicht eingebaut. Ist-Stand:
react-i18next ist nicht installiert; alle deutschen UI-Strings liegen
als Pre-i18n-TS-Namespaces unter app/src/locales/de/, das Routing kennt
keine Sprachpräfixe. Der Rest dieses Abschnitts beschreibt das Soll.
react-i18nextals Library.- URL-Präfix:
/de/...(Deutscher Default) und/en/...(Englisch). Verwerfen: Cookie-basiert (schlechte UX bei Link-Sharing), Domain-Switch (Hosting-Komplexität). - Lazy-Loaded Locale-Bundles pro Sprache — JSON-Dateien unter
app/src/locales/{de,en}/....
// app/src/i18n.ts (Skizze)
i18n
.use(LanguageDetector)
.use(HttpBackend) // JSON-Files lazy laden
.use(initReactI18next)
.init({
fallbackLng: 'de',
supportedLngs: ['de', 'en'],
detection: { order: ['path', 'htmlTag', 'navigator'] },
interpolation: { escapeValue: false },
});- MVP (DE only, Ist-Stand): alle Strings nur in
locales/de/(als TS-Namespaces, noch ohne i18n-Library); keine Sprachpräfixe im Routing. - P1+ (EN folgt):
locales/en/füllen. Reihenfolge: Lese-Hilfe-UI zuerst (Genealogie-Zielgruppe), dann Inhalts-Seiten (Einstieg, Glossar). - Hilfetexte und Pitch-Texte bleiben in den Page-Komponenten als
Trans-Keys; technische Strings (Button-Labels, Validierungen) kommen in
ein gemeinsames
common.json.
Jede Page-Komponente setzt eigene Meta-Tags:
<Helmet>
<title>Lese-Hilfe — kurrentschrift.ink</title>
<meta name="description" content="Historische Briefe transkribieren …" />
<meta property="og:image" content="/og/lesehilfe.png" />
<link rel="alternate" hreflang="de" href="https://kurrentschrift.ink/de/lese-hilfe" />
<link rel="alternate" hreflang="en" href="https://kurrentschrift.ink/en/reading-help" />
</Helmet>Befund des SEO-Audits vom 2026-08-29: alle Titel waren markenintern
(„Lese-Quiz · kurrentschrift.ink", „Schreibtafel · …"), die H1s reine
Projektnamen — kein Titel außer der Startseite trug „Sütterlin",
„Kurrent" oder „alte deutsche Schrift", und die Seite war bei Bing nicht
indexiert. Regel seitdem (app/src/locales/de/seo.ts, gepinnt von
routes/seoCoverage.test.ts):
<title>: Suchbegriff vorn, Marke hinten, ≤ 80 Zeichen — „Sütterlin- Quiz: alte deutsche Schrift lesen üben · kurrentschrift.ink". Jeder indexierbare Titel nennt Sütterlin, Kurrent, „deutsche Schrift" oder „Schreibschrift"; Impressum und die noindex-404 sind ausgenommen.- H1 trägt das Suchwort, das Nav-Label bleibt der Kurzname. Die
Werkzeug-Seiten haben dafür neben
title(Kurzname für Nav, Karten, Breadcrumbs) ein eigenesheading(quiz.heading,tafel.heading,scribe.heading; Hubs:hub.*.heading, der Kurzname wird ihr Eyebrow). - Die Hubs
/lesenund/schreibentragen je einen erklärenden Absatz (hub.*.about: was die Schrift ist, für wen die Werkzeuge sind, Fakten aus der Schriftkunde) — vorher 139 bzw. 141 Wörter, zu dünn für einen Treffer. /seo-proxybeantwortet HEAD wie GET ohne Body (vorher 405 — für einen Link-Checker eine tote Seite).- Der Prerender nimmt die Breadcrumb-Bezeichnung des letzten Glieds aus dem Nav-Label der Route, nicht mehr aus dem (jetzt langen) Titel.
Nicht Teil davon: hreflang/Englisch — kommt mit der englischen Lese-Hälfte
(Website-Audit 8/8). Owner-Schritt daneben: Search Console und Bing
Webmaster Tools anmelden, Sitemap einreichen.
- Google: rendert JavaScript, indexiert SPAs mit aktualisierten
Meta-Tags zuverlässig. Reichweite für unsere Hauptzielgruppen (Genealogie
- Lernende) gegeben.
- Bing, DuckDuckGo, andere: lesen Meta-Tags ohne JS-Rendering — daher
ist
react-helmet-asyncPflicht, nicht Kür. - Social-Sharing (Open Graph): wird über
react-helmet-asyncgleich miterledigt.
public/sitemap.xmlstatisch generieren beim Build (Vite-Pluginvite-plugin-sitemapoder eigenes Script).- Aufnehmen: alle öffentlichen Routen × beide Sprachen.
Sollte sich später herausstellen, dass SEO-Indexierung doch zu schwach ist (z.B. niedrige Rankings bei langem Inhalt), Migration möglich auf:
- vite-ssg (statische Pre-Renderung der Inhalts-Routen) — kleine Migration, gleicher React-Code.
- Astro mit React-Islands — größere Migration, eigentliche SEO-First-Lösung.
Beide bleiben als Optionen dokumentiert, sind aber kein MVP-Pfad.
Wie anyplot (anyplot/api/routers/debug.py:require_admin):
- Cloudflare Access verifiziert Google-Identity am Edge.
- Cloudflare leitet Request mit
Cf-Access-Jwt-Assertion-Header an Cloud Run weiter. - FastAPI verifiziert das JWT (Issuer, Audience, Email-Allowlist).
- Bei Erfolg: Endpoint freigegeben.
Vorteile:
- Auth-Komplexität an die Edge ausgelagert.
- Keine Cookies, kein Session-Management im Backend.
- Google-Login Out-of-the-Box.
api/auth.py:require_admin akzeptiert neben dem CF-Access-JWT einen
X-Admin-Token-Header als Shared-Secret-Fallback (lokale Entwicklung /
CI / Break-Glass): ADMIN_TOKEN im API-Env, das passende
VITE_ADMIN_TOKEN im SPA-Env. Ist keiner der beiden Pfade konfiguriert,
beantwortet das Gate jeden geschützten Request mit 503 — ein
fehlkonfiguriertes Prod-Deploy schlägt geschlossen fehl statt offen.
Gegen die deployte API: nur über api.kurrentschrift.ink. Die
Apex-Route kurrentschrift.ink/api/* liegt hinter Cloudflare Access und
antwortet schon an der Edge mit 302 auf den Login — der X-Admin-Token
erreicht Cloud Run dort nie. Das ist das Spiegelbild der Regel für
öffentliche Reads (CONFIG.publicApiBase): die offene Subdomain ist der
einzige Weg, auf dem ein selbst gesetzter Header ankommt. Verifiziert am
2026-08-01 — GET /sources/<id>/work-items mit X-Admin-Token gegen
https://api.kurrentschrift.ink antwortet auf allen vier Sources mit 200.
Dritte Umgebung: die claude.ai/code-Cloud-Session. Dort gibt es keine
.env — die Datei ist gitignoriert und liegt nie im Checkout —, und das
Cloud-SQL-Egress-Gate blockiert eine lokal gestartete API. Die deployte
API ist deshalb der einzige Admin-Pfad; ADMIN_TOKEN, VITE_ADMIN_TOKEN
und API_BASE_URL (= die api-Subdomain) sind dort als Umgebungsvariablen
konfiguriert. Vorhandensein prüft man am Exit-Code, nicht am Wert:
printenv ADMIN_TOKEN >/dev/null && echo set — den Token selbst nie
ausgeben.
Fallstrick Zeilenumbruch. Cloud Run injiziert Secret-Manager-Werte
byteweise als Env-Var. Eine mit echo angelegte Version trägt ein
abschließendes \n, das ein HTTP-Header nicht transportieren kann —
secrets.compare_digest lehnt dann jeden Tokenwert mit 401 ab. Genau
das war von der Anlage des Secrets (2026-05-27) bis 2026-08-01 der Fall:
der Break-Glass-Pfad war unbenutzbar, ohne dass es auffiel, weil der
Browser-Admin über den JWT-Zweig läuft. Diagnose ist die Byte-Differenz,
nicht der Wert:
gcloud secrets versions access latest --secret=ADMIN_TOKEN --project=kurrentschrift | wc -cgegen die Länge desselben Werts in $(…) — die Kommando-Substitution
schluckt den Umbruch, ein naiver Fingerprint-Vergleich meldet also
fälschlich „identisch", während Prod weiter 401 sagt. Neue Versionen
darum immer mit printf '%s' anlegen; core/config.py strippt seit
PR #262 zusätzlich alle vier Secret-gestützten Settings und mappt
Whitespace-only auf None, damit das Gate weiter fail-closed bleibt.
Cloud Run löst latest beim Instanz-Start auf — eine neue
Secret-Version wirkt also erst mit dem nächsten Kaltstart oder Deploy,
und häufiges Polling hält die Instanz warm und verhindert genau das.
Wenn wir Cloudflare gar nicht im Stack haben wollen, ist GCP IAP die äquivalente Lösung — IAP-Header werden statt CF-Access-Header gelesen. Funktionsweise identisch.
- Admin-Routen sind im Router-Tree als geschützt markiert.
- Auf 401/403 zeigt die App eine „Sign-in"-Seite mit Redirect zum Auth-Provider.
- Existierende
DebugPage.tsx-Logik in anyplot als Vorlage.
cd app && npm install && npm run build→ statischesdist/mit JS-Chunks + Assets.- Geplant (P1, noch nicht in
vite.config.ts):- Manual-Chunks wie anyplot —
mui-iconsseparat (large, oft gecached),mui(MUI + Emotion),vendor(React + Router). - Gzip + Brotli pre-compressed via
vite-plugin-compression2.
- Manual-Chunks wie anyplot —
Zwei Services, live seit 2026-05:
kurrentschrift-api— FastAPI (api/Dockerfile);api/cloudbuild.yamlfährt vor dem Rollout einen Alembic-Migrate-Job (kurrentschrift-migrate).kurrentschrift-app— statisches Vite-Build hinter nginx-unprivileged (app/Dockerfile+app/cloudbuild.yaml).- CI/CD: Cloud Build, je ein Trigger pro Service (deploy-api /
deploy-app), deployt aus
main. - Region: europe-west4 — niedrige Latenz für deutschsprachige Hauptzielgruppe.
- Min instances (Stand 2026-08-30): API 1, App 0. Die frühere
Annahme „Cold-Start akzeptabel für eine Lern-Webseite" beruhte auf einem
geschätzten ~3-Sekunden-Start; gemessen sind es bei der API p50 9 447 ms /
p95 12 245 ms, und 60 % aller Stunden sehen gar keine Anfrage, der Dienst
ist also meist kalt. Rund 279 der 344 Starts in 30 Tagen waren nutzerseitig.
98 % der Zeit gehen für Containerstart und Python-Import drauf, die Datenbank
kostet 0,13 s. Die App bleibt bei 0 — sie startet in 170 ms und wäre eine
warme Instanz nicht wert. Gegenfinanziert durch
anyplot-app, das mit 99,56 % Leerlauf dauerwarm stand (anyplot#10812); netto ~0 €. Die Kostenrechnung dahinter: eine Mindestinstanz mit 1 vCPU kostet rund 8,50 €/Monat, weil Leerlauf-CPU zu ~10 % des Aktivsatzes abgerechnet wird, Leerlauf-Speicher aber zum vollen Satz. - Max instances: API 3, App 3. Nicht wegen Durchsatz — in 30 Tagen
liefen ganze 3 Anfragen auf HTTP 429 —, sondern weil
min=1zusammen mitmax=1ein Deployment zwingt, die einzige Instanz zu ersetzen, statt die neue daneben warmlaufen zu lassen. Daher kamen die ~73 Deploy-Kaltstarts. - Memory: API 512 Mi (gemessen 15 % Mittel / 25 % p99 von 1 GiB, also ~254 MiB Spitze — 512 Mi lässt doppelte Luft über p99; bei einer Dauerinstanz ist die Speicherstufe reine Standmiete), App 512 Mi. Sollte WeasyPrint oder später TrOCR mehr brauchen, wird hier wieder erhöht.
- Datenbank: Alle Daten liegen in Postgres — DB
kurrentschriftauf der Cloud-SQL-Instanz von anyplot (Zugang über.env). Die lokale Entwicklung schreibt DIESELBE Cloud-SQL-DB; eine separate lokale DB gibt es nicht. Jeder Schreibvorgang aus einem Dev-Lauf trifft also die geteilten Echtdaten.
Crawler und KI-Agenten führen kein JavaScript aus; die SPA gäbe ihnen
auf jeder URL die leere Hülle mit dem Startseiten-Titel. Seit 2026-08-28
bekommen sie stattdessen je Route eine vorgerenderte HTML-Seite —
nach dem Muster von anyplot, mit derselben Crawler-Liste (Entscheid
des Autors: „identisch halten"). Der Markdown-Spiegel der Schriftkunde
(2026-08-27, /schriftkunde.md) war der Vorläufer für eine Seite und
ist in diesem Pfad aufgegangen.
- Erkennung in
app/nginx.conf: diemap $http_user_agent $is_botist WORTGLEICH mit~/projects/anyplot/app/nginx.conf(Suchmaschinen, KI-Crawler, nutzergesteuerte Fetcher, Social-/Messenger-Vorschauen); eine Änderung wird in beiden Dateien im selben Zug gemacht. Ein gemappter UA landet übererror_page 418 = @seo_proxybeim API-Host (https://api.kurrentschrift.ink/seo-proxy$request_uri, TLS-Prüftiefe 4 — anyplots Vier-Wochen-502 wiederholt sich hier nicht), Menschen bekommenindex.html.robots.txt,llms.txt,sitemap.xmlund alle statischen Dateien (og.png, Favicon …) werden auch für Bots DIREKT bedient (location =bzw. die Regex-Location auf Dateiendungen) — sonst ginge dasog:imageeiner Link-Vorschau an den Proxy. Trailing Slashes werden relativ auf die kanonische Form umgeleitet (absolute_redirect off). - Inhalt:
app/src/lib/seo/prerender.tsrendert aus dem Locale-Katalog je öffentliche Route ein vollständiges Dokument — Head (Title/Description ausseo.ts, Canonical, OG/Twitter, JSON-LD:WebSiteauf der Startseite,BreadcrumbListdarunter), Body in der DOM-Reihenfolge der Seite (eine Regel je View-Komponente; Schriftkunde komplett, Landing samt Schriftstatus, Hubs, Impressum; die Werkzeuge Quiz/Tafel/Übungsblatt/Federprobe als beschriebene Auswahl mit dem Hinweis, dass das Werkzeug selbst im Browser läuft), Site-Nav auf jeder Seite, Footer mit Stand (aus dem Sitemap-lastmodder Route — deterministisch stattnew Date()) und dem Rechtehinweis in-band (offene Politikai-train=yes+ Vorbehalt der Schriftdaten). Dazu die 404-Seite mitnoindex. Erste Zeile jeder Datei ist der Marker<!-- kurrentschrift.ink prerender -->, an dem der Bot-Serving-Check eine vorgerenderte Seite von der Hülle unterscheidet. - Erzeugung:
npm run prerender(läuft alsprebuildvor jedemvite build;--experimental-strip-types, damit auch Node 22.15 den.ts-Renderer laden kann) schreibtapp/prerender/*.html— die Dateien sind EINGECHECKT, denn das API-Image liefert sie aus (api/Dockerfilekopiertapp/prerender/,api/routers/seo.pybedient/seo-proxy/{route}als reine Datei-Suche: keine DB, kein Template, nichts, das ein Crawler teuer machen kann; Unbekanntes bekommt die 404-Seite mit Status 404 — die Hülle antwortete 200). - Wächter:
prerender.test.ts— jede öffentliche Route hat eine Seite; die Inhaltsseiten (Landing, Schriftkunde, Hubs, Impressum) spiegeln jedes Locale-Blatt oder benennen es im SKIP; die eingecheckten Dateien sind byte-gleich mit einem frischen Render und nichts anderes liegt im Verzeichnis; Head, Marker, Nav und Rechtehinweis auf jeder Seite.tests/test_api_seo_proxy.pypinnt die API-Seite (Route → Datei, 404, keine Pfadtricks). Und weil der Pfad für Menschen unsichtbar ist:.github/workflows/bot-serving-check.ymlruft täglich den Cloud-Run-Origin mit Crawler-UAs an (Prerender je Route, Bypass der Maschinendateien,og.png, Trailing Slash, 404, SPA-Kontrolle) — anyplots Alarm, der dort vier stille Wochen beendet hat. - Bewusst NICHT in
sitemap.xmlund ohne eigene URL: die Prerender- Seite IST die Route (gleiche URL, gleicher Canonical) — Google billigt das ausdrücklich, solange der Inhalt dem entspricht, was Menschen sehen. - Für Maschinen lesbar, nicht nur für Crawler erreichbar (seit
2026-08-28, nach dem Befund eines Assistenten, der die Seiten
abgerufen hatte): Die drei Schriften tragen ihre Kennwerte als
Daten — im Locale ein typisiertes
dataje Variante (slantDeg,lineature,pen,stroke, bei BedarfpenAngleDeg,lineatureAlt), im Prerender der Schriftkunde zweimal ausgegeben: als JSON-LD (ItemListausDefinedTerms mitPropertyValues) im Head UND als sichtbarer<pre><code class="language-json">-Block im Body, weil die HTML→Markdown-Konverter, mit denen Assistenten Seiten abrufen,<script>verwerfen und<pre>behalten. Jeder Winkelwert in der Prosa nennt seine Bezugsgröße selbst („75–80° zur Grundlinie (90° = senkrecht)", „Federkante 15–20° zur Schreiblinie — nicht die Schräglage"), damit ein Chunk allein nicht Schräglage und Federwinkel zusammenwirft;prerender.test.tshält Zahlen und Prosa zusammen. Die Buchstaben selbst sind über Rezepte abrufbar (Tafel- Seite „Buchstaben für Maschinen" +llms.txt): Inventar (/templates), Vorlage als PNG (/bboxes/{glyph_key}/crop, gemeinfrei), geschriebene Form als SVG (/write/glyphs/{glyph_key}.svg— neu,write-api.md), Geometrie und ganzes Wort als JSON, das ganze Wort auch als Bild (/write/word.svg?text=).
Wer die vorgerenderten Seiten liest, sieht die Besucher-Statistik nie —
kein JavaScript, kein Plausible-Skript. Der Prerender-Pfad ist der eine
Ort, an dem diese Abrufe sichtbar werden, und dort werden sie gezählt:
serverseitig, auf der zweiten Plausible-Site bots.kurrentschrift.ink
(nach anyplots Vorbild, Glossar „Bot-Site"). Die Middleware
record_bot_fetch (api/main.py) meldet jeden /seo-proxy-Abruf an
api/analytics.py, das den User-Agent gegen die mit anyplot wortgleiche
Taxonomie AI_AGENTS hält und ein Event bot_fetch mit assistant,
kind, path und status an Plausibles Events-API schickt —
Fire-and-forget, nie im Antwortpfad. Eine Middleware statt einer
Router-Dependency, weil nur sie den STATUS sieht: Eine 404 wird als 404
aufgezeichnet, nicht als Seitenaufruf.
Drei Dinge lassen die Events schweigend verschwinden — alle drei am 2026-08-28 live nachgestellt:
- Ein Bot-User-Agent. Plausible verwirft jedes Event, dessen UA es
als Bot erkennt — jeden UA auf diesem Pfad. Darum laufen die Events
unter
kurrentschrift-server/1.0; die Identität steckt in den Props. - Eine Hosting-IP als Besucher. Probe-Events mit
X-Forwarded-Foraus Google-Cloud-Bereichen (34.90.1.1,35.204.1.1) kamen nie an, dieselben Events mit einer Heim- oder GitHub-IP sofort. Auf dem Crawler-Pfad (Cloud-Run-App → Cloudflare → API) istcf-connecting-ipaber genau die Google-Egress-IP des App-Containers — von zwanzig Crawler-Abrufen zählte einer. Darum reicht nginx den Crawler inX-Forwarded-Fordurch (@seo_proxy,$proxy_add_x_forwarded_for), undapi/request_context.py::visitor_ipnimmt die ERSTE gültige weitergeleitete Adresse VORcf-connecting-ip(anders als anyplot; für direkte Clients hinter Cloudflare sind beide dieselbe Adresse). - Der Edge-Cache. Cloudflare cacht die Antworten des API-Hosts per
Regel;
/seo-proxyantwortetes-maxage=86400und liefertecf-cache-status: HIT— ein gecachter Abruf erreicht die zählende Middleware nie./seo-proxyantwortet deshalbprivate, no-store; der Crawler bezahlt den API-Roundtrip für eine 8-KB-Datei, das ist der Preis der Zählung.
Aktiv ist die Meldung nur in Produktion (ENVIRONMENT=production, wie
Cloud Run es setzt); BOT_ANALYTICS=true|false überschreibt — ein
Dev-Lauf schreibt nie auf die Live-Bot-Site. Auf der Bot-Site liegen
außerdem ein paar Events mit assistant=probe vom 2026-08-28 — die
Nachstellung oben; im Dashboard herausfiltern, nicht wundern.
Zweites Event asset_fetch (seit 2026-08-28): Was Assistenten
über die API konkret ABRUFEN — einen Buchstaben als Bild
(/write/glyphs/{key}.svg) oder JSON, ein Wort als Bild
(/write/word.svg?text=) oder JSON, den gemeinfreien Tafel-Ausschnitt
(/bboxes/{key}/crop) — meldet dieselbe Middleware mit asset
(glyph_svg · glyph_json · word_svg · word_json · crop),
source (Quellen-Id) und key (glyph_key bzw. der angefragte Text,
auf 80 Zeichen gekappt) neben assistant, kind, status
(api/analytics.py::classify_asset, track_asset_fetch). Nur die
Einzel-Routen zählen; Batch-Read und Inventar sind Sache der SPA — und
ein Browser-UA kommt ohnehin nie bis hierher, sodass die Abrufe der
eigenen Besucher (Federprobe, Quiz-Crops) die Bot-Zahlen nicht
verfälschen. Damit lässt sich im Dashboard lesen, welche Buchstaben und
Wörter Assistenten wie oft zeigen wollten — mit einer Einschränkung: Die
JSON-Reads und der Crop bleiben am Edge gecacht (die Tafel, das
Hero-Wort und das Quiz hängen daran), ein Edge-HIT erreicht die
Middleware nicht, ihre Zahl sind also die Cache-MISSES (erster Abruf je
Asset und Edge-TTL). Die SVG-Reads, die nur Assistenten anfragen,
antworten private, max-age=300 (Browser-Cache, kein Edge) und zählen
jeden Abruf (api/http.py BROWSER_ONLY_CACHE, Befund 2026-08-28:
drei von vier Assistenten-Abrufen waren Edge-HITs und fehlten).
Auf der Plausible-Seite braucht die Site bots.kurrentschrift.ink die
Ziele bot_fetch und asset_fetch (Custom Events) und die
registrierten Properties assistant, kind, path, status sowie
asset, source, key — ohne Registrierung kommen die Events an,
lassen sich aber nicht aufschlüsseln. Die Site hat KEIN
Tracking-Skript und zeigt darum „Setup pending" — erwartet, kein
Fehler. kind ist die Eigenschaft, nach der man filtert:
user_directed ist ein Leser, alles andere ein Korpus-Bau. Erst nach
status filtern, dann lesen.
- Alle
@font-face-Regeln stehen früh inapp/index.html; die Dateien liegen selbst gehostet unterapp/public/fonts/(16 wörtliche woff2-Kopien aus@fontsource/{eb-garamond,playfair-display}v5.3.0, Subsets latin + latin-ext, plus die beiden Show-Fonts GLKurrent/ Suetterlin-TTF). Die@fontsource-Pakete sind devDependencies — Bezugsquelle und Update-Kanal, kein Laufzeitpfad;npm run fonts:synckopiert nach einemnpm updateneu und prüft Byte-Identität (Lizenzbedingung: verbatim, nie re-subsetten —app/THIRD_PARTY_NOTICES.md). - Zwei Above-the-fold-Schnitte sind per
<link rel="preload" as="font">vorgeladen (Playfair 600 + Garamond 400, latin) — das einzige layoutunabhängige Startsignal, weil#rootbis zum Entry-Chunk leer ist;crossoriginist auch same-origin Pflicht. Die Zahl ist gemessen, nicht gesetzt: im Fast-3G-A/B kostete jeder weitere Preload den Entry-Chunk mehr, als er brachte. - Die
/fonts/-URLs sind UNGEHASHT: nginx cached sie 30 Tage (nichtimmutable); wird je eine Datei wirklich getauscht, muss der DATEINAME mitversioniert werden undindex.htmlmitziehen. Die gehashten/assets/-Bundles cachenimmutable/1 Jahr (app/nginx.conf).
/api/*→ der Cloudflare-Worker vor dem App-Service leitet aufapi.kurrentschrift.ink(FastAPI) um; nginx im App-Container kennt kein/api(siehe Kopfkommentarapp/nginx.conf)./admin/*→ React-SPA (Auth-Gate am Edge via Cloudflare Access, §5).- alles andere → React-SPA mit Fallback
index.html(nginx).
Struktur seit dem Restructure (2026-06): routes/ (Pfad-Konstanten +
lazy Public/Admin-Sections) · pages/ (dünne Route-Mounts) · sections/
(Feature-Views mit Logik) · components/ (wiederverwendbar) ·
layouts/admin/ · theme/ (Farbwahrheit in styles/paper.ts) ·
lib/api/ (Fetch-Client mit Cold-Start-Retry + typisiertem ApiError,
Wire-Typen handsynchron zu api/schemas.py) · domain/glyphs.ts
(Alphabet-Registry + Lock/Split-Helfer) · context/AdminContext.tsx ·
locales/de/ (alle deutschen UI-Strings als Pre-i18n-Namespaces) ·
hooks/.
routes/index.tsx— Router-Assembly (Suspense-Fallback, errorElement);routes/paths.tsist die einzige Quelle der URLs.sections/landing/—LandingView+HeroWritten(das Markenwort engine-first vonWrittenWordgeschrieben, seit 2026-08-27; der GLKurrent-Clip-Path-Wisch ist nur noch der Fallback bei echtem Fehler) +Reveal(Scroll-Reveal; ohne IntersectionObserver und im Druck sofort sichtbar).sections/schriftkunde/— der/schriftkunde-Überblick (Grundbegriffe, drei Ausgangsschriften mit Specimen, drei Federn, Tinte & Papier, Buchstaben-Besonderheiten, Zahlen & Zeichen, Chronologie). Die drei Ausgangsschriften stehen bewusst in DREI verschiedenen Specimen-Techniken da: Kurrent in der GLKurrent-Schauschrift-Font, Sütterlin LIVE von der Engine geschrieben, Offenbacher als PD-Specimen unter Nennung seiner Quelle. Seit 2026-08-29 trägt jeder Abschnitt eine stabile Sprungmarke (sections.ts:#grundbegriffe,#buchstaben,#entziffern…, die drei Schrift-Karten#kurrent/#suetterlin/#offenbacher— dieselben Ziele, auf die das Kennwerte-JSON-LD des Prerenders zeigt) und unter dem Seitenkopf steht die Sprungliste „Auf dieser Seite“; die Buchstaben-Besonderheiten schreiben die Buchstaben, von denen die Zeile spricht (ſ · s · f, u · n, e · n · ä, ſ · z · ß), alsWrittenGlyph-Streifen live daneben — markiertes Specimen auf eigener Fläche mit Antiqua-Beschriftung (design-system.md §9), nachgeladen erst in Sichtweite, ausgeblendet statt Fehlerkasten, wenn die Engine nicht erreichbar ist. Der Prerender setzt dieselben Ids auf seine<h2>, dieselbe Liste als<nav>und nennt die Schriftproben je Zeile nur beim Antiqua-Namen.sections/vergleichen/—VergleichenView, die Lesart-Seite (/lesen/vergleichen, Website-Audit 2026-08-29, 4/8): die getippte Vermutung alsWrittenWord, darunter die Lesarten auslib/lesarten.ts(je Karte genau EIN Buchstabe gegen seinen dokumentierten Verwechsler getauscht — n/u, e/n, n/m, i/j, t/l, f/h, ſ/f für ein nicht-finales s, Umlaut ↔ Grundbuchstabe, die Versalien-Cluster L/K/R, N/M, B/V —, höchstens acht, Klick übernimmt die Lesart), darunter die klassischen Verwechsler-Paare alsSpecimenStripmit dem unterscheidenden Merkmal. Kein HTR: die Person liest, die Engine liefert die Kandidaten (Vision Ziel 5, didaktische Hälfte).components/SpecimenStrip/— Buchstaben „wie geschrieben" als markiertes Specimen auf eigener Fläche (design-system.md §9), Antiqua- Beschriftung, Klick schreibt neu; die Seite holt die Payloads aller Streifen in EINEM Batch (useSpecimenPayloads), jeder Streifen montiert seine Zellen erst in Sichtweite und zieht sich zurück, wenn nichts schreibbar ist. Genutzt von der Schriftkunde (Buchstaben-Besonderheiten) und der Lesart-Seite.sections/hub/—HubView(die/lesen- und/schreiben-Bereichs-Hubs).sections/worksheet/—WorksheetView+ConfigPanel+PreviewSvg(Lineatur-Konfigurator,/schreiben/uebungsblatt) +useWorksheetText(Browser-Hälfte des Übungstexts: eine Komposition je Zeile über den geteilten Render-Cache, entprellt, nach Text gemerkt; das Platzieren auf die Zeilen ist die reinelib/uebungstext.ts).sections/scribe/— der/federprobe-Live-Schreiber (Text → serverseitig komponiertes Wort,WrittenWord).sections/tafel/— die/tafel-Schreibtafel (Vorlage-Zeilen „wie geschrieben") +useLesetafelPdf(Browser-Hälfte der druckbaren Lesetafel: Render-Payloads im Batch, Originaltafeln per Canvas → JPEG, Download).lib/pdf.tsist seit 2026-08-29 ein kleiner Dokument-Builder (PdfDocument+ContentStream: Linien, gefüllte Ringe even-odd, Helvetica-Text, JPEG-XObjects; Latin-1-Body, damit die xref-Offsets Stringlängen bleiben), auf demlineaturePdf(Übungsblatt) undlib/lesetafel.ts(Lesetafel: Zeilen-Reflow mit proportionalen Breiten wieWrittenSheet, Lineatur je Zeile, Seitenumbruch) sitzen — clientseitig, weil alle Blätter reine Vektor-/Bild-Inhalte sind — seit 2026-08-30 auch das inhaltsbewusste Übungsblatt (lib/uebungstext.ts,ContentStream.polylinefür die Übergänge); der WeasyPrint-Pfad (architektur.md §15) ist damit für das Einzelblatt nicht mehr nötig.sections/quiz/—QuizView+useQuizEngine(gesamte Quiz-Logik ohne JSX) + Setup/Play/Results-Panels +QuestionVisual+lesefallen.ts(die Regel-Erklärung nach einem Fehlgriff: gezeigte Form gegen geratenen Buchstaben, Katalog ausorthographie-regeln.md; Sätze inlocales/de/quiz.tsunterplay.rules).sections/impressum/— Impressum/Datenschutz/Quellen als Dokumentspalte.sections/admin/chart/—ChartView(Pointer-Routing) +useChartViewport(Zoom/Pan/Pinch) +useBboxEditing(Bbox-Commits, Lock) +BboxOverlay/ChartToolbar+ purebboxGeometry.sections/admin/setup-wizard/—SetupWizard(Dialog-Shell) +useWizard(State + Server-Mutationen) +useCropView(Crop-Viewport) +WizardCanvassteps/{Mask,Lineatur,Slant,Trace,Overview}Step. Einzige Autoren-Fläche.
sections/admin/diagnostics/—DiagnosticDialog(3-Spalten + M4-Fit),DiagnosticView/FitView.sections/admin/shell/— die Werkbank-Hülle, die alle drei Ansichten teilen:AdminHeader(drei Bereiche + Vorlagen-Chip + Korb-Badge),StartView(/admin, die Vorlagen-Auswahl),LetterPicker(Buchstaben-Grid ausdomain/glyphs.ts— als Popover statt als Dauer-Sidebar),WorkbenchData(die EINE geteilte Datenschicht: Vorkommen je Quelle + die admin-gesicherten Statistik-Schichten je Hand, über dem Outlet montiert),KorbContext(⚑ von überall, Korb als Drawer) +KorbPanel/MarkDialog,LensStats(H1/H2-Blöcke),AggregateSketch(die ausLensStatsherausgelöste H1-Aggregat-Zeichnung hinter einemheight-Prop — die Miniatur im Raster ist damit buchstäblich DIESELBE Zeichnung wie die in der Linse) über der purensketchGeometry.ts(isPoint·boundsOf·pathOf·letterSketchAnchors·occurrenceChainsOf·SKETCH_FRAME),OccurrenceThumb,Panel/ViewHeader(die geteilten Layout-Bausteine) und die puren, getestetenfocus.ts(Subjekt ⇄ URL) +model.ts.- Registrierungs-Regel für jede „gemessen gegen komponiert"-Zeichnung der
Werkbank: SOWOHL die gespeicherte Spur ALS AUCH die Engine-Tinte reiten
auf der eigenen gemessenen Registrierung der Zeile
(
measurements.registration_px+xh_px), und zwar über die geteilten, unit-getestetenshell/model.ts::traceFrameOf/traceMatrix— Spur und Komposition liegen im identischen Rahmen (Grundlinie = 0, 1 Einheit = x-Höhe), es wird also nichts nach Augenmaß ausgerichtet. Die Komposition stattdessen an die LINKE CROP-KANTE zu heften setzte sie über die 63 Sütterlin-Wortzeilen im Median 8,9 px (~0,3 xh) links neben die Tinte und ließ damit jede Komposition schlechter aussehen, als sie ist (gemessene Registrierung: Median 1,1 px; was an der rechten Kante bleibt, ist der echte Breitenunterschied). Die Links-Kanten-Heftung überlebt NUR dort, wo es keine nachgefahrene Zeile gibt. - Zuschnitt-Regel von
OccurrenceThumb: Das gespeicherte Vorkommens-Kästchen stammt aus dem M4-Fit und umschließt die CENTERLINE — die Tinte läuft also darüber hinaus. Die Luft um den Ausschnitt ist deshalb proportional,max(7, 0.18·√(w·h))Crop-Pixel (der Anteil wird auf dem GEOMETRISCHEN MITTEL genommen, nicht auf der langen Seite, weil die gespeicherten Kästchen in beiden Richtungen extrem ausfallen), undTHUMB_Hist 80 statt 64. Ein fester Rand von 4 px schnitt in den Buchstaben hinein. sections/admin/letters/—LetterView(/admin/buchstaben): Übersicht übercompare/GlyphComparison— jeder autorierte Buchstabe als Kachel mit VIER Flächen: Original (der Chart-Crop) · Tafel-Form (Variante 0, „wie geschrieben") · Laufform (Variante 100) · „Median & Vorkommen" (die H1-Aggregat-Skizze: Anker-Median, die Vorkommensketten dünn dahinter, MAD-Kreise, die aktuell gerenderte Laufform gestrichelt). Jede Fläche trägt einen EHRLICHEN Leerzustand statt einer stillen Lücke („noch keine Laufform"; bei der Skizze ein Hinweis, der lädt / keine Hand / kein Admin-Read / wirklich kein Aggregat unterscheidet). Die Flächen sindflex: 1 1 150px, brechen auf dem Telefon also zu 2×2; der Überlagerungs-Modus klappt weiterhin die ersten beiden zur Rot-Silhouetten-Überlagerung zusammen. Jede Kachel zeigt ihre Kennzahlen — Vorkommenszahl, mittleres Fit-Residuum über die gespeicherten Vorkommen, den gespeicherten Bildraum-Score und dessen Abzüge je Kategorie — plus einen Sortier-Umschalter (Alphabet · Schlechteste zuerst), der das Raster zur Arbeitsliste macht und jede Kachel in ihren Buchstaben öffnet. Das Kostenbudget dieser Übersicht ist eine stehende Auflage, kein Zufall: die Render-Payloads für das GANZE Alphabet kommen aus ZWEI Batch-Requests (Variante 0 und Variante 100 über/write/glyphs), die Statistik aus der geteilten Werkbank-Datenschicht (gar kein Request), die Scores aus dem EINEN admin-gesicherten Batch-Read der Qualität — und das teure/diagnosticje Glyph, das das Raster früher einmal pro Karte feuerte, wird NUR noch für den Überlagerungs-Modus geholt, der seine Umriss-Geometrie braucht. Im Detail Tafel-Ausschnitt, Tafel-Form neben Laufform, Vorkommen, H1-Statistik, Absprünge — plusLaufformApplyDialog, die EINE rendernde Aktion des Admins (POST …/aggregates/apply-laufform): Warnung, Vorschau je Buchstabe (Vorkommen · Abstand · „neu"), Bestätigung, danach der Bericht.sections/admin/joins/—JoinView(/admin/uebergaenge): Matrix + Freitext-Kombination, komponierte Verbindung, H2-Statistik, Vorkommen, Paar-Editor.sections/admin/words/—WordView(/admin/woerter): Freitext-Wort, „woraus es besteht", Belege je Specimen überWordSpineCard. Diese Karte ist wie eine Buchstaben-Kachel aus ZWEI Flächen gebaut: links die MESSUNG (Platten-Crop + die gespeicherte Spur in Grün + je gefittetem Buchstaben ein gestricheltes Kästchen und je Übergang ein Punkt, alles anklickbar — der Weg in die beiden anderen Ansichten; die Engine-Tinte legt sich durchscheinend dazu, wenn der „Überlagern"-Schalter an ist), rechts die EIGENE Antwort der Engine für sich allein. Beide werden im selben px-pro-Einheit-Maßstab auf derselben Grundlinien-Zeile gezeichnet, damit Breite, Schräglage und Rhythmus ohne gedankliches Umskalieren vergleichbar sind. Je Karte „Bewerten" (der Admin-/score) und „Nachfahren" (der Wort-Editor).sections/admin/eigenhand/—EigenhandView(/admin/eigenhand): der Bestand einer HAND (nicht einer Vorlage) und der Bogendruck. Die Zahlen kommen fertig ausGET /eigenhand/bestand/{hand}— dieselbe Rechenschicht, die das Terminal druckt —, das PDF wird geholt statt verlinkt, weil das Admin-Token in der Entwicklung ein HEADER ist, den ein<a href>nicht mitschickt. DanebenSetupPanel(das stehende Setup der Hand) undStripsPanel(die geschriebenen Streifen): dessen Bilder werden aus demselben Grund als Blob geholt und zusätzlich erst auf Klick, weil ein Streifen ~350 KB wiegt und zum reservierten Datensatz gehört; die Object-URLs werden von Hand wieder freigegeben.sections/admin/chart/,setup-wizard/,diagnostics/,compare/,pairs/,belege/,quality/bleiben die WERKZEUGE, die diese drei Ansichten einsetzen (Chart-Editor, Wizard, Diagnose, Vergleichsraster, Paar-Editor, Wort-Editor + die pureregistration.ts, Score-Darstellung) — sie haben seit dem Redesign keine eigene Route mehr.quality/scoreParts.tsxhältscoreColor, den Score-Chip und die Aufschlüsselung je Kategorie; das liegt AUSSERHALB des Wizards, damit die Wizard-Vorschau, das Diagnose-Modal (das die Aufschlüsselung dadurch bekam, die es nie zeigte, obwohl sein Payload sie immer trug) und die Buchstaben-Übersicht dieselbe Zahl auf dieselbe Weise lesen;setup-wizard/steps/previewParts.tsxbehält nur noch die Silhouetten-Überlagerung. Der Tooltip des Chips sagt ausdrücklich, dass die gespeicherte Zahl der Score ZUM ZEITPUNKT DES AUTORIERENS ist und keine Neubewertung mit der heutigen Metrik.components/—PaperBackground(Papier-Atmosphäre),PublicHeader(3-Bereiche-Nav),PublicFooter,PageContainer(eine Inhaltsspalte, drei Breiten 760/1152/1280),Prose(Lesemaß ~66 Zeichen),PageHeader(einheitlicher Seitenkopf: Bereichs-Eyebrow + Playfair-Titel + Intro),CategoryHeading(Abschnittstitel mit Viridian-Kurrent-Initiale),InfoHint(Kurrent-„i"-Popover, die eine Info-Affordanz app-weit),inkReveal/(geteilte „wie geschrieben"-Primitiven: Silhouette, maskiert von einer gesweepten Centerline + Ink-Bleed/Settle),WrittenGlyph(ein Glyph als Duktus-Animation),WrittenWord(ganzes Wort/Zeile, serverseitig komponiert viaGET /write/word),BootStatus(Boot/Fehler-Screens). Vollinventar mit Kern-APIs:design-system.md§7.
Bereits gebaut (siehe oben): Landing (sections/landing/),
Schriftkunde (sections/schriftkunde/), die Bereichs-Hubs
(sections/hub/), Lineatur-Konfigurator (sections/worksheet/,
/schreiben/uebungsblatt), Federprobe (sections/scribe/), Schreibtafel
(sections/tafel/), Lese-Quiz (sections/quiz/). Neue Features
kommen als je eine
sections/<feature>/-View + dünner pages/-Mount + Eintrag in
routes/paths.ts:
sections/learn/— Einstieg (P1+).sections/animation/— Animierte Tafel (P1+).sections/render/— Text → Kurrent (P2).sections/htr/— Upload + Job-Polling (P1) und Lese-Lupe (P1+).sections/style-analysis/— Stil-Analyse-Upload (P3).sections/hand-compare/— Heatmaps Side-by-Side (P4).sections/open-data/— Daten-Export-Seite (P5).components/GlyphAnimation— abgespeckte MVP-Animation (heute schon alsWrittenGlyphim Quiz).components/KurrentRenderer— Text → SVG-Render.components/HeatmapView— D3.js-Heatmap-Komponente.components/IiifViewer— Annotorious + OpenSeadragon wrapper.
- Keine eigene Komponenten-Bibliothek. MUI 9 deckt unsere
UI-Bedürfnisse. Custom-Komponenten nur dort, wo es unvermeidbar ist
(Animation, Lineatur, Heatmap). Die verbindliche Bauvorschrift
(Tokens, Typo-Leiter, Flächen) ist
design-system.md— das ist ein Regelwerk ÜBER MUI, keine eigene Bibliothek. - Kein State-Management-Framework (Redux/Zustand/Recoil). React-Context
- lokaler Component-State reichen für unsere Use-Cases.
- Keine GraphQL-Schicht. REST über FastAPI ist genug.
- Kein Service Worker / PWA-Modus im MVP. Kann später als progressive Erweiterung kommen.
- React 19 Release Notes
- Vite Docs
- MUI v9
- React Router 7
- react-helmet-async
- react-i18next
- Astro vs Next.js 2026 — alexbobes.com (Vergleich, nicht verwendet)
- Cloudflare Access
- GCP Identity-Aware Proxy
- Cloud Run
- anyplot.ai-Repo (interner Maintainer; bewusst gleicher Stack)