Neon Design System: Phase 3 & 4 – Der letzte Schliff
Neon Design System: Phase 3 & 4 – Der letzte Schliff
Nach der Implementierung der Design Token Foundation (Phase 1) und der Component Library (Phase 2) stand heute der spannendste Teil des UI-Refactorings an: Die Migration aller Views und das Hinzufügen von Micro-Interactions. In diesem Post beschreibe ich, wie ich die SyncFlow- und Rules-Views migriert und dann die Animationen implementiert habe, die dem UI den letzten Schliff geben.
Ausgangslage
Das Design System war in einem guten Zustand:
- Phase 1: Design Tokens (Tokens.fs, Primitives.fs, Icons.fs) ✅
- Phase 2: Component Library (Button.fs, Card.fs, Badge.fs, Input.fs, Stats.fs, Money.fs, Table.fs, Loading.fs, Toast.fs, Modal.fs, Navigation.fs) ✅
- Dashboard & Settings: Bereits migriert (R5-R7) ✅
Noch ausstehend waren die komplexesten Views (SyncFlow, Rules) und die Polish-Phase mit Animationen.
Herausforderung 1: SyncFlow View Migration (R8)
Das Problem
Die SyncFlow-Seite ist die komplexeste View in BudgetBuddy. Sie hat fünf verschiedene Zustände:
- Start-Ansicht: Sync-Button zum Starten
- TAN-Wartebildschirm: Warten auf Push-TAN-Bestätigung am Handy
- Transaktionsliste: Review und Kategorisierung
- Completed-Ansicht: Erfolgsmeldung mit Statistiken
- Error-Ansicht: Fehleranzeige
Jeder Zustand hat eigenes Styling, eigene Interaktionen und eigene Daten. Die alte Implementierung hatte hunderte Zeilen inline CSS-Klassen.
Die Lösung: Design System Komponenten
Ich habe jeden Zustand systematisch auf die Design System Komponenten umgestellt:
// Start Sync View - Neon Orange/Pink Gradient Header
let startSyncView (dispatch: Msg -> unit) =
Html.div [
prop.className "flex flex-col items-center justify-center min-h-[60vh] text-center space-y-8"
prop.children [
// Neon gradient header
Html.div [
prop.className "text-5xl md:text-6xl mb-4"
prop.children [ Icons.sync XL (IconColor.Custom "text-neon-orange") ]
]
Html.h2 [
prop.className "text-2xl md:text-3xl font-bold font-display bg-gradient-to-r from-neon-orange to-neon-pink bg-clip-text text-transparent"
prop.text "Ready to Sync"
]
// Feature list mit Neon-Icons
Html.ul [
prop.className "space-y-3 text-left"
prop.children [
featureItem Icons.creditCard "Fetch transactions from Comdirect"
featureItem Icons.rules "Auto-categorize with your rules"
featureItem Icons.upload "Import to YNAB"
]
]
// Primary Action Button
Button.view {
Button.defaults with
Text = "Start Sync"
Variant = Button.Primary
Size = Button.Large
Icon = Some (Icons.sync MD IconColor.Primary)
FullWidth = true
OnClick = fun () -> dispatch StartSync
}
]
]
Architekturentscheidung: Warum eigene Hilfsfunktionen pro Zustand?
Anstatt eine riesige match-Expression zu haben, habe ich jeden Zustand in eine eigene Funktion extrahiert:
// Hauptview delegiert an Zustandsfunktionen
let view model dispatch navigateToDashboard =
match model.SyncSession with
| RemoteData.NotAsked -> startSyncView dispatch
| RemoteData.Loading -> loadingView ()
| RemoteData.Failure err -> errorView err dispatch
| RemoteData.Success session ->
match session.Status with
| WaitingForTan -> tanWaitingView model session dispatch
| InProgress -> transactionsView model session dispatch navigateToDashboard
| Completed -> completedView session dispatch navigateToDashboard
| Cancelled -> cancelledView dispatch
| Failed -> errorView "Sync failed" dispatch
Vorteile:
- Jeder Zustand ist isoliert testbar
- Änderungen an einem Zustand beeinflussen andere nicht
- Code ist leichter zu lesen und zu navigieren
Status-Badges mit Design System
Die alte Implementierung hatte inline-definierte Farben für jeden Transaktionsstatus. Jetzt nutze ich die Badge-Komponenten:
// Badge-Mapping für Transaktionsstatus
let statusBadge status =
match status with
| Uncategorized -> Badge.uncategorized
| AutoCategorized -> Badge.autoCategorized
| ManualCategorized -> Badge.manual
| PendingReview -> Badge.pendingReview
| Skipped -> Badge.skipped
| Imported -> Badge.imported
Herausforderung: RemoteData Namespace-Konflikt
Ein interessantes F#-Problem: IconColor.Success kollidierte mit RemoteData.Success. Die Lösung war einfach, aber man muss daran denken:
// Vorher: Compiler-Fehler
| Success transactions -> ... // Welches Success?
// Nachher: Expliziter Namespace
| RemoteData.Success transactions -> ...
Herausforderung 2: Rules View Migration (R9)
Das Problem
Die Rules-Seite hat ein komplexes Modal für das Erstellen/Bearbeiten von Regeln mit:
- Pattern-Typ-Auswahl (Contains, Exact, Regex)
- Target-Field-Auswahl (Payee, Memo, Combined)
- Pattern-Testing mit Live-Feedback
- Kategorie-Dropdown aus YNAB
Die Lösung: Modulares Modal mit Input-Komponenten
let ruleEditModal model dispatch =
Modal.view {
IsOpen = model.ShowRuleModal
OnClose = fun () -> dispatch CloseRuleModal
Size = Modal.Large
Title = if model.IsNewRule then "Create Rule" else "Edit Rule"
} [
Modal.body [
// Form mit Design System Input-Komponenten
Input.groupRequired "Rule Name" (
Input.textSimple
model.RuleFormName
(UpdateRuleFormName >> dispatch)
"e.g., Netflix Subscription"
)
Input.group "Pattern Type" None (
Input.selectSimple
model.RuleFormPatternType
[ "Contains", "Contains (match substring)"
"Exact", "Exact (match full text)"
"Regex", "Regex (regular expression)" ]
(UpdateRuleFormPatternType >> dispatch)
)
// Pattern-Test-Bereich mit Live-Feedback
patternTestSection model dispatch
]
Modal.footer [
Button.ghost "Cancel" (fun () -> dispatch CloseRuleModal)
Button.primary (if model.IsNewRule then "Create" else "Save") (fun () -> dispatch SaveRule)
]
]
Architekturentscheidung: Warum Input.groupRequired vs Input.group?
Das Design System unterscheidet zwischen optionalen und Pflichtfeldern:
// Pflichtfeld: Rotes Sternchen am Label
let groupRequired (label: string) (input: ReactElement) = ...
// Optionales Feld: Normales Label mit optionaler Beschreibung
let group (label: string) (description: string option) (input: ReactElement) = ...
Rationale:
- Benutzer sehen sofort, welche Felder ausgefüllt werden müssen
- Konsistentes Styling über die gesamte App
- Die Validierung kann sich auf Pflichtfelder konzentrieren
Pattern-Test mit visuellen Badges
Das Pattern-Testing zeigt jetzt farbcodierte Ergebnisse mit Icons:
let patternTestResult model =
match model.PatternTestResult with
| Some (Ok true) ->
Html.div [
prop.className "flex items-center gap-2 p-3 rounded-lg bg-neon-green/10 border border-neon-green/30"
prop.children [
Icons.checkCircle MD Icons.Success
Html.span [
prop.className "text-neon-green font-medium"
prop.text "Pattern matches!"
]
]
]
| Some (Ok false) ->
Html.div [
prop.className "flex items-center gap-2 p-3 rounded-lg bg-neon-red/10 border border-neon-red/30"
prop.children [
Icons.xCircle MD Icons.Error
Html.span [
prop.className "text-neon-red font-medium"
prop.text "Pattern does not match"
]
]
]
| Some (Error err) ->
Html.div [
prop.className "flex items-center gap-2 p-3 rounded-lg bg-neon-orange/10 border border-neon-orange/30"
prop.children [
Icons.warning MD Icons.Warning
Html.span [
prop.className "text-neon-orange font-medium"
prop.text $"Error: {err}"
]
]
]
| None -> Html.none
Herausforderung 3: Page Transitions (R10)
Das Problem
Beim Wechseln zwischen Seiten gab es einen “harten” Übergang – die neue Seite erschien einfach. Das fühlt sich nicht poliert an.
Die Lösung: Key-basierte Animation
In React/Feliz kann man die key-Property nutzen, um Animationen bei Änderungen zu triggern:
// In View.fs
Navigation.pageContent [
Html.div [
// Key ändert sich bei Seitenwechsel -> Animation wird neu gestartet
prop.key (model.CurrentPage.ToString())
prop.className "animate-page-enter"
prop.children [
match model.CurrentPage with
| Dashboard -> Components.Dashboard.View.view ...
| SyncFlow -> Components.SyncFlow.View.view ...
// ...
]
]
]
Die zugehörige CSS-Animation:
.animate-page-enter {
animation: pageEnter 0.3s ease-out forwards;
}
@keyframes pageEnter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Warum ease-out und nicht ease-in-out?
ease-out: Startet schnell, bremst sanft ab → fühlt sich natürlich an beim Erscheinenease-in-out: Beschleunigt und bremst → besser für Hover-Effekteease-in: Startet langsam → fühlt sich träge an
Herausforderung 4: Success Feedback mit Checkmark-Animation
Das Problem
Nach erfolgreichen Aktionen (Import abgeschlossen, Regel gespeichert) soll der Benutzer visuelles Feedback bekommen – nicht nur einen Toast, sondern etwas Besonderes.
Die Lösung: SVG Stroke Animation
Ich habe eine animierte Checkmark implementiert, die sich “zeichnet”:
/// Animated success checkmark (SVG with draw animation)
let successCheckmark (size: SpinnerSize) =
let sizeClass =
match size with
| XS -> "w-4 h-4"
| SM -> "w-5 h-5"
| MD -> "w-6 h-6"
| LG -> "w-8 h-8"
| XL -> "w-12 h-12"
Html.div [
prop.className "animate-success-pop"
prop.children [
Svg.svg [
svg.className $"{sizeClass} text-neon-green"
svg.viewBox (0, 0, 24, 24)
svg.fill "none"
svg.stroke "currentColor"
svg.strokeWidth 3
svg.custom ("strokeLinecap", "round")
svg.custom ("strokeLinejoin", "round")
svg.children [
Svg.path [
svg.d "M5 13l4 4L19 7" // Checkmark-Pfad
svg.className "animate-checkmark"
svg.custom ("strokeDasharray", "24")
svg.custom ("strokeDashoffset", "24")
]
]
]
]
]
Die Animation nutzt den SVG stroke-dashoffset-Trick:
.animate-checkmark {
animation: checkmarkDraw 0.4s ease-in-out forwards;
}
@keyframes checkmarkDraw {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
Wie funktioniert das?
stroke-dasharray: 24sagt “der Strich ist 24 Einheiten lang”stroke-dashoffset: 24verschiebt den Start um 24 → nichts sichtbar- Animation reduziert offset auf 0 → Strich “erscheint” von Anfang bis Ende
Kombiniert mit Pop-Animation:
.animate-success-pop {
animation: successPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes successPop {
0% { opacity: 0; transform: scale(0); }
50% { transform: scale(1.2); }
100% { opacity: 1; transform: scale(1); }
}
Der cubic-bezier(0.34, 1.56, 0.64, 1) ist ein “spring” Timing – er überschwingt auf 1.2x und federt zurück.
Herausforderung 5: Form-Fehler mit Shake-Animation
Das Problem
Wenn der Benutzer ein Formular mit Fehlern absendet, soll das visuell kommuniziert werden – nicht nur durch roten Text.
Die Lösung: Shake-Animation für Error-State
// In Input.fs
let private stateClass state =
match state with
| Normal -> "input-bordered border-white/10 focus:border-neon-teal focus:shadow-glow-teal/30"
| Error _ -> "input-bordered border-neon-red focus:border-neon-red animate-shake"
| Success -> "input-bordered border-neon-green focus:border-neon-green"
Die Shake-Animation ist subtil aber effektiv:
.animate-shake {
animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes shake {
10%, 90% { transform: translateX(-1px); }
20%, 80% { transform: translateX(2px); }
30%, 50%, 70% { transform: translateX(-4px); }
40%, 60% { transform: translateX(4px); }
}
Warum diese spezifischen Werte?
- Die Animation ist asymmetrisch (nicht einfach links-rechts-links)
- Die Amplitude variiert (größer in der Mitte, kleiner am Rand)
- Das fühlt sich wie echtes “Kopfschütteln” an
Herausforderung 6: Staggered List Animations
Das Problem
Wenn eine Liste von Items erscheint, wirkt es unnatürlich wenn alle gleichzeitig einblenden.
Die Lösung: Stagger Delays
/// Wrap list items with staggered animation
let staggeredList (animation: string) (items: ReactElement list) =
Html.div [
prop.className "space-y-2"
prop.children [
for i, item in items |> List.indexed do
Html.div [
prop.key (string i)
prop.className $"{animation} opacity-0 {Tokens.StaggerDelays.forIndex i}"
prop.children [ item ]
]
]
]
/// Staggered slide-up list
let staggeredSlideUp (items: ReactElement list) =
staggeredList "animate-slide-up" items
Die Delay-Klassen in CSS:
.stagger-1 { animation-delay: 50ms; }
.stagger-2 { animation-delay: 100ms; }
.stagger-3 { animation-delay: 150ms; }
/* ... bis stagger-10 */
Und die Token-Hilfsfunktion:
module StaggerDelays =
let d1 = "stagger-1"
let d2 = "stagger-2"
// ...
let forIndex (i: int) =
match min i 9 with
| 0 -> d1
| 1 -> d2
// ...
Warum 50ms Inkrement?
- Zu schnell (20ms): Kein wahrnehmbarer Unterschied
- Zu langsam (200ms): Fühlt sich träge an
- 50ms ist der “Sweet Spot” für visuelle Kaskaden
Lessons Learned
1. Namespace-Konflikte in F# sind subtil
Die gleichen Namen in verschiedenen Kontexten (Success für RemoteData vs. IconColor) können zu Compile-Fehlern führen. Die Lösung: Explizite Namespace-Qualifizierung.
2. CSS-Animationen brauchen Timing-Funktionen
Der Unterschied zwischen ease, ease-out, und cubic-bezier ist enorm für das “Gefühl” der Animation. Immer die passende Funktion für den Kontext wählen.
3. Key-Props in React sind mächtiger als gedacht
Sie kontrollieren nicht nur Reconciliation, sondern können Animationen triggern. Wenn sich der Key ändert, wird die Komponente “neu gemountet”.
4. SVG-Animationen sind erstaunlich flexibel
Mit stroke-dasharray und stroke-dashoffset kann man fast jede Linien-Animation erstellen.
Fazit
Die UI-Refactoring-Phasen 3 und 4 haben BudgetBuddy von einer funktionalen App zu einem polierten Produkt transformiert:
Vorher:
- Inline CSS-Klassen überall
- Keine Animationen
- Inkonsistentes Styling
Nachher:
- Design System mit 15 Komponenten-Modulen
- Page Transitions, Success Feedback, Error Shake
- Staggered Animations für Listen
- Neon Glow Dark Theme durchgehend
Statistiken
| Metrik | Wert |
|---|---|
| Migrierte Views | 4 (Dashboard, Settings, SyncFlow, Rules) |
| Neue Animationen | 10 (fadeIn, slideUp, scaleIn, shake, successPop, checkmark, slideInRight, bounceSubtle, glowPulse, pageEnter) |
| Neue Loading-Komponenten | 9 (spinner, ring, dots, neonPulse, skeleton, successCheckmark, successBadge, errorMessage, staggeredList) |
| Geänderte Zeilen | ~3,400 |
| Build-Warnungen | 0 |
| Test-Ergebnis | 121/121 bestanden |
Key Takeaways für Neulinge
-
Design Tokens sind die Grundlage: Bevor du Komponenten baust, definiere deine Farben, Abstände und Schriften als wiederverwendbare Konstanten. In F# bedeutet das Module mit
let-Bindings für CSS-Klassen. -
Animationen machen den Unterschied: Eine App kann funktional identisch sein, aber mit guten Micro-Interactions fühlt sie sich 10x besser an. Starte mit Page Transitions und Feedback-Animationen.
-
Key-Props steuern React’s Verhalten: Wenn du willst, dass eine Komponente “neu startet” (inkl. Animationen), ändere ihren Key. Das ist mächtiger als
useEffectfür viele Animationsfälle.
Diese Arbeit wurde mit Claude Code durchgeführt, einem AI-Assistenten von Anthropic. Der Blogpost dokumentiert die tatsächlichen Implementierungsentscheidungen und Herausforderungen während der UI-Refactoring-Session am 4. Dezember 2025.