Frontend-Redesign: Von “extrem hässlich” zu Premium Mobile-First UI

Datum: 2025-11-30 Autor: Claude Thema: Frontend-Entwicklung, F#/Feliz, TailwindCSS, Mobile-First Design


Einleitung

Als ich die BudgetBuddy-Anwendung das erste Mal sah, war die Diagnose klar: “extrem hässlich” – so die ehrliche Einschätzung. Das Frontend hatte zwar alle funktionalen Komponenten (Dashboard, SyncFlow, Rules, Settings), aber praktisch keine Styles. Die Anwendung war unbenutzbar auf mobilen Geräten und selbst auf dem Desktop wenig einladend.

Die Aufgabe war klar definiert: Eine komplette UI-Transformation mit Fokus auf Mobile-First-Design, Premium-Ästhetik und – besonders wichtig – keine Standard-Schriftarten. Die App sollte sich wie eine moderne Finanz-App anfühlen, vergleichbar mit Banking-Apps, die Nutzer täglich verwenden.

Was diese Aufgabe besonders interessant machte: Das gesamte Frontend ist in F# mit Feliz geschrieben – einer funktionalen, typsicheren Alternative zu JavaScript/TypeScript. Das bringt eigene Herausforderungen und Einschränkungen mit sich, wie ich schnell feststellen sollte.

Ausgangslage

Das Projekt hatte bereits eine solide technische Basis:

Die styles.css war praktisch leer – nur ein @import "tailwindcss". Alle View-Dateien verwendeten zwar Tailwind-Klassen, aber ohne visuelles Konzept. Es gab keine Custom-Fonts, keine Animationen, keine responsive Navigation und keine konsistente Designsprache.


Herausforderung 1: Font-Strategie für eine Finanz-App

Das Problem

Standard-Schriftarten wie Arial, Helvetica oder die System-Fonts sind überall. Sie sind funktional, aber sie geben einer App keine Identität. Besonders bei einer Finanz-App, die Vertrauen ausstrahlen soll, ist die Typografie entscheidend.

Gleichzeitig gibt es bei Fonts im Web Performance-Überlegungen: Jeder zusätzliche Font erhöht die Ladezeit.

Optionen, die ich betrachtet habe

  1. Nur System-Fonts (abgelehnt)
    • Pro: Beste Performance, kein zusätzlicher Download
    • Contra: Genau das, was vermieden werden sollte – “Standard”
  2. Ein einziger Custom-Font für alles
    • Pro: Minimale Performance-Kosten
    • Contra: Weniger visuelle Hierarchie, Zahlen in normalen Sans-Serif-Fonts sind oft schlecht lesbar
  3. Drei spezialisierte Fonts (gewählt)
    • Pro: Optimale Lesbarkeit für jeden Zweck, Premium-Gefühl
    • Contra: Höhere initiale Ladezeit (durch font-display: swap abgemildert)

Die Lösung: Font-Trio mit klarer Aufgabenteilung

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap');

:root {
  --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --font-display: 'Space Grotesk', 'Inter', sans-serif;
  --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}

Warum diese drei Fonts?

  1. Inter – Der Arbeitstier-Font für Body-Text
    • Extrem gut lesbar in kleinen Größen
    • Speziell für Screens optimiert (nicht für Print adaptiert)
    • Ausgezeichnete Unterstützung für verschiedene Gewichte
  2. Space Grotesk – Display-Font für Überschriften
    • Geometrisch und modern, aber nicht kalt
    • Gibt der App Charakter und Wiedererkennungswert
    • Funktioniert hervorragend in großen Größen
  3. JetBrains Mono – Für Zahlen und technische Inhalte
    • Monospace-Font mit hervorragender Lesbarkeit
    • Zahlen sind perfekt ausgerichtet (wichtig für Beträge!)
    • Banking-Apps verwenden ähnliche Ansätze für Kontostände

Architekturentscheidung: CSS Custom Properties statt direkter Werte

Ich habe mich entschieden, alle Fonts als CSS-Variablen zu definieren. Dadurch:

h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-display);
  font-weight: 600;
  letter-spacing: -0.02em;  /* Tighter für Headlines */
}

.font-mono, code, pre, .stat-value {
  font-family: var(--font-mono);
}

Herausforderung 2: Mobile-First Navigation in Feliz

Das Problem

Die ursprüngliche Navigation war eine Desktop-Navbar, die auf Mobilgeräten völlig unbrauchbar war. Moderne Mobile-Apps haben typischerweise eine Bottom-Navigation – denken Sie an Instagram, Banking-Apps oder Spotify. Das fühlt sich natürlicher an, weil der Daumen die untere Bildschirmhälfte leichter erreicht.

Die Herausforderung: Wie implementiere ich eine dual-mode Navigation (Top für Desktop, Bottom für Mobile) in Feliz/F#?

Optionen, die ich betrachtet habe

  1. Hamburger-Menu für Mobile
    • Pro: Einfach zu implementieren, spart Platz
    • Contra: Versteckt Navigation, erfordert zwei Klicks
  2. Responsive Top-Navbar (kollabiert auf Mobile)
    • Pro: Ein Code-Pfad
    • Contra: Touch-Interaktion am oberen Rand ist ergonomisch schlecht
  3. Separate Desktop-Top + Mobile-Bottom Navigation (gewählt)
    • Pro: Optimale UX für beide Gerätetypen
    • Contra: Mehr Code, zwei Navigationskomponenten zu pflegen

Die Lösung: Dual-Mode Navigation

let private navbar (model: Model) (dispatch: Msg -> unit) =
    Html.div [
        prop.children [
            // Desktop navbar - versteckt auf Mobile
            Html.nav [
                prop.className "hidden md:flex fixed top-0 left-0 right-0 z-50
                               navbar bg-base-100/80 backdrop-blur-xl
                               border-b border-base-200 px-6"
                prop.children [
                    // Logo und volle Navigation
                ]
            ]

            // Mobile header - nur Logo, versteckt auf Desktop
            Html.nav [
                prop.className "md:hidden fixed top-0 left-0 right-0 z-50
                               navbar bg-base-100/90 backdrop-blur-xl"
                prop.children [ (* Logo only *) ]
            ]

            // Mobile bottom navigation
            Html.nav [
                prop.className "md:hidden fixed bottom-0 left-0 right-0 z-50
                               bg-base-100/90 backdrop-blur-xl
                               border-t border-base-200 safe-area-pb"
                prop.children [
                    Html.div [
                        prop.className "flex justify-around items-center py-2"
                        prop.children [
                            mobileNavItem "Home" Dashboard model.CurrentPage dispatch
                            mobileNavItem "Sync" SyncFlow model.CurrentPage dispatch
                            mobileNavItem "Rules" Rules model.CurrentPage dispatch
                            mobileNavItem "Settings" Settings model.CurrentPage dispatch
                        ]
                    ]
                ]
            ]
        ]
    ]

Architekturentscheidung: Tailwind’s Responsive Prefixes

Statt JavaScript-basierter Media-Query-Logik nutze ich Tailwinds md: Prefix-System:

Dies hält die F#-Logik sauber und überläss CSS die Responsive-Arbeit.

Rationale für safe-area-pb:

.safe-area-pb {
  padding-bottom: env(safe-area-inset-bottom);
}

iPhones mit Notch haben einen “Safe Area Inset” am unteren Rand. Ohne diese Anpassung würde die Bottom-Navigation vom Home-Indicator überlappt werden.


Herausforderung 3: SVG-Icons in Feliz – Der überraschende Blocker

Das Problem

Mein ursprünglicher Plan war, Heroicons oder ähnliche SVG-Icon-Libraries zu verwenden. Das ist der Standard-Ansatz in modernen Web-Apps. Ich hatte bereits schöne SVG-Icons implementiert:

// Was ich schreiben wollte
Html.svg [
    prop.className "w-5 h-5"
    prop.viewBox (0, 0, 24, 24)
    prop.fill "none"
    prop.stroke "currentColor"
    prop.children [
        Html.path [ prop.d "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0..." ]
    ]
]

Der Build schlug fehl mit:

error FS0039: The value, constructor, namespace or type 'viewBox' is not defined.
error FS0039: The value, constructor, namespace or type 'svg' is not defined.

Analyse des Problems

Feliz ist eine typsichere Abstraktionsschicht über React. Nicht alle HTML/SVG-Properties sind in jeder Version verfügbar. Die Version im Projekt unterstützte die SVG-spezifischen Properties nicht direkt.

Optionen, die ich betrachtet habe

  1. Feliz.Svg oder ähnliche Erweiterung installieren
    • Pro: “Richtige” Lösung, volle SVG-Unterstützung
    • Contra: Dependency hinzufügen, potenziell Breaking Changes
  2. Raw HTML mit dangerouslySetInnerHTML
    • Pro: Funktioniert definitiv
    • Contra: Verliert Type-Safety, XSS-Risiko bei dynamischen Inhalten
  3. Emoji-basierte Icons (gewählt)
    • Pro: Zero Dependencies, funktioniert sofort, universell unterstützt
    • Contra: Weniger präzise Kontrolle über Styling, nicht alle Icons verfügbar

Die Lösung: Emoji-Icons als pragmatischer Workaround

let private navIcon (page: Page) =
    let icon =
        match page with
        | Dashboard -> "🏠"
        | SyncFlow -> "🔄"
        | Rules -> "📋"
        | Settings -> "⚙️"
    Html.span [
        prop.className "text-lg"
        prop.text icon
    ]

Warum Emojis funktionieren:

  1. Universelle Unterstützung – Jedes moderne OS rendert Emojis nativ
  2. Semantisch passend – Es gibt Emojis für fast jeden UI-Zweck
  3. Performance – Kein zusätzlicher Download nötig
  4. Skalierbar – Verhalten sich wie Text, skalieren mit font-size

Mapping der wichtigsten Icons:

Zweck Emoji Verwendung
Dashboard 🏠 Navigation
Sync 🔄 Navigation, Actions
Rules 📋 Navigation
Settings ⚙️ Navigation
Success Status-Badges
Error Fehler-Anzeige
Warning ⚠️ Warnungen
Info ℹ️ Hinweise
Edit ✏️ Action-Buttons
Delete 🗑️ Action-Buttons
Bank 🏦 Comdirect-Settings
Money 💰 YNAB-Settings
Category 🏷️ Kategorien

Lessons Learned:

Manchmal ist die “unprofessionelle” Lösung die pragmatischste. Emojis sehen auf modernen Geräten gut aus, sind barrierefrei (Screen-Reader können sie vorlesen) und erfordern keine zusätzliche Infrastruktur.


Herausforderung 4: F# Interpolated Strings mit Bedingungen

Das Problem

F# hat strikte Regeln für interpolierte Strings. Dieser Code funktioniert nicht:

// Fehler: FS3373
prop.className $"text-2xl font-bold {if uncategorized > 0 then "text-red-500" else "text-base-content/40"}"

Der Compiler beschwert sich: “Invalid interpolated string. Single quote or verbatim string literals may not be used in interpolated expressions.”

Die Lösung: Variable Extraction

// Korrigiert
let pendingColor = if uncategorized > 0 then "text-red-500" else "text-base-content/40"
prop.className $"text-2xl font-bold font-mono {pendingColor}"

Architekturentscheidung: Benannte Variablen für Lesbarkeit

Statt nur den Compiler zufriedenzustellen, habe ich dies als Gelegenheit genutzt, den Code lesbarer zu machen:

// Vorher (hätte nicht kompiliert)
prop.className $"badge {if status = Completed then "badge-success" else "badge-warning"}"

// Nachher (kompiliert und lesbar)
let statusClass =
    match status with
    | Completed -> "badge-success"
    | InProgress -> "badge-info"
    | Pending -> "badge-warning"
    | Failed _ -> "badge-error"
prop.className $"badge {statusClass}"

Dies ist ein Beispiel dafür, wie Compiler-Einschränkungen zu besserem Code führen können.


Herausforderung 5: Design-System mit CSS Custom Properties

Das Problem

Tailwind ist fantastisch für Utility-First CSS, aber ohne ein Design-System wird der Code schnell inkonsistent. Verschiedene Spacing-Werte, unterschiedliche Schatten, keine einheitliche Animations-Timing.

Die Lösung: Design Tokens als CSS Variables

:root {
  /* Spacing Scale */
  --space-xs: 0.25rem;
  --space-sm: 0.5rem;
  --space-md: 1rem;
  --space-lg: 1.5rem;
  --space-xl: 2rem;
  --space-2xl: 3rem;
  --space-3xl: 4rem;

  /* Border Radius */
  --radius-sm: 0.375rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;
  --radius-xl: 1rem;
  --radius-2xl: 1.5rem;
  --radius-full: 9999px;

  /* Shadows */
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
  --shadow-glow: 0 0 20px rgb(59 130 246 / 0.3);

  /* Transitions */
  --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
  --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
  --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
  --transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

Warum diese Struktur?

  1. Konsistenz – Überall dieselben Werte verwenden
  2. Wartbarkeit – Änderungen an einer Stelle wirken sich überall aus
  3. Theming – Dark Mode kann diese Variablen überschreiben
  4. Dokumentation – Die Variablen-Namen dokumentieren sich selbst

Die Spring-Transition:

--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);

Dieser Cubic-Bezier erzeugt einen “Spring”-Effekt – die Animation overshoots leicht und federt zurück. Das fühlt sich lebendiger an als lineare Animationen.


Herausforderung 6: Glass Morphism für Premium-Gefühl

Das Problem

Flat Design sieht oft leblos aus. Skeuomorphismus ist out. Glass Morphism (oder “Glassmorphismus”) ist der aktuelle Trend bei Premium-Apps – denken Sie an macOS Big Sur oder iOS.

Die Lösung: backdrop-filter und semi-transparente Backgrounds

.glass-card {
  background: rgba(255, 255, 255, 0.7);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.3);
  box-shadow: var(--shadow-lg);
}

[data-theme="dark"] .glass-card {
  background: rgba(30, 41, 59, 0.8);
  border: 1px solid rgba(255, 255, 255, 0.1);
}

Anwendung auf die Navbar:

.navbar {
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(20px);
  border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}

Rationale:


Herausforderung 7: Animations mit Accessibility

Das Problem

Animationen machen eine UI lebendig, aber manche Nutzer haben Probleme mit Bewegung (vestibuläre Störungen). Wie baue ich Animationen ein, die optional sind?

Die Lösung: prefers-reduced-motion Media Query

/* Slide up animation */
.animate-slide-up {
  animation: slideUp 0.5s var(--transition-spring) forwards;
}

@keyframes slideUp {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Reduced Motion Support */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Warum 0.01ms statt 0ms?

Manche CSS-Engines ignorieren animation-duration: 0ms. Mit 0.01ms wird die Animation technisch ausgeführt, ist aber für Menschen unsichtbar schnell.

Animationen im Projekt:

Animation Verwendung Timing
fadeIn Seiten-Übergänge 0.3s ease-out
slideUp Cards, Content 0.5s spring
scaleIn Modals, Toasts 0.3s spring
shimmer Loading States 1.5s infinite
gradientShift Hero-Sections 15s infinite

Herausforderung 8: Card-basiertes Design für Transaction-Listen

Das Problem

Die ursprüngliche SyncFlow-Seite zeigte Transaktionen in einer Tabelle. Auf Mobile sind Tabellen problematisch:

Die Lösung: Transaction Cards

let private transactionCard (tx: TransactionDisplay) (dispatch: Msg -> unit) =
    Html.div [
        prop.className "card bg-base-100 shadow-sm hover:shadow-md transition-all"
        prop.children [
            Html.div [
                prop.className "card-body p-4"
                prop.children [
                    // Header: Payee + Amount
                    Html.div [
                        prop.className "flex justify-between items-start"
                        prop.children [
                            Html.div [
                                prop.className "flex-1 min-w-0"
                                prop.children [
                                    Html.h3 [
                                        prop.className "font-semibold truncate"
                                        prop.text tx.Payee
                                    ]
                                    Html.p [
                                        prop.className "text-sm text-base-content/60"
                                        prop.text (tx.Date.ToString("dd.MM.yyyy"))
                                    ]
                                ]
                            ]
                            Html.span [
                                let amountClass =
                                    if tx.Amount >= 0m
                                    then "text-success"
                                    else "text-error"
                                prop.className $"font-mono font-bold {amountClass}"
                                prop.text (formatAmount tx.Amount)
                            ]
                        ]
                    ]
                    // Category selector
                    // ...
                ]
            ]
        ]
    ]

Design-Entscheidungen:

  1. truncate – Lange Payee-Namen werden abgeschnitten statt umgebrochen
  2. min-w-0 – Ermöglicht Flexbox-Items zu schrumpfen (wichtig für truncate)
  3. Visuelle Hierarchie – Payee groß, Datum klein und grau
  4. Farbkodierung – Grün für Einnahmen, Rot für Ausgaben

Lessons Learned

1. Type-Safety hat manchmal Kosten

Feliz ist großartig für Type-Safety, aber nicht alle Browser-APIs sind abgedeckt. Die SVG-Limitation war unerwartet. In Zukunft würde ich vor dem Designen einer Lösung prüfen, welche HTML/CSS-Features die konkrete Feliz-Version unterstützt.

2. Pragmatismus über Perfektion

Emojis statt SVG-Icons war nicht mein ursprünglicher Plan, aber es funktioniert hervorragend. Die Nutzer sehen keinen Unterschied in der Qualität, und der Code ist einfacher. Manchmal ist die “unprofessionelle” Lösung die richtige.

3. Design-Tokens früh etablieren

Ich habe die CSS-Variablen am Anfang definiert und konnte sie dann überall nutzen. Das hätte ich bei den F#-Komponenten auch machen können – z.B. ein DesignTokens.fs Modul mit Common-Klassen-Strings.

4. Mobile-First ist mehr als Responsive

Es geht nicht nur darum, dass die App auf kleinen Bildschirmen funktioniert. Es geht um Touch-Targets, Daumen-Erreichbarkeit, Safe Areas und die Erwartungen von Mobile-Nutzern (Bottom-Navigation statt Top).

5. Compiler-Fehler als Code-Verbesserung

Die F#-Interpolated-String-Einschränkung zwang mich, Bedingungen in benannte Variablen zu extrahieren. Das Ergebnis ist lesbarer Code. Manchmal sind Einschränkungen Features.


Fazit

Was wurde erreicht

Das BudgetBuddy-Frontend wurde von einer funktionalen, aber stilistisch unbrauchbaren Anwendung zu einer modernen, mobil-optimierten Finanz-App transformiert.

Dateien modifiziert:

Technische Errungenschaften:

Build-Status: ✅ 0 Fehler, 0 Warnungen


Key Takeaways für Neulinge

1. Starte mit dem Design-System, nicht mit einzelnen Komponenten

Bevor du anfängst, einzelne Buttons oder Cards zu stylen, definiere deine Design-Tokens: Farben, Spacing, Typografie, Animationen. Das spart später enorm viel Zeit und Inkonsistenz.

2. Mobile-First bedeutet: Mobile zuerst designen, dann Desktop erweitern

Schreibe zuerst die Mobile-Styles, dann füge mit md: und lg: Prefixes Desktop-Anpassungen hinzu. Das ist einfacher als umgekehrt, weil Mobile weniger Platz hat und du genauer über Priorisierung nachdenken musst.

3. Pragmatismus ist kein Dirty Word

Wenn SVG nicht funktioniert, nimm Emojis. Wenn eine Library nicht das kann, was du brauchst, finde einen Workaround. Das Ziel ist eine funktionierende, schöne App – nicht die “perfekte” technische Lösung.


Dieser Blogpost dokumentiert die Frontend-Redesign-Arbeit vom 30. November 2025 für das BudgetBuddy-Projekt.