Ziele
Eine persönliche Website zählt meist Fähigkeiten auf. Diese hier sollte sie stattdessen belegen: Die Stories erklären echte Probleme aus der Produktion, die Architekturdiagramme lassen sich absichtlich kaputt machen, und der Hero ist ein kleines, funktionierendes Modell der Art von System, die ich baue. Die Seite selbst soll zeigen, wie ich im Frontend arbeite.
Daraus ergaben sich von Anfang an drei Bedingungen:
- Schnell. Jede Seite wird vorgerendert; Animation darf nie mehr kosten, als sie bringt.
- Barrierearm. Per Tastatur bedienbar, in beiden Themes lesbar, ruhig bei reduzierter Bewegung und nie auf JavaScript angewiesen, um Inhalte zu zeigen.
- Zweisprachig. Englisch und Deutsch, ohne dass eine Sprache hinterherhinken darf.
Architektur
Die Website läuft auf Next.js 16 mit dem App Router, React 19 und TypeScript, gestaltet mit Tailwind CSS v4. Die Liste der Laufzeit-Abhängigkeiten ist bewusst kurz: Next.js, React, MDX, Motion für UI-Übergänge und Vercel Analytics, das ohne Cookies auskommt. Es gibt kein UI-Kit, keine Canvas-Engine und keine WebGL-Bibliothek; diese Teile sind von Hand geschrieben.
Alles ist vorgerendert. cacheComponents ist aktiviert, und die Story-Seiten exportieren ensureStatic = "navigation". Schleicht sich etwas ein, das erst zur Anfragezeit läuft, schlägt der Build fehl.
Die Sprache steht in der URL. Jede Seite existiert unter /en und /de. Ein kleiner Proxy (src/proxy.ts, früher Middleware) leitet Pfade ohne Präfix anhand des Accept-Language-Headers weiter: Er berücksichtigt q-Werte, vergleicht das primäre Subtag (de-AT → de), fällt auf Englisch zurück und setzt Vary: Accept-Language, damit geteilte Caches keine falsche Weiterleitung wiederverwenden. Für zwei Sprachen waren ein paar Dutzend Zeilen eigener Code einfacher als eine Abhängigkeit.
Übersetzungen sind typisiert. Das englische Wörterbuch definiert den Typ Dictionary, und das deutsche muss ihn erfüllen. Ein fehlender oder überzähliger Schlüssel in einer der Sprachen ist ein Compilerfehler und nichts, was erst Besucher bemerken.
Stories sind MDX mit statischen Imports. Jede Story besteht aus einem Eintrag in der Registry und einer MDX-Datei pro Sprache. Die Inhalte werden statisch importiert statt per dynamischem import(), weil eine dynamisch importierte MDX-Datei, die eine Client Component rendert (etwa das interaktive Diagramm), die Validierung im Dev-Modus von Next 16.4 gebrochen hat. Statische Imports kosten hier nichts, gerendert wird ohnehin zur Build-Zeit. Ein Unit-Test prüft, dass jede registrierte Story in beiden Sprachen einen Inhalt hat.
Die lebendige Pipeline
Die Hero-Animation ist ein simuliertes eventgetriebenes System: Produzenten schicken Events über eine API in eine Queue, ein Pool aus sechs Workern verarbeitet sie, und Fehlschläge werden mit Backoff wiederholt oder landen in einer Dead-Letter-Queue. Wer den Mauszeiger bewegt oder scrollt, erzeugt zusätzlichen Traffic, und ein Klick schickt einen Schwall von fünfzig Events los.
Die wichtigste Entscheidung war, die Simulation vom Renderer zu trennen. pipeline-sim.ts ist reines TypeScript ohne DOM: ein Zufallsgenerator mit Seed (mulberry32), dessen Zustand im Simulationszustand liegt, und ein fester Zeitschritt von 1/60 s. Der Canvas-Renderer liest diesen Zustand nur. Die Schleife in der Komponente ist der klassische Akkumulator:
const frame = (now: number) => {
const dt = Math.min(now - last, 100) / 1000;
last = now;
acc += dt;
while (acc >= DT) {
step(sim, DT, input); // rein, mit Seed, ohne Allokationen
acc -= DT;
}
drawPipeline(ctx, sim, layout, palette, rs, intro, dt);
raf = requestAnimationFrame(frame);
};
Weil die Simulation deterministisch ist, lässt sie sich testen wie jeder andere Code: Die Unit-Tests prüfen, dass derselbe Seed denselben Ablauf ergibt, dass die Worker-Grenze nie überschritten wird, dass jedes Event in genau einem Zustand gezählt wird und dass der Paket-Pool begrenzt bleibt.
Keine Allokationen im heißen Pfad. Pakete kommen aus einem vorab angelegten Pool (400 auf dem Desktop, 150 auf kleinen Bildschirmen) mit einer Freiliste als Int32Array, und die Queue ist ein Ringpuffer aus Indizes. Die Zahlen im HUD werden höchstens alle 250 ms aktualisiert und nur, wenn sich ein Wert geändert hat.
Der Traffic ist abgestimmt, kein Zufallsrauschen. Die Ankünfte folgen einem Poisson-Prozess mit wiederkehrenden Lastspitzen, deren Gipfel über der Kapazität der Worker von etwa 5,5 Events pro Sekunde liegen. So baut sich der Rückstau sichtbar auf und wieder ab, auch wenn niemand etwas anfasst, und ein Unit-Test stellt sicher, dass das so bleibt.
Sie läuft nur, wenn jemand hinsieht. Die Schleife stoppt, sobald das Canvas den sichtbaren Bereich verlässt oder der Tab im Hintergrund ist. Mit prefers-reduced-motion gibt es gar keine Schleife: Die Simulation läuft einmal ein paar Sekunden vor und zeichnet ein einziges, komponiertes Bild. Den Seed dafür habe ich so gewählt, dass jeder Zustand zu sehen ist, auch Dead Letters. Das Canvas ist dekorativ (aria-hidden) und hat eine Textbeschreibung für Screenreader.
Gemessen auf einem Apple M4 in Chrome brauchen Simulationsschritt und Zeichnen zusammen etwa 0,7 ms pro Frame (Median, etwa 1 ms im 95. Perzentil).
Der Hintergrund
Hinter allen Inhalten läuft ein WebGL-Hintergrund: ein bildschirmfüllendes Dreieck und ein einziger Fragment-Shader in WebGL1 / GLSL ES 1.00. Er zeichnet eine weiche Aurora aus domain-gewarptem fraktalem Rauschen (fbm), ein feines Punktraster alle 24 CSS-Pixel, das mit langsamem Rauschen atmet, eine Vignette und dreieckiges Dithering gegen Banding. Die Hash-Funktion kommt ohne sin() aus, das sich auf manchen mobilen GPUs uneinheitlich verhält.
Jeder Abschnitt hat eine Stimmung. Abschnitte tragen ein data-bg-Attribut, und ein IntersectionObserver mit einem Root-Margin von −50 % feuert nur, wenn ein Abschnitt die Mitte des Viewports kreuzt. Die Farben gleiten dann zur neuen Palette, statt hart umzuschalten. Story-Seiten bekommen einen eigenen Farbton, abgeleitet aus dem Slug.
Sparsam durch Konstruktion. Das Canvas rendert mit halber Geräteauflösung (höchstens 1,5 Megapixel), mit höchstens 60 Bildern pro Sekunde und pausiert, wenn der Tab verborgen ist. Auf dem Main Thread sind das etwa 0,1 ms pro Frame, gemessen auf demselben M4. Bei reduzierter Bewegung zeichnet er ein statisches Bild pro Stimmungswechsel; ohne WebGL greift ein CSS-Verlauf; ein verlorener WebGL-Kontext wird abgefangen und wiederhergestellt.
Kontrast ist garantiert, nicht erhofft. Der Shader mischt nur vom Seitenhintergrund in Richtung der Aurora-Farben, also ist jede Palettenfarbe das Dunkelste (helles Theme) oder Hellste (dunkles Theme), was der Hintergrund je erreichen kann. Helle Spitzen bleiben bei einer relativen Leuchtdichte von mindestens 0,89, dunkle bei höchstens 0,04. Ein Unit-Test liest die Farb-Tokens aus dem CSS und prüft Fließtext, gedämpften Text und die Akzentfarbe gegen jede Aurora-Farbe jeder Stimmung, einschließlich jedes Story-Farbtons:
for (const theme of themes) {
for (const id of allMoods) {
const p = resolveMood(id, theme, seed);
for (const o of [U.c1, U.c2, U.c3]) {
for (const text of ["fg", "muted", "accent"]) {
expect(contrast(tokens[text], color(p, o))).toBeGreaterThanOrEqual(4.5);
}
}
}
}
Details der Interaktion
Befehlspalette. ⌘K (oder Strg+K oder /) öffnet eine Palette für Navigation, Sprache und Theme. Sie ist ein natives <dialog>, geöffnet mit showModal(), damit ist der Rest der Seite ohne Focus-Trap-Bibliothek inaktiv. Das Eingabefeld ist eine combobox, die per aria-activedescendant auf die aktive Option einer listbox zeigt; die Pfeiltasten bewegen die Auswahl, beim Schließen kehrt der Fokus an seinen Ausgangspunkt zurück, und eine Live-Region meldet Bestätigungen und leere Ergebnisse.
Scroll-Animationen, die nie Inhalte verstecken. Der Server rendert immer den fertigen, sichtbaren Inhalt: kein opacity: 0 im HTML, nichts, was JavaScript braucht, um lesbar zu werden. Nach dem Mounten wird ein Element nur dann für seinen Auftritt versteckt, wenn es noch unterhalb des Viewports liegt, sodass der Wechsel nie sichtbar ist. Der Zustand wird als Data-Attribut direkt an den DOM-Knoten geschrieben, das Einblenden rendert React also nie neu. Bei reduzierter Bewegung wird nie etwas versteckt.
Theme ohne Aufblitzen. Ein kleines Inline-Skript setzt das gespeicherte oder das System-Theme vor dem ersten Paint. React warnt vor Script-Tags, die es im Client rendert, und das passiert auf Seiten, die im Client gerendert werden, etwa der 404-Shell. Dort wird das Tag zu einem inaktiven text/plain-Block, und ein Layout-Effect setzt das Theme stattdessen.
Qualität
- Unit-Tests (Vitest) decken die Simulation, die Hintergrund-Paletten samt Kontrast, die Wörterbücher, die Story-Registry, die Diagrammdefinitionen und den Filter der Befehlspalette ab. Über hundert Tests laufen in deutlich unter einer Sekunde.
- End-to-End-Tests (Playwright) prüfen jeden Ablauf auf einem 1280 px breiten Desktop und einem 375 px breiten Mobil-Viewport, standardmäßig gegen einen Production-Build und gegen den Dev-Server, wenn ich Probleme finden will, die es nur dort gibt, etwa Hydration-Warnungen.
- Jeder Konsolenfehler und jede Warnung lässt einen Test scheitern. Eine gemeinsame Fixture hört die Browser-Konsole und Seitenfehler ab. Die einzigen Ausnahmen sind zwei dokumentierte Fehlalarme; alles andere wird an der Ursache behoben.
- Typen überall, auch in den Wörterbüchern, dazu ESLint mit den Core-Web-Vitals-Regeln von Next.js.
- Teilen und Suche. Jede Story bekommt ein eigenes generiertes Vorschaubild (
next/og, gesetzt in den Schriften der Website), und JSON-LD beschreibt die Person, die Website und jede Story alsTechArticle.
Abwägungen und was ich anders machen würde
- WebGL kostet auf schwachen GPUs. Halbe Auflösung, eine Grenze von 60 fps und ein stromsparender Kontext halten den Aufwand klein, aber ein bildschirmfüllender Fragment-Shader ist nie gratis. Auf sehr schwachen Geräten wäre ein statischer Verlauf die ehrlichere Voreinstellung, und diesen Fall würde ich gern besser erkennen.
- Kein CMS. Stories sind MDX-Dateien im Repository. Dadurch sind sie typisiert, werden zusammen mit dem Code geprüft und getestet, aber jede Textänderung ist ein Deployment. Für eine persönliche Website mit einer Handvoll Stories ist das der richtige Tausch; für ein Redaktionsteam wäre er es nicht.
- Das helle Theme ist bewusst zurückhaltend. Das Kontrastbudget lässt der hellen Aurora sehr wenig Spielraum: Ihre Farben müssen fast weiß bleiben. Ich habe lesbaren Text einem dramatischeren Hintergrund vorgezogen.
- Selbst geschriebener Code ist Code, den ich pflege. Proxy, Befehlspalette und Canvas-Renderer hätten Bibliotheken sein können. Ich habe sie selbst geschrieben, weil jedes Teil klein und spezifisch ist, aber das heißt auch, dass ich sie warte.
Ergebnis
- Jede Seite ist in zwei Sprachen vorgerendert, und fehlende Übersetzungen lassen den Build scheitern.
- Die Hero-Simulation braucht etwa 0,7 ms pro Frame und der Hintergrund etwa 0,1 ms auf dem Main Thread, beide pausieren, wenn niemand hinsieht.
- Lesbarer Kontrast auf dem animierten Hintergrund wird von einem Test geprüft, nicht nach Augenmaß.
- Über hundert Unit-Tests und eine Playwright-Suite auf zwei Viewports, die bei jeder Konsolenwarnung scheitert.