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:
- TailwindCSS 4 Beta mit DaisyUI als Component-Library
- Feliz für typsichere React-Komponenten in F#
- Elmish-Architektur für State-Management (Model-View-Update Pattern)
- Vier Hauptseiten: Dashboard, SyncFlow, Rules, Settings
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
- Nur System-Fonts (abgelehnt)
- Pro: Beste Performance, kein zusätzlicher Download
- Contra: Genau das, was vermieden werden sollte – “Standard”
- 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
- 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?
- 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
- 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
- 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:
- Können Themes die Fonts überschreiben
- Ist die Wartung zentral an einer Stelle
- Gibt es Fallbacks für jeden Font
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
- Hamburger-Menu für Mobile
- Pro: Einfach zu implementieren, spart Platz
- Contra: Versteckt Navigation, erfordert zwei Klicks
- Responsive Top-Navbar (kollabiert auf Mobile)
- Pro: Ein Code-Pfad
- Contra: Touch-Interaktion am oberen Rand ist ergonomisch schlecht
- 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:
hidden md:flex= Versteckt auf Mobile, Flex auf Desktopmd:hidden= Sichtbar auf Mobile, versteckt auf Desktop
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
- Feliz.Svg oder ähnliche Erweiterung installieren
- Pro: “Richtige” Lösung, volle SVG-Unterstützung
- Contra: Dependency hinzufügen, potenziell Breaking Changes
- Raw HTML mit dangerouslySetInnerHTML
- Pro: Funktioniert definitiv
- Contra: Verliert Type-Safety, XSS-Risiko bei dynamischen Inhalten
- 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:
- Universelle Unterstützung – Jedes moderne OS rendert Emojis nativ
- Semantisch passend – Es gibt Emojis für fast jeden UI-Zweck
- Performance – Kein zusätzlicher Download nötig
- 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?
- Konsistenz – Überall dieselben Werte verwenden
- Wartbarkeit – Änderungen an einer Stelle wirken sich überall aus
- Theming – Dark Mode kann diese Variablen überschreiben
- 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:
backdrop-filter: blur(20px)– Unschärfe-Effekt auf den Hintergrund- Semi-transparenter Background lässt Inhalte durchscheinen
- Subtiler Border gibt Definition ohne hart zu wirken
- Dark Mode invertiert die Farben, behält aber den Effekt bei
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:
- Horizontales Scrollen nötig
- Kleine Touch-Targets
- Schwer zu scannen
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:
truncate– Lange Payee-Namen werden abgeschnitten statt umgebrochenmin-w-0– Ermöglicht Flexbox-Items zu schrumpfen (wichtig für truncate)- Visuelle Hierarchie – Payee groß, Datum klein und grau
- 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:
src/Client/styles.css– 530 Zeilen CSS mit Design-Systemsrc/Client/View.fs– Responsive Dual-Navigationsrc/Client/Views/DashboardView.fs– Stats-Cards, Quick-Action, Historysrc/Client/Views/SyncFlowView.fs– Transaction-Cards, Step-Indicatorsrc/Client/Views/RulesView.fs– Card-basierte Regel-Darstellungsrc/Client/Views/SettingsView.fs– Section-Headers, Status-Indicators
Technische Errungenschaften:
- Custom Font-Stack (Inter, Space Grotesk, JetBrains Mono)
- 6 Custom-Animationen (fadeIn, slideUp, scaleIn, shimmer, pulse, gradientShift)
- Dark Mode Support (via DaisyUI Theming)
- Accessibility (prefers-reduced-motion, Screen-Reader-freundliche Emojis)
- Glass Morphism Effekte für Premium-Gefühl
- Mobile Bottom-Navigation mit Safe-Area-Support
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.