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:

  1. Start-Ansicht: Sync-Button zum Starten
  2. TAN-Wartebildschirm: Warten auf Push-TAN-Bestätigung am Handy
  3. Transaktionsliste: Review und Kategorisierung
  4. Completed-Ansicht: Erfolgsmeldung mit Statistiken
  5. 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:

  1. Jeder Zustand ist isoliert testbar
  2. Änderungen an einem Zustand beeinflussen andere nicht
  3. 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 Erscheinen
  • ease-in-out: Beschleunigt und bremst → besser für Hover-Effekte
  • ease-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?

  1. stroke-dasharray: 24 sagt “der Strich ist 24 Einheiten lang”
  2. stroke-dashoffset: 24 verschiebt den Start um 24 → nichts sichtbar
  3. 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

  1. 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.

  2. 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.

  3. Key-Props steuern React’s Verhalten: Wenn du willst, dass eine Komponente “neu startet” (inkl. Animationen), ändere ihren Key. Das ist mächtiger als useEffect fü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.