diff --git a/docs/03-frontend.md b/docs/03-frontend.md
new file mode 100644
index 0000000..2f74b39
--- /dev/null
+++ b/docs/03-frontend.md
@@ -0,0 +1,170 @@
+# 03 Frontend
+
+React 19 mit TypeScript, gebaut von Vite 6, gestaltet mit Tailwind 4. Eine
+einzige Seite; Apache liefert sie über `FallbackResource` für jeden Pfad aus.
+
+## Menü
+
+```
+Dashboard
+Leads Pipeline als Kanban
+ └ Archiviert alles, was per Aktenschrank weggelegt wurde
+Kontakte
+Unternehmen
+Ausschreibungen Tabelle; nur der laufende Bestand
+ ├ Live! Kanban über die Phasen
+ └ Archiv archivierte Ausschreibungen, von dort zurückholbar
+Laufende Projekte
+Historie
+Einstellungen
+```
+
+Die Unterpunkte klappen auf, sobald ein Punkt des Zweigs benutzt wird. Dauerhaft
+ausgeklappt wäre die Leiste doppelt so lang, und Archive schaut man selten an.
+
+Die **Netzwerkansicht ist kein Menüpunkt**, sondern eine Ansicht innerhalb von
+Kontakten und Unternehmen – genau wie im perinco CRM.
+
+## Komponentenkarte
+
+```mermaid
+flowchart TD
+ M["main.tsx
AuthProvider, ThemeProvider, ToastProvider,
OptionsProvider, WhisperProvider"] --> A["App.tsx
Ansichtswechsel, gemeinsame Daten"]
+ A --> SB["Sidebar"]
+ A --> HD["Header
Suche, Design, hell/dunkel, Konto"]
+ A --> V{"Ansicht"}
+ V --> DSH["dashboard/"]
+ V --> LDS["leads/
Kanban, Karte, ToDos, Erinnerungen"]
+ V --> KON["contacts/
Liste, Kacheln, Netzwerk, CV-Import"]
+ V --> UNT["companies/
Liste, Kacheln, Group, Netzwerk"]
+ V --> AUS["tenders/
Tabelle, Live!, Archiv"]
+ V --> PRJ["projects/"]
+ V --> HIS["history/"]
+ V --> SET["settings/
Nutzer, Stammdaten, Konto, Diktat"]
+ KON --> NW["network/Network.tsx
d3-force + SVG"]
+ UNT --> NW
+ NW --> CT["network/CompanyTree.tsx"]
+```
+
+Zwei Dinge liegen bewusst in `App.tsx`:
+
+* Die Listen der Unternehmen und Benutzer werden **einmal** geladen und
+ weitergereicht – fast jedes Formular braucht sie als Auswahl.
+* `open(type, id)` wechselt die Ansicht und reicht die Kennung durch. Damit kann
+ jede @-Erwähnung und jeder Suchtreffer die passende Karte öffnen, ohne dass
+ die Ebenen dazwischen davon wissen (`src/lib/navigation.tsx`). Dort hängt auch
+ `goToSettings(tab)` – der Weg, den der Diktat-Knopf nimmt, wenn das Modell
+ noch fehlt.
+
+## Zustand
+
+| Was | Wo |
+| --- | --- |
+| Angemeldeter Benutzer, JWT | `lib/auth.tsx`, Token im `localStorage` |
+| Design und hell/dunkel | `lib/theme.tsx` – am Benutzer **und** im `localStorage` |
+| Auswahllisten (Branchen, Arten) | `lib/options.tsx` |
+| Spracherkennung | `lib/whisper.tsx` |
+| Meldungen | `components/Toast.tsx` |
+
+Es gibt keinen globalen Datenspeicher. Jede Ansicht lädt, was sie braucht, und
+meldet über `onChanged` zurück, wenn die gemeinsamen Listen neu gelesen werden
+müssen. Bei der Größe des Bestands ist das die einfachere Wahl.
+
+## Designs
+
+Rechts oben zwei Umschalter: Farbwelt und hell/dunkel. Beides gilt für den
+angemeldeten Benutzer, nicht nur für den Browser.
+
+| Design | Beschreibung |
+| --- | --- |
+| **ICON** | Gold `#C1862F` auf neutralem Grau, Montserrat – bewusst ohne Grafik |
+| **Half-Life** | Black Mesa: HEV-Orange auf Sichtbeton, harte Schatten, Oswald in den Überschriften |
+| **Pixel Art** | 8-bit: keine Radien, versetzte Schatten, Arcade-Grün, Silkscreen in den Überschriften |
+
+Half-Life und Pixel Art bringen Grafiken hinter Kopfleiste und Menü mit (SVG
+unter `public/themes/`). Die CSS-Variable enthält dabei **zwei** Ebenen: einen
+Schleier aus der Flächenfarbe und darunter die Zeichnung – nur so funktioniert
+dieselbe Grafik in hell und dunkel, ohne dass die Schrift verliert.
+
+Ein Design tauscht ausschließlich Variablenwerte; im Markup steht nirgends eine
+feste Farbe. Auch der Dunkelmodus arbeitet **ohne `dark:`-Varianten**: die
+Klasse `dark` auf `` tauscht in `src/index.css` die Werte von `canvas`,
+`surface`, `ink-*`, `brand-*` und den getönten Statusfarben. Ein Inline-Skript
+in `index.html` setzt die Klasse vor dem ersten Frame, sonst blitzt die helle
+Variante auf.
+
+Die Liste der Designs steht an zwei Stellen und muss übereinstimmen:
+`THEME_PALETTES` in `src/types.ts` und `THEMES` in `server/src/userFields.js`.
+Ein unbekannter Wert wird vom Backend mit 400 abgewiesen und beim Lesen auf
+`null` normalisiert – sonst reicht ein entfernter Palettenname, um die Oberfläche
+beim Anmelden abstürzen zu lassen.
+
+## Texte: drei Feldarten
+
+| Art | Komponente | Wo |
+| --- | --- | --- |
+| WYSIWYG (HTML) | `ui/RichTextEditor.tsx` | Ausschreibungstext, Projektnotizen |
+| Text mit @-Erwähnungen | `lib/mentions.tsx` (`MentionField`) | Notizen an Kontakt, Unternehmen, Lead |
+| einfacher Text | `ui/form.tsx` | alles Übrige |
+
+Der WYSIWYG-Editor kommt **ohne Fremdpaket** aus: `contentEditable` plus
+`document.execCommand` für sechs Formate. Quill & Co. bringen ihr eigenes
+Farbschema mit, das im Dunkelmodus gegen die Variablen arbeitet, und kosten über
+200 kB.
+
+Aus dem Altbestand kommt HTML mit Inline-Farben, geschützten Leerzeichen und
+verschachtelten Listen. `src/lib/richText.ts` räumt es beim Laden zurückhaltend
+auf (`sanitize()`): Farbe, Hintergrund, Schriftart und -größe fliegen aus den
+Stilangaben, Fett und Kursiv bleiben. Zusätzlich hebt `src/index.css` alle
+Inline-Farben im Editor aus – sonst stünde im Dunkelmodus schwarze Schrift auf
+weißen Kästen.
+
+**@-Erwähnungen** stehen als Token im Text: `@[Anzeigename](contact:12)`, ebenso
+für `company` und `user`. Das Feld hat zwei Zustände: *lesen* (Erwähnungen sind
+Knöpfe und führen auf das Profil) und *bearbeiten* (der rohe Token – anders
+ließe sich eine Erwähnung nicht setzen oder löschen).
+
+## Netzwerkansicht
+
+Kraftgerichtetes Layout (`d3-force`), gerendert als SVG.
+
+* **Rechtecke** = Unternehmen, Größe nach Anzahl Verbindungen, dunkler Rahmen bei Kunden
+* **Kreise** = Kontakte
+* **Goldene Linie** = arbeitet dort · **graue gestrichelte** = hat dort gearbeitet
+* **Feine Linie** = Kontakt kennt Kontakt
+* **Grün umrandete Kreise** = Kontakte aus dem perinco CRM (siehe [06 Schnittstellen](06-schnittstellen.md))
+
+Hintergrund ziehen verschiebt, Mausrad zoomt auf den Zeiger, Knoten ziehen
+fixiert ihn, Doppelklick löst die Fixierung. Eine Geste gilt erst ab 4 px Weg
+als Ziehen, damit Verschieben nicht die Auswahl verwirft.
+
+> **Falle:** Die Liste der externen Knoten muss **stabil** sein. Ein
+> `externals = []` als Vorgabewert erzeugt bei jedem Rendern ein neues Array;
+> die Simulation startet dann endlos neu und alle Knoten liegen auf einem Punkt.
+> Deshalb steht in `Network.tsx` ein `NO_EXTERNALS` auf Modulebene, und die
+> aufrufende Ansicht memoisiert ihre Liste.
+
+### Stammbaum eines Unternehmens
+
+Ein Klick auf ein Unternehmen mit Kontakten öffnet dessen Stammbaum: das
+Unternehmen oben, die Kontakte darunter.
+
+| Ast | Bedeutung |
+| --- | --- |
+| **golden** | Berater – Kontaktart „Experte" |
+| **hell** | normale Verbindung – Ansprechpartner und übrige |
+| **grau, gestrichelt** | Ehemalige – beendete Anstellung; darunter steht, wo die Person heute ist |
+
+Die Art des Astes ergibt sich aus der **Beziehung**, nicht aus der Person: wer
+das Unternehmen verlassen hat, hängt am grauen Ast, auch wenn er Berater ist.
+
+## Fotos
+
+Fotos und Logos liegen als Data-URL in der Datenbank. Der Browser verkleinert
+jedes Bild **vor** dem Upload (`src/lib/imageCompress.ts`): mittiger Ausschnitt
+auf 400 px im Quadrat, WebP mit Qualität 0,82, Rückfall JPEG, dann PNG.
+
+Der Knopf *Von LinkedIn* holt das öffentliche Profilfoto – über das Backend
+(`server/src/linkedinPhoto.js`), weil aus dem Browser die Same-Origin-Policy
+davorstünde. Gelesen wird ausschließlich die Open-Graph-Vorschau der
+öffentlichen Profilseite.