Status (2026-09-04): lebend. Ist-Stand von Stack, Routen, i18n-Soll, Deploy, Admin-Gate und Crawler-Prerender.
Was gilt. React 19 + Vite + MUI + React Router, EINE SPA für Endnutzer und Admin (§1). Öffentlich sind drei Bereiche plus Landing, der Admin ist die Werkbank in drei Ansichten (§2 — die Routenkarte ist
app/src/routes/paths.ts, nicht diese Liste). Das Admin-Gate ist Cloudflare Access in Prod undADMIN_TOKEN/X-Admin-Tokenlokal; davor liegt seit 2026-09-02 das Origin-Geheimnis, das der Apex-Worker selbst stempelt, weil ein Worker-Subrequest die Transform-Rules der eigenen Zone umgeht (§5,infra/cloudflare/). Gebaut und deployt wird auf Cloud Run: Min-Instanzen API 1 · App 0, Max je 3 — die eine warme API-Instanz seit 2026-08-30, weil ein Kaltstart gemessen 9,4 s p50 kostet, undmax=3, damit ein Deploy die warme Instanz nicht ersetzen muss (§6). Crawler bekommen vorgerenderte Seiten über den$is_bot-Pfad der nginx-Config. Was bewusst NICHT gemacht wird, steht in §8.Was offen ist. i18n ist als Soll beschrieben (§3) — die Website ist v1 deutsch, Englisch folgt; die Komponenten-Map (§7) ist eine Beschreibung des Codes und veraltet zuerst, wenn eine Komponente umzieht.
Beschreibungsstände. Die Detailregeln aus
CLAUDE.md(Vier-Gesichter-Übersicht, Registrierungs-Regel, Kostenbudget, Cloud-Session-Betrieb) tragen den Stand 2026-08-16; die Admin-Routen wurden am 2026-08-03 gegen den Code geprüft (PR #263), §5 am 2026-09-02 beim Rollout korrigiert.Nachzieh-Anlass. Jede Änderung an
app/package.json,app/src/routes/paths.ts, den Cloudbuild-/nginx-Dateien,app/security-headers.conf,api/security_headers.py,api/routers/csp.py,api/auth.py,api/origin_gate.py,infra/cloudflare/oderapp/src/lib/seo/prerender.ts. Für die öffentliche Gestaltung istdesign-system.mddie bindende Vorschrift, nicht diese Datei.
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. Seit dem Website-Audit 2026-09-02 gilt dasselbe für die beiden Werkzeug-Seiten:quiz.aboutundscribe.aboutstehen unter der H1, in der SPA wie im Prerender (vorher 111 bzw. 129 Wörter Hauptinhalt). description: höchstens 155 Zeichen (seoCoverage.test.ts). Google schneidet länger mitten im Satz ab, und verloren geht regelmäßig die letzte Teilaussage — genau dort steht die Zusage. Das Gate erlaubte bis zum Audit 2026-09-02 200 Zeichen; fünf Beschreibungen waren daraufhin auf bis zu 190 gewachsen. Dieselben Texte stehen alsog:descriptionim Prerender und (für die Startseite) inapp/index.html— beim Kürzen mitziehen.- Was eine Seite verspricht, muss sie halten. Der Prerender ist kein
zweiter Textbestand, sondern dieselbe Locale in anderer Form: Wo die SPA
eine Auswahl verbirgt, weil es sie nicht gibt (
quizOptions.offersChoice), verbirgt die vorgerenderte Seite sie auch — sonst lesen Crawler und KI-Antworten ein Angebot, das die Seite nicht hat (Audit 2026-09-02: Kurrent, Offenbacher und drei Schwierigkeitsstufen im Quiz-Body). Gepinnt vonlib/seo/prerender.test.ts. <lastmod>insitemap.xmlist die „Stand"-Zeile der Crawler-Seite (prerender.tsliest sie von dort), nicht bloß Buchhaltung. Wer Copy ändert, zieht das Datum der betroffenen Route mit;scripts/check-sitemap-lastmod.mjsläuft imnpm run prerenderund hält jedes Datum gegen die Git-Historie der gerenderten Seite selbst (app/prerender/<seite>.html) — die Datei IST die Antwort auf „wann hat sich diese Seite geändert". Bis 2026-09-03 war der Vergleichsmaßstab eine handgepflegte Quellenliste je Seite (PageSpec.sources); die driftete in beide Richtungen — eine geteilte Datei wieseo.tsmeldete Seiten stale, deren Text sich nicht bewegt hatte, und ein Body, der ein nicht gelistetes Modul las, änderte die Seite unbemerkt. Eine Folge ist einzukalkulieren: eine Änderung am Seitenrahmen (Nav-Beschriftung, Rechtehinweis) schreibt alle zehn Dateien neu und verlangt entsprechend alle zehn Daten — was den Crawler-Seiten tatsächlich passiert ist. Bei flacher Klonung (keingit log) überspringt der Wächter die Historien-Hälfte still, statt zehn Fehlalarme zu werfen; die Arbeitsbaum-Hälfte greift weiter./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.
Beide Cloud-Run-Dienste stehen mit ingress=all im Netz — es gibt keinen
Load Balancer, und einer würde mehr im Monat kosten als das ganze Projekt. Der
API-Dienst antwortet damit auf ZWEI Adressen: https://api.kurrentschrift.ink
(von Cloudflare proxied) und die rohe *.run.app-URL (nicht). Alles, was
Cloudflare durchsetzt — die Rate-Limiting-Regel, die WAF, der Cache — war über
die zweite Adresse umgehbar; das Audit vom 2026-09-02 hat genau das gemessen
(die run.app-Antwort trug keinen einzigen cf--Header).
Eine Cloudflare-Transform-Rule stempelt deshalb auf jeden Request, den sie
für api.kurrentschrift.ink weiterreicht, den Header
X-Origin-Secret: <Geheimnis>. api/origin_gate.py verlangt ihn und antwortet
sonst 403 — vor dem Limiter, vor der Auth, vor jeder Datenbankabfrage. Das
ist keine Authentifizierung: der Header sagt „ich kam durch die Vordertür",
nichts darüber, wer da kommt. Wer etwas darf, entscheidet weiterhin
api/auth.py.
- Unset heißt aus. Ohne
ORIGIN_SECRETin der Cloud-Run-Env ist die Prüfung komplett inaktiv — das ist zugleich der Rollback und der Grund, warum lokale Entwicklung und Testsuite sie nie sehen. Achtung beim Rollback: eine Änderung an Secrets oder Env legt eine NEUE Revision an, und der Dienst hängt nach jedem Deploy an einer namentlich festgenagelten Revision (update-traffic --to-revisions=…,api/cloudbuild.yaml) — die neue bekommt also erst Verkehr, wenn sie ausdrücklich promotet wird. Scharfschalten und Zurücknehmen sind deshalb je ZWEI Befehle:services update …, dannservices update-traffic --to-revisions=<neue Revision>=100. Kein neuer Build, aber auch kein Selbstläufer. - Ausgenommen sind
/healthund/seo-proxy/…./healtherreicht der Deploy-Smoke auf derrun.app-Tag-URL der Kandidaten-Revision, die definitionsgemäß nie am Edge vorbeikommt — ein Gate davor ließe jeden Deploy geschlossen fehlschlagen./seo-proxy/…ist Gürtel-und-Hosenträger: das nginx der Website holt die Prerender-Seiten überapi.kurrentschrift.ink(app/nginx.conf@seo_proxy), kommt also durch den Edge und trägt den Header — aber der Preis eines Irrtums wären 403 für jeden Crawler. - Der Admin-Weg stempelt selbst — das war der Befund des Rollouts. Die
Apex
/api/*erreicht den Dienst über den Workerkurrentschrift-api-proxy, und ein Worker-Subrequest an einen Host derselben Zone läuft an den Transform-Rules der Zone vorbei. Die Regel greift also für Browser und Crawler, aber nicht für dasfetch()aus dem Worker heraus:/api/healthmeldete nach dem Anlegen der Regel weiterhinoff. Der Worker setzt den Header deshalb selbst aus einersecret_text-BindungORIGIN_SECRET(danachoff-seen, nach dem Scharfschaltenok). Quelltext, Einstellungen und Deploy-Weg liegen seit 2026-09-02 im Repo:infra/cloudflare/— vorher existierte der Worker nur im Dashboard. nginx kennt kein/apiund ruft nichts direkt auf. /healthmeldet das Urteil für den Request, mit dem es gefragt wurde:origin_gate=off·off-seen·ok·missing·mismatch(nie der Wert). Damit lässt sich JEDER Weg in den Dienst —api.-Host, Apex hinter Access, nginx, roherun.app— prüfen, BEVOR das Gate scharf geschaltet wird: Transform-Rule anlegen, dann muss jeder Weg, der weiterlaufen soll,off-seenmelden; erst danach die Env setzen. Genau diese Messung hat den Worker-Befund oben gefunden, bevor er den Admin lahmlegen konnte — sie ist nicht Zierrat, sondern der Grund, warum das Scharfschalten kein Sprung war.- Break-Glass braucht jetzt beide Header. Der dokumentierte Notweg über die
direkte
run.app-URL mitX-Admin-Tokenläuft ins 403, solange nicht zusätzlichX-Origin-Secretmitgeschickt wird — beide Werte liegen im Secret Manager, wer den einen holen kann, holt auch den anderen. Wer die Tür ganz aufmachen will, entfernt für die Dauer des Notfalls die Env-Variable.
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.
api/origin_gate.py hat die Tür des API-Dienstes zugemacht und die des
App-Dienstes zugleich als das benannt, was es von seiner Seite aus nicht
schließen konnte. kurrentschrift-app steht ebenfalls mit ingress=all im
Netz: Auf der rohen *.run.app-URL liefert es die ganze Website ohne
Bot-Challenge, ohne WAF und ohne Rate-Limit aus — und reicht einen
Crawler-UA über @seo_proxy an https://api.kurrentschrift.ink weiter, wo der
Edge das API-Geheimnis rechtmäßig stempelt. Das API-Gate kann das nicht sehen:
Der Request, den es bekommt, kam wirklich durch die Vordertür. Jeder solche
Umweg kostet einen Prerender-Read auf der API plus das Crawler-Plausible-Event,
das sie dafür meldet.
app/origin-gate.conf.template ist dieselbe Mechanik in nginx — ein
Geheimnis, fünf Urteile (off · off-seen · ok · missing ·
mismatch), eine Rollout-Prozedur. Die Datei ist eine Vorlage, weil nginx die
Umgebung nicht lesen kann; das Basis-Image bringt den Entrypoint
20-envsubst-on-templates.sh bereits mit, das Geheimnis kommt also als
gewöhnliche Cloud-Run-Env-Variable herein und beim Containerstart läuft nichts
Neues.
- Aus, bis es jemand anschaltet. Das Image setzt
ORIGIN_GATE=off, der Dienst deklariert bis heute gar keine Env-Variablen.ORIGIN_GATE=onmit leeremORIGIN_SECRETschlägt geschlossen fehl — die Map-Schlüssel sind getaggt, ein vergessenes Geheimnis öffnet also nicht das ganze Internet. /_healthmeldet das Urteil im HeaderX-Origin-Gate, für den Request, mit dem es gefragt wurde (nie den Wert). Es ist der EINZIGE ausgenommene Pfad, exakt und ohne Präfix. Damit ist jeder Weg messbar, bevor scharf geschaltet wird: Transform-Rule aufkurrentschrift.inkerweitern, dann muss der Edge-Wegoff-seenmelden und die roherun.app-URL weiteroff; erst danach wird armiert.- Zwei Aufrufer kommen legitim am Edge vorbei und stempeln jetzt selbst:
der Deploy-Smoke in
app/cloudbuild.yaml(liestORIGIN_SECRETim Schritt aus dem Secret Manager, nicht überavailableSecrets, und fragt/_healthvor jeder Inhaltsprobe) und der tägliche Bot-Wächter.github/workflows/bot-serving-check.yml(aus dem Repository-SecretORIGIN_SECRET;missing/mismatchsind dort harte Fehler mit einer Meldung, die das Secret beim Namen nennt — sonst würde ein scharfes Gate ohne Secret 32 rote Crawler-Checks erzeugen und ein Incident aufmachen, der „jede Crawler-Seite ist kaputt" behauptet). - Der Worker braucht hier nichts. Anders als im Schwesterprojekt schickt
kurrentschrift-api-proxy.jsjeden Pfad anapi.kurrentschrift.ink, und die Plausible-Aufrufe (/js/script.js,/pa/event) laufen über einen eigenen Worker direkt zuplausible.io— beides erreicht diesen Container nie. Das ist eine Eigenschaft des Codes, kein Naturgesetz:tests/test_app_origin_gate.pyschlägt an, sobald ein Zweig einen Pfad an den eigenen Origin zurückgibt (dort wäre wieder zu stempeln, weil ein Worker-Subrequest in derselben Zone an den Transform-Rules vorbeiläuft). - Scharfschalten ist ein Block, kein Flag. Auch dieser Dienst nagelt
Verkehr namentlich fest (
app/cloudbuild.yamlpromotet mit--to-revisions=…=100), einservices updateallein legt also eine armierte Revision an, die nichts ausliefert — beim API-Rollout (#493) gemessen, nicht angenommen. Der Block schickt trotzdem--no-trafficmit: Er lieststatus.traffic, prüft aber nie die Verkehrs-Spezifikation, und ein Dienst, den zuletzt ein blankesgcloud run deployaußerhalb der Pipeline angefasst hat, trägt dortlatestRevision: true— dann wäre die Änderung sofort scharf, bevor irgendetwas gemessen ist. Wo der Verkehr ohnehin namentlich hängt, ist das Flag wirkungslos. Dazu: das Image der ausliefernden Revision pinnen (nicht die letzte), die Secret-Version als Nummer setzen (nie:latest— Cloud Run löst sie beim Instanz-Start auf, sonst gibt es sporadische 403 innerhalb einer Revision) und keinen Lauf starten, während ein Cloud Build unterwegs ist. Der vollständige Block, die Hostnamen-Tabelle und der Rollback stehen ininfra/cloudflare/README.md§ „The site's own origin". - Ein Längen-Deckel auf dem Geheimnis. nginx kann keinen
map-Schlüssel hashen, der länger ist als ein Bucket; der Schlüssel istpresented:plus das ganze Geheimnis, die Vorlage setzt deshalbmap_hash_bucket_size 512. Ab grob 500 Zeichen startet nginx nicht mehr — mit ausgeschaltetem Gate dagegen einwandfrei, der Fehler erschiene also erst im Moment des Scharfschaltens. Gefunden hat ihn der Container-Smoke im Schwesterprojekt; hier hält ihn der Jobapp-imagein.github/workflows/ci.ymlgefangen, der das echte Image mit einem produktionslangen Geheimnis dreimal fährt (aus, scharf, scharf ohne Geheimnis).
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).
Der Website-Audit vom 2026-09-02 hat den Layout-Sprung auf /tafel der
nachgeladenen GLKurrent-Initiale zugeschrieben und daraus die offene
font-display-Frage abgeleitet. Die Nachmessung in Produktion widerlegt
das: der Sprung passiert, nachdem die Schrift geladen ist.
Aufbau — echtes Chrome über CDP (nicht headless; seit dem 2026-09-03 steht
Cloudflare Bot Fight Mode vor der Zone, eine Challenge wäre also möglich, die
ausgelieferte Seite wurde je Lauf gegengeprüft), ignoreCache, Slow 4G +
4× CPU, je drei Läufe, gemessen mit einem PerformanceObserver auf
layout-shift:
| Ansicht | CLS | Ursache |
|---|---|---|
| Mobil 390×844 | 0,0948 · 0,0948 · 0,0979 | ein einziger Shift bei ~1,9–2,3 s |
| Desktop 1280×800 | 0,112 · 0,112 · 0,112 | derselbe Shift |
Ursachenanteil, aus den Shift-Quellen und einer Zeitreihe der Dokumenthöhe:
- Tafelscans: 0. Sie tragen seit #476 ihr
chart_sizealsaspect-ratio(1633/1869 · 1614/1300 · 2190/1029) und reservieren ihren Platz, bevor ein Byte Bild da ist. - Geschriebene Initiale (GLKurrent): 0.
document.fonts.check('40px GLKurrent')ist in JEDEM Lauf schontrue, bevor der Shift eintritt. - Später Aufbau der drei Schrift-Abschnitte: alles. Bis ~1,4 s ist das
Dokument 844 px hoch (Kopf + Einleitung), dann springt es in EINEM Schritt
auf 3132 px (mobil) bzw. 4494 px (Desktop), sobald
/sourcesbeantwortet ist. Die Fußzeile, die bis dahin bei y≈649 im Sichtfeld stand, verlässt es — genau dieser eine Shift IST der CLS.
Folge für die offene Entscheidung: font-display: optional für die Initiale
würde diese Zahl nicht bewegen. Wer den CLS senken will, reserviert die
Höhe der drei Abschnitte (Skelett), solange die Quellen laden — das ist eine
Gestaltungsfrage (eine hohe leere Fläche statt einer kurzen Seite) und keine
Schriftfrage.
Genau diese Reservierung steht jetzt: TafelSkeleton zeichnet vom ersten Bild
an den eigenen Seitenkopf und die drei Abschnitte in ihrer fertigen Höhe, statt
eine Bildschirmhöhe „lade Vorlage …" zu zeigen. Reserviert wird, was sich sonst
bewegt — die echten Schriftnamen und Feder-Zeilen (feste Texte, also von Anfang
an richtig), ein Kasten in Größe des Status-Chips, die Tafel im eigenen
Seitenverhältnis (RESERVED_CHART_RATIO in useGrundtafeln.ts, Spiegel von
sources.chart_size) und die Herkunftskarte. Die Ratios sind nur
Reservierung: OriginalScan nimmt sein aspect-ratio weiterhin aus der
Antwort, eine ausgetauschte Tafel kostet also einen kleinen Shift, nie ein
falsch geformtes Bild.
Gegengemessen mit demselben Aufbau wie oben (echtes Chrome über CDP, Slow 4G +
4× CPU, Cache aus, je drei Läufe). Beide Stände als lokaler preview-Build
gegen https://api.kurrentschrift.ink — die Zone hat Bot Fight Mode, und der
Basis-Build reproduziert die Produktionszahl auf zwei Stellen genau (0,0969 vs.
0,0948–0,0979 mobil; 0,1125 vs. 0,112 Desktop), samt identischer Dokumenthöhe
3132/4494 px:
| Ansicht | vorher | nachher | Rest |
|---|---|---|---|
| Mobil 390×844 | 0,0969 · 0,0969 · 0,0969 | 0,0007 · 0,0007 · 0,0007 | Kopfzeilen-Fontswap |
| Desktop 1280×800 | 0,1125 · 0,1125 · 0,1125 | 0,0004 · 0,0004 · 0,0004 | derselbe |
Der Beitrag der Seite selbst ist damit 0: was übrig bleibt, ist der Schriftwechsel
in der Navigation des gemeinsamen Seitenkopfs (0,00073 mobil / 0,00033 Desktop) —
den hatte die alte Seite genauso, er gehört nicht /tafel.
Genauigkeit der Reservierung, gemessen als Höhe des reservierten gegen den
fertigen Abschnitt: Kurrent und Offenbacher 0–1 px, Sütterlin 39 px (mobil) bzw.
57 px (Desktop). Diese Differenz ist die Original/Geschrieben-Umschaltung, die es
nur auf der nachgeschriebenen Schrift gibt — welche das ist, weiß erst
/sources, und sie liegt auf beiden Ansichten unter der Falzkante, wo eine
Verschiebung nichts kostet. Der Schimmer läuft unter
prefers-reduced-motion: reduce gar nicht (animation={false}, geprüft: keine
MuiSkeleton-wave-Klasse, animation: none).
Bis zum Audit vom 2026-09-02 lieferte kurrentschrift.ink keinen einzigen
der sechs üblichen Sicherheits-Header und auch kein Cache-Control auf der
SPA-Hülle aus; das Schwesterprojekt trug beides schon. Seither stehen sie in
app/security-headers.conf — eine eigene Datei, weil nginx add_header
nicht über Ebenen hinweg vererbt: Sobald ein location-Block einen eigenen
add_header setzt, fallen sämtliche geerbten weg. Die Datei wird darum im
Server-Block UND in jedem solchen location per include gezogen; wer irgendwo
einen add_header ergänzt, ergänzt die include-Zeile daneben.
tests/test_csp_policy.py hält genau das fest.
| Header | Wert | Warum |
|---|---|---|
Content-Security-Policy |
siehe unten | Erlaubt-Liste der tatsächlichen Quellen der Seite; scharf seit 2026-09-05, davor seit dem 2026-09-02 -Report-Only |
Strict-Transport-Security |
max-age=15552000 |
180 Tage, ohne includeSubDomains, ohne preload (Autor-Entscheid 2026-09-02, wie anyplot) |
X-Content-Type-Options |
nosniff |
|
X-Frame-Options |
SAMEORIGIN |
die alte Hälfte von frame-ancestors |
Referrer-Policy |
strict-origin-when-cross-origin |
|
Permissions-Policy |
Geo/Kamera/Mikro/Payment/USB/MIDI/Serial aus | alles Ungenannte behält seine Vorgabe — u. a. clipboard-write, das „Link kopieren" braucht. bluetooth steht bewusst NICHT drin: Chromium kennt das Token nicht und schreibt dafür „Unrecognized feature" in jede Besucher-Konsole (im Durchgang vom 2026-09-02 gesehen) |
Die CSP nennt die gemessenen Quellen, nicht die vermuteten. script-src
kommt ohne 'unsafe-inline' aus: Die beiden Inline-Skripte in
app/index.html (Hero-Vorwärmer und Plausible-Stub) laufen über ein Nonce
pro Antwort, der Plausible-Loader /js/script.js und das Vite-Modul sind
'self'.
Bis 2026-09-04 standen dort zwei sha256-Hashes, und der Tausch hat einen gemessenen Grund. Ein Hash gilt für die Bytes — und ein drittes Inline-Skript kommt hinzu, das dieses Repository nicht schreibt: Cloudflares JavaScript Detections spritzt es an der Kante in jede HTML-Antwort ein, mit Ray-ID und Zeitstempel pro Antwort im Rumpf. Dafür kann es keinen Hash geben, und eine reine Hash-Policy blockiert genau dieses eine Skript (im Schwesterprojekt gemessen, anyplot #11213) — auf einer Free-Plan-Zone, auf der JavaScript Detections bei aktivem Bot Fight Mode nicht einmal abschaltbar ist.
Das Nonce ist Cloudflares eigene Empfehlung: Die Kante liest den
Response-Header und stempelt ihr eingespritztes Skript damit — am
2026-09-04 auf anyplot.ai live nachgemessen, inklusive der beiden Skripte, die
Cloudflare in seinem versteckten iframe erzeugt. Erzeugt wird es von nginx als
$request_id (16 Zufallsbytes, 32 Hex-Ziffern); sub_filter stempelt
dieselbe Variable auf jedes <script-Tag der Shell, und
tests/test_csp_policy.py lässt die beiden Hälften nicht auseinanderlaufen.
Ein Nebeneffekt, der in app/nginx.conf steht: sub_filter löscht
Last-Modified und ETag, deshalb bringt das frühere no-cache auf der Shell
kein 304 mehr und der Header heißt jetzt no-store — gleiche Bytes, aber die
Zusage, dass kein genonctes Dokument im Cache liegt.
style-src behält 'unsafe-inline', und zwar nicht mehr mangels Nonce —
nginx kann eines erzeugen, siehe oben —, sondern weil die Theme-Tokens auf
inline-style-Attributen reiten, die ein Nonce grundsätzlich nicht deckt,
und weil Emotion (MUI) sein Stylesheet zur Laufzeit weiterschreibt.
Report-Only ab dem 2026-09-02, scharf seit dem 2026-09-05 (Autor-Entscheid).
Die Policy ging zuerst als Content-Security-Policy-Report-Only live und
blockierte damit nichts, sondern meldete nur — ein Fehler in ihr macht die
Werkbank unbenutzbar, und die Werkbank ist genau die Fläche, die kein
automatischer Durchgang öffnen kann. Diese Zeit — davon 40 Stunden auf dem
Nonce-Pfad, also seit #532 am 2026-09-04 gegen 15:00Z — hat keine einzige
Meldung aus dem Code der Seite erzeugt: Angekommen sind nur die absichtliche
Sonde vom 2026-09-04T15:29Z (CSP_PROBE_2026_09_04) und, einmal, am
2026-09-05T12:58Z zwei Meldungen eines einzelnen Clients, dessen
eingespritztes Cloudflare-JavaScript-Detections-Skript
(window.__CF$cv$params…, Quellzeile 61 von /, samt dessen verstecktem
iframe) kein Nonce trug. Jeder Abruf seither — mit Chrome-UA wie mit curl —
zeigt alle fünf Skript-Tags inklusive Cloudflares mit dem Nonce des Headers;
und scharf führt so ein Client schlicht Cloudflares Bot-Skript nicht aus, die
Seite selbst bleibt unberührt. Was ein Melde-Kanal nicht sehen kann, wurde
stattdessen im Code nachgelesen: keine Worker, kein WebAssembly, kein eval
und kein new Function, und createObjectURL nur für Downloads und Bilder —
was blob: in img-src ohnehin erlaubt. anyplot.ai fährt dieselbe Policy seit
dem 2026-09-04 scharf, ohne eine eigene Meldung. Der Entscheid, fünf Tage vor
dem ursprünglich geplanten Termin scharf zu schalten, steht damit auf gemessener
Stille plus gelesenem Code, nicht auf Zuversicht.
Gemeldet wird an POST /csp-report auf dem API-Host (api/routers/csp.py):
zählt und loggt, schreibt nichts, kennt beide Wire-Formate (report-uri schickt
ein Objekt, die Reporting-API ein Array mit camelCase-Feldern) und ist die
einzige öffentliche Schreiboperation dieser API — als solche in
tests/test_api_public_surface.py::PUBLIC_WRITES benannt und begründet. Vom
Rate-Limiter ist sie nicht ausgenommen (der weite Eimer ist genau das Netz,
das eine offene POST-Route braucht), vom Origin-Gate ebenfalls nicht: Reports
laufen wie jeder Browser-Aufruf über den Edge, der den Header stempelt.
Gemeldet wird ausschließlich per report-uri — gemessen, nicht vermutet.
Die naheliegende Fassung deklariert beide Kanäle, report-uri für Firefox und
Safari, report-to samt Reporting-Endpoints für Chromium. Im Browser-Durchgang
vom 2026-09-02 kostete genau das jede Chromium-Meldung: Mit report-to in der
Policy ignoriert Chromium report-uri (so ist es spezifiziert) und lieferte
dann gar nichts — in 200 Sekunden erreichte keine Anfrage den Endpunkt.
Ohne report-to kam dieselbe Verletzung in unter einer Sekunde an. Ein Kanal,
der den funktionierenden stilllegt, ohne ihn zu ersetzen, ist schlechter als
keiner; report-to kommt zurück, sobald eine Meldung nachweislich über HTTPS
darüber ankommt. Der Endpunkt versteht das Reporting-API-Format trotzdem
schon — dann ändert sich nur der Header.
Kommt nichts an, ist zuerst der Kanal zu prüfen — Stille ist zweideutig: Sie kann heißen, dass niemand die Policy verletzt, oder dass niemand den Endpunkt erreicht. Die Sonde geht den Weg eines echten Reports:
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Content-Type: application/csp-report' \
--data '{"csp-report":{"document-uri":"https://kurrentschrift.ink/","effective-directive":"probe","blocked-uri":"probe"}}' \
https://api.kurrentschrift.ink/csp-report # erwartet: 204403 heißt: Die Cloudflare-Transform-Rule stempelt X-Origin-Secret nicht auf
POST-Anfragen (§5). Die Meldungen selbst stehen als WARNING im Log der API,
eine Zeile je verschiedener Verletzung und danach eine je hundertster
Wiederholung — eine Verletzung, die zehntausendmal feuert, ist ein anderer
Befund als eine, die zweimal feuert, und der mitlaufende Zähler ist die Stelle,
an der man das sieht. Jeder geloggte Wert wird entschärft: Die Felder kommen
aus einer anonymen POST-Anfrage, ein Zeilenumbruch darin würde sonst weitere
Log-Einträge erfinden.
report-uri steht auch in der scharfen Policy — eine tatsächlich blockierte
Quelle will man erst recht erfahren. Auseinanderhalten lassen sich die beiden
Modi am Feld disposition, das jede Log-Zeile mitführt: report, solange eine
Policy nur zusah, enforce, sobald sie handelt.
Zurückrollen ist dieselbe eine Zeile: In app/security-headers.conf den
Header-Namen wieder zu Content-Security-Policy-Report-Only ändern und
deployen — oder, schneller als ein Build, den Verkehr auf die vorige Revision
zurücklegen:
gcloud run services update-traffic kurrentschrift-app \
--region=europe-west4 --project=kurrentschrift \
--to-revisions=<vorige>=100Cache-Control auf der Hülle. location = /index.html setzt no-store
(seit 2026-09-04; davor no-cache). Ohne Header trug die Antwort nur
Last-Modified, der Browser cachte die Hülle heuristisch mit ~10 % ihres
Alters und verlangte nach einem Deploy /assets/-Hashes, die es nicht mehr
gibt: weiße Seite. Das war der ursprüngliche Anlass.
no-cache war danach die bewusst engere Wahl gegenüber anyplots no-store:
„vor Gebrauch nachfragen", die Kopie bleibt liegen, der gemessene Weg endet in
einem 304 mit null Bytes. Diese Ersparnis gibt es seit dem Nonce nicht mehr —
sub_filter schreibt die Hülle pro Antwort um und nginx löscht dabei
Last-Modified und ETag, sonst könnte ein 304 einen frischen Header über
einen gespeicherten Rumpf mit altem nonce="…" legen. Gegengemessen an genau
dieser Konfiguration: kein Last-Modified im Kopf, und ein bedingter GET
antwortet 200 mit vollen 17 287 Bytes statt 304. no-cache hieß damit „behalte
eine Kopie, die du nie revalidieren kannst, und lade sie trotzdem jedes Mal neu"
— gleiche Bytes wie no-store, ohne dessen Zusage. Das ist nicht der
Schwesterdatei-Abgleich, vor dem der Kommentar dort gewarnt hat; die Zahlen
stehen als Kommentar daneben.
Der API-Host hat seine eigenen drei. api.kurrentschrift.ink ist ein
zweiter öffentlicher Host mit eigenen Antworten; api/security_headers.py
hängt nosniff, Referrer-Policy und HSTS an jede von ihnen — auch an die 403
des Origin-Gates und die 429 des Limiters, denn die Middleware sitzt außerhalb
beider. HSTS muss hier wiederholt werden, gerade WEIL der Apex bewusst ohne
includeSubDomains fährt: Der Header gilt im Browser für den Hostnamen der
Antwort, die ihn trug — der Apex sagt über diesen Host nichts aus, auch nicht
als Geschwister, und dass Cloudflare für beide Namen TLS terminiert, ändert
daran nichts (Copilot-Review, PR #497). app/nginx.conf blendet die drei am
Crawler-Proxy per proxy_hide_header aus, weil die Seite sie dort selbst setzt.
Eine Antwort erreicht die Middleware nie: Starlette baut
ServerErrorMiddleware AUSSERHALB jeder User-Middleware, die 500 einer
unbehandelten Ausnahme ist also schon auf der Leitung. Dafür registriert
api/main.py einen Exception-Handler — genau die Antwort, die jene
Middleware sendet — und stempelt die Header dort.
Keine CSP dort: /docs und /redoc laden Swagger UI bzw. ReDoc von
cdn.jsdelivr.net und führen Inline-Skripte aus; eine Policy, die streng genug
wäre, um etwas zu taugen, würde die eigene API-Dokumentation zerlegen.
Keine gemeldete URL wird ganz geloggt. /federprobe?text=… und
/lesen/vergleichen?text=… tragen, was der BESUCHER getippt hat — sie sind
zum Teilen gemacht —, und ein Report zitiert document-uri wörtlich. Query und
Fragment werden abgeschnitten, bevor irgendetwas geloggt oder gemerkt wird
(api/routers/csp.py::_path_only); eine Sicherheitsmaßnahme soll nicht
nebenbei mitschreiben, was Fremde schreiben.
/api/*→ der Cloudflare-Workerkurrentschrift-api-proxyleitet aufapi.kurrentschrift.ink(FastAPI) um; nginx im App-Container kennt kein/api(siehe Kopfkommentarapp/nginx.conf). Weil ein Worker-Subrequest die Transform-Rules der eigenen Zone NICHT durchläuft, stempelt dieser Worker das Origin-Geheimnis aus §5 selbst — Quelltext und Einstellungen:infra/cloudflare/. nginx muss nichts mitschicken: sein einziger Ausgang (@seo_proxy) geht überapi.kurrentschrift.inkund damit durch den Edge, wo die Regel greift./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; untermdVollbild wie der Wizard),DiagnosticView/FitView. Ihre Spaltenbreite kommt aususeColumnWidth, das den CONTAINER misst (ResizeObserver) und nicht das Fenster — die Fensterbreite kennt die Ränder des Dialogs nicht und ließ das Modal bei 390 px quer scrollen.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; die Breite der Beschriftungsspalte misst das Nachbarmodulquality/labelColumn.ts(labelColumnChars, inch) aus den Bezeichnern selbst — eigenes Modul, weilscoreParts.tsxunter der react-refresh-Regel nur Komponenten exportieren darf.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)