Von CSS-Chaos zu F# Design System: Eine Typensichere UI-Komponentenbibliothek aufbauen

Heute habe ich einen kompletten Tag damit verbracht, BudgetBuddys Frontend von einer Sammlung inline gestylter React-Komponenten in ein durchdachtes, modulares Design System zu transformieren. Was als einfaches “Theme-Update” begann, wurde zu einer tiefen Auseinandersetzung mit F#-Typensicherheit, zirkulären Abhängigkeiten und der Frage: Wie baut man eine UI-Komponentenbibliothek in einer funktionalen Sprache?

Ausgangslage: Das Problem mit Inline-Styling

BudgetBuddy hatte nach den ersten Milestones eine funktionierende UI, aber sie war… chaotisch. Hier ein typisches Beispiel aus der alten View.fs:

Html.nav [
    prop.className "fixed top-0 left-0 right-0 z-50 h-16 px-6 items-center justify-between bg-base-100/85 backdrop-blur-xl border-b border-white/5"
    prop.children [
        Html.a [
            prop.className "flex items-center gap-2.5 px-4 py-2.5 rounded-lg transition-all duration-200 cursor-pointer text-neon-teal"
            // ...60+ weitere Zeilen Navigation
        ]
    ]
]

Die Probleme:

  1. Wiederholter Code: Jede Seite hatte ihre eigene Navigation-Implementation
  2. Keine Konsistenz: Farben, Abstände und Animationen variierten
  3. Schwer wartbar: Eine Änderung am Theme erforderte Änderungen in dutzenden Dateien
  4. Keine Type-Safety: CSS-Klassen waren Magic Strings

Der Plan: 5 Phasen, 12 Milestones

Ich entschied mich für einen strukturierten Ansatz mit einem detaillierten Milestone-Plan in docs/UI-REFACTORING-MILESTONES.md. Die Kernidee: Von den Fundamenten aufwärts bauen.

Phase 1: Foundation

  • R0: Theme-Konfiguration (Tailwind CSS 4 + DaisyUI 5)
  • R1: Design Tokens & UI Primitives

Phase 2: Component Library

  • R2: Core UI Components (Button, Card, Badge, Input)
  • R3: Data Display Components (Stats, Money, Table, Loading)
  • R4: Feedback & Navigation Components (Toast, Modal, Navigation)

Phase 3-5: View Migrations, Polish, Documentation

Heute habe ich Phase 1 und Phase 2 komplett abgeschlossen - insgesamt 14 neue F#-Dateien mit über 130KB an typsicherem UI-Code.


Herausforderung 1: Tailwind CSS 4 Migration

Das Problem

BudgetBuddy verwendete Tailwind CSS 4.0.0-beta.5 mit DaisyUI 4.12.14. Das klang modern, war aber ein Alptraum:

// Die alte tailwind.config.js
module.exports = {
  plugins: [require("daisyui")],  // <- ESM-Fehler!
  // ...
}

Die Fehlermeldung: Error [ERR_REQUIRE_ESM]: require() of ES Module daisyui not supported.

DaisyUI 4 war für Tailwind 3 gebaut. Tailwind 4 hatte eine komplett neue Architektur.

Die Optionen

  1. Downgrade auf Tailwind 3
    • Pro: Sofort stabil
    • Contra: Verpasse die neue CSS-first Konfiguration, technische Schuld
  2. Upgrade auf stabile Versionen (gewählt)
    • Pro: Zukunftssicher, neue Features
    • Contra: Migration-Aufwand
  3. DaisyUI komplett entfernen
    • Pro: Volle Kontrolle
    • Contra: Viel mehr eigener CSS-Code nötig

Die Lösung: CSS-First Konfiguration

Tailwind CSS 4.1.17 und DaisyUI 5.5.5 nutzen eine CSS-first Konfiguration. Die tailwind.config.js wird komplett durch CSS-Direktiven ersetzt:

/* src/Client/styles.css */
@import "tailwindcss";
@plugin "daisyui" {
  themes: light --default, dark;
}

@theme {
  /* Custom Neon Colors */
  --color-neon-green: #39FF14;
  --color-neon-orange: #FF6B35;
  --color-neon-teal: #00E5CC;
  --color-neon-purple: #BF40BF;
  --color-neon-pink: #FF69B4;
  --color-neon-red: #FF355E;

  /* Custom Glow Shadows */
  --shadow-glow-green: 0 0 20px rgba(57, 255, 20, 0.4);
  --shadow-glow-orange: 0 0 20px rgba(255, 107, 53, 0.4);
  --shadow-glow-teal: 0 0 20px rgba(0, 229, 204, 0.4);
}

Rationale für diesen Ansatz:

  • @plugin ist die neue DaisyUI 5 Syntax
  • @theme definiert CSS Custom Properties, die Tailwind als Utilities exponiert
  • Keine JavaScript-Konfiguration nötig - alles ist CSS
  • Bessere IDE-Unterstützung für CSS-Completion

Die tailwind.config.js konnte komplett gelöscht werden!


Herausforderung 2: Design Tokens in F# - Typsicher statt Magic Strings

Das Problem

Selbst mit definierten CSS-Klassen waren sie im F#-Code nur Strings:

prop.className "text-neon-green shadow-glow-green"  // Tippfehler? Keine Warnung.

Die Optionen

  1. String-Konstanten
    • Pro: Einfach
    • Contra: Keine logische Gruppierung
  2. Verschachtelte Module (gewählt)
    • Pro: Namespace-Organisation, IntelliSense-Support
    • Contra: Mehr Boilerplate
  3. Type Provider für CSS
    • Pro: Automatisch generiert
    • Contra: Komplexes Setup, Runtime-Dependency

Die Lösung: Tokens.fs

Ich erstellte eine hierarchische Modul-Struktur:

module Client.DesignSystem.Tokens

module Colors =
    let neonGreen = "text-neon-green"
    let neonOrange = "text-neon-orange"
    let neonTeal = "text-neon-teal"
    // ...

module Backgrounds =
    let void' = "bg-[#0a0a0f]"
    let dark = "bg-base-100"
    let surface = "bg-base-200"
    // ...

module Glows =
    let green = "shadow-glow-green"
    let orange = "shadow-glow-orange"
    // ...

module Animations =
    let fadeIn = "animate-fade-in"
    let slideUp = "animate-slide-up"
    let neonPulse = "animate-neon-pulse"

F#-spezifische Herausforderung: Reservierte Schlüsselwörter

F# hat Schlüsselwörter wie base, void, fixed, die ich als Token-Namen verwenden wollte. Die Lösung:

// FALSCH: Compiler-Fehler
let base = "text-base"
let void = "bg-void"
let fixed = "fixed"

// RICHTIG: Umbenennung oder Backticks
let body = "text-base"      // base -> body
let void' = "bg-[#0a0a0f]"  // void -> void' mit Apostroph
let fixed' = "fixed"        // fixed -> fixed' mit Apostroph

Architekturentscheidung: Warum Module statt Discriminated Unions?

Eine Alternative wäre gewesen:

type NeonColor = Green | Orange | Teal | Purple | Pink | Red

let colorClass = function
    | Green -> "text-neon-green"
    | Orange -> "text-neon-orange"
    // ...

Ich entschied mich dagegen, weil:

  1. Direkter CSS-Zugriff: Im View-Code will man Colors.neonTeal, nicht colorClass NeonColor.Teal
  2. Kombination von Klassen: $"{Colors.neonTeal} {Glows.teal}" ist lesbarer als verschachtelte Funktionsaufrufe
  3. IntelliSense: Module zeigen alle verfügbaren Werte sofort an

Herausforderung 3: Layout Primitives - Container, Stack, Grid

Das Problem

Responsive Layouts erforderten überall ähnlichen Code:

Html.div [
    prop.className "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
    prop.children [ ... ]
]

Die Lösung: Primitives.fs

module Client.DesignSystem.Primitives

module Grid =
    let cols1 children =
        Html.div [
            prop.className "grid grid-cols-1 gap-4"
            prop.children children
        ]

    let cols2 children =
        Html.div [
            prop.className "grid grid-cols-1 md:grid-cols-2 gap-4"
            prop.children children
        ]

    let cols3 children =
        Html.div [
            prop.className "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
            prop.children children
        ]

    let autoFit (minWidth: int) children =
        Html.div [
            prop.className $"grid gap-4"
            prop.style [ style.custom ("gridTemplateColumns", $"repeat(auto-fit, minmax({minWidth}px, 1fr))") ]
            prop.children children
        ]

module Stack =
    let xs children =
        Html.div [ prop.className "flex flex-col gap-1"; prop.children children ]
    let sm children =
        Html.div [ prop.className "flex flex-col gap-2"; prop.children children ]
    let md children =
        Html.div [ prop.className "flex flex-col gap-4"; prop.children children ]
    let lg children =
        Html.div [ prop.className "flex flex-col gap-6"; prop.children children ]

Verwendung im View-Code:

// Vorher:
Html.div [
    prop.className "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
    prop.children [ statsCard1; statsCard2; statsCard3 ]
]

// Nachher:
Grid.cols3 [ statsCard1; statsCard2; statsCard3 ]

Warum Funktionen statt Komponenten mit Props?

In React-Land würde man schreiben:

<Grid cols={3} gap="md">{children}</Grid>

In F#/Feliz ist der funktionale Ansatz idiomatischer:

Grid.cols3 children

Vorteile:

  1. Keine Props-Records definieren
  2. Kürzerer Code
  3. Compiler kann Typen besser inferieren
  4. Passt zum funktionalen Paradigma

Herausforderung 4: Icon System mit SVG

Das Problem

Icons waren überall Emojis oder inline-SVGs:

Html.span [ prop.text "📊" ]  // Dashboard
Html.span [ prop.text "🔄" ]  // Sync
Html.span [ prop.text "⚙️" ]  // Settings

Emojis sind nicht konsistent zwischen Plattformen, nicht skalierbar, nicht farblich anpassbar.

Die Lösung: Icons.fs mit Heroicons

module Client.DesignSystem.Icons

type IconSize = XS | SM | MD | LG | XL
type IconColor = Default | Primary | NeonGreen | NeonOrange | NeonTeal | NeonPurple | NeonPink | NeonRed | Success | Warning | Error | Info

let private sizeClass = function
    | XS -> "w-3 h-3"
    | SM -> "w-4 h-4"
    | MD -> "w-5 h-5"
    | LG -> "w-6 h-6"
    | XL -> "w-8 h-8"

let private colorClass = function
    | Default -> "text-current"
    | Primary -> "text-primary"
    | NeonGreen -> "text-neon-green"
    | NeonTeal -> "text-neon-teal"
    // ...

let dashboard (size: IconSize) (color: IconColor) =
    Svg.svg [
        svg.className $"inline-block {sizeClass size} {colorClass color}"
        svg.fill "none"
        svg.viewBox (0, 0, 24, 24)
        svg.stroke "currentColor"
        svg.strokeWidth 1.5
        svg.children [
            Svg.path [
                svg.strokeLinecap "round"
                svg.strokeLinejoin "round"
                svg.d "M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25..."
            ]
        ]
    ]

let sync (size: IconSize) (color: IconColor) = // ...
let settings (size: IconSize) (color: IconColor) = // ...

Warum Funktionen mit zwei Parametern statt Props-Record?

Ich hätte schreiben können:

type IconProps = { Size: IconSize; Color: IconColor }
let dashboard (props: IconProps) = ...

Aber:

// Mit Record (umständlich):
dashboard { Size = MD; Color = NeonTeal }

// Mit Parametern (elegant):
dashboard MD NeonTeal

Die Zwei-Parameter-Variante ist kürzer und die Reihenfolge (erst Größe, dann Farbe) ist intuitiv.


Herausforderung 5: Button Component mit Varianten

Das Problem

Buttons hatten verschiedene Styles (Primary, Secondary, Ghost, Danger), Größen, Zustände (Loading, Disabled), und optional Icons. Die Kombinatorik explodierte.

Die Lösung: Discriminated Unions + Props Record

module Client.DesignSystem.Button

type ButtonVariant = Primary | Secondary | Ghost | Danger
type ButtonSize = Small | Medium | Large
type IconPosition = Left | Right

type ButtonProps = {
    Text: string
    Variant: ButtonVariant
    Size: ButtonSize
    IsLoading: bool
    IsDisabled: bool
    OnClick: unit -> unit
    FullWidth: bool
    Icon: ReactElement option
    IconPosition: IconPosition
}

let defaults = {
    Text = ""
    Variant = Primary
    Size = Medium
    IsLoading = false
    IsDisabled = false
    OnClick = ignore
    FullWidth = false
    Icon = None
    IconPosition = Left
}

let button (props: ButtonProps) =
    let variantClass = match props.Variant with
        | Primary -> "btn-primary shadow-glow-orange hover:shadow-glow-orange/80"
        | Secondary -> "btn-ghost border border-neon-teal text-neon-teal hover:bg-neon-teal/10 hover:shadow-glow-teal"
        | Ghost -> "btn-ghost text-base-content/70 hover:text-base-content hover:bg-white/5"
        | Danger -> "btn-ghost border border-neon-red text-neon-red hover:bg-neon-red/10 hover:shadow-glow-red"

    let sizeClass = match props.Size with
        | Small -> "btn-sm min-h-[36px] md:min-h-[32px]"
        | Medium -> "min-h-[48px] md:min-h-[40px]"
        | Large -> "btn-lg min-h-[56px] md:min-h-[48px]"

    Html.button [
        prop.className $"btn {variantClass} {sizeClass} ..."
        prop.disabled (props.IsLoading || props.IsDisabled)
        prop.onClick (fun _ -> props.OnClick())
        prop.children [
            if props.IsLoading then
                Html.span [ prop.className "loading loading-spinner loading-sm" ]
            // Icon + Text rendering...
        ]
    ]

Convenience Functions für häufige Fälle:

let primary text onClick =
    button { defaults with Text = text; OnClick = onClick }

let secondary text onClick =
    button { defaults with Text = text; Variant = Secondary; OnClick = onClick }

let danger text onClick =
    button { defaults with Text = text; Variant = Danger; OnClick = onClick }

let primaryWithIcon icon text onClick =
    button { defaults with Text = text; Icon = Some icon; OnClick = onClick }

Verwendung:

// Ausführlich (für komplexe Fälle):
Button.button {
    Button.defaults with
        Text = "Save"
        Variant = Primary
        IsLoading = model.IsSaving
        OnClick = fun () -> dispatch Save
}

// Kurz (für Standard-Fälle):
Button.primary "Save" (fun () -> dispatch Save)
Button.secondary "Cancel" (fun () -> dispatch Cancel)
Button.danger "Delete" (fun () -> dispatch Delete)

Mobile Touch Targets:

Ein wichtiges Detail: Alle interaktiven Elemente haben min-h-[48px] auf Mobile. Das ist der von Apple und Google empfohlene Mindest-Touch-Target. Auf Desktop wird das auf 40px oder kleiner reduziert (md:min-h-[40px]).


Herausforderung 6: Zirkuläre Abhängigkeiten bei Navigation

Das Problem

Die Navigation-Komponente brauchte Zugriff auf den Page-Typ aus Types.fs. Aber Types.fs wird vor dem DesignSystem-Ordner kompiliert. F# kompiliert strikt in einer Reihenfolge - keine Vorwärtsreferenzen möglich.

src/Client/Types.fs          <- definiert Page
src/Client/DesignSystem/Navigation.fs  <- braucht Page
src/Client/State.fs          <- braucht Types.fs

Aber der DesignSystem-Ordner sollte unabhängig sein, ohne Abhängigkeit zu Types.fs!

Die Optionen

  1. DesignSystem nach Types.fs kompilieren
    • Pro: Funktioniert
    • Contra: DesignSystem wird von Types abhängig
  2. Interface mit generischem Page-Typ
    • Pro: Lose Kopplung
    • Contra: Komplexer, mehr Boilerplate
  3. Eigenen NavPage-Typ in Navigation.fs definieren (gewählt)
    • Pro: DesignSystem bleibt unabhängig
    • Contra: Type-Conversion nötig

Die Lösung: Parallele Typen + Conversion Functions

In Navigation.fs:

module Client.DesignSystem.Navigation

/// Page identifiers for navigation
/// Note: This mirrors Types.Page but is defined here to avoid circular dependencies
type NavPage =
    | Dashboard
    | SyncFlow
    | Rules
    | Settings

type NavItem = {
    Page: NavPage
    Label: string
    Icon: IconSize -> IconColor -> ReactElement
}

let navigation (currentPage: NavPage) (onNavigate: NavPage -> unit) =
    // ...

In View.fs (kompiliert nach Types.fs UND DesignSystem):

module View

open Types
open Client.DesignSystem

/// Convert Types.Page to Navigation.NavPage
let private toNavPage (page: Page) : Navigation.NavPage =
    match page with
    | Dashboard -> Navigation.Dashboard
    | SyncFlow -> Navigation.SyncFlow
    | Rules -> Navigation.Rules
    | Settings -> Navigation.Settings

/// Convert Navigation.NavPage to Types.Page
let private fromNavPage (navPage: Navigation.NavPage) : Page =
    match navPage with
    | Navigation.Dashboard -> Dashboard
    | Navigation.SyncFlow -> SyncFlow
    | Navigation.Rules -> Rules
    | Navigation.Settings -> Settings

let view (model: Model) (dispatch: Msg -> unit) =
    Navigation.appWrapper [
        Navigation.navigation
            (toNavPage model.CurrentPage)
            (fun navPage -> dispatch (NavigateTo (fromNavPage navPage)))
        // ...
    ]

Architekturentscheidung: Warum ist das besser als direkte Abhängigkeit?

  1. Isolation: Das DesignSystem kann in anderen Projekten wiederverwendet werden
  2. Testbarkeit: Navigation kann unabhängig getestet werden
  3. Explizite Grenzen: Die Conversion Functions dokumentieren die Schnittstelle
  4. Compile-Time Safety: F# erzwingt, dass alle Cases gemappt werden

Herausforderung 7: Toast Component ohne Type-Dependency

Das Problem

Ähnlich wie bei Navigation: Die Toast-Komponente brauchte ToastType aus Types.fs, aber sollte unabhängig bleiben.

Die Lösung: Eigener ToastVariant + Tuple-basierte API

module Client.DesignSystem.Toast

type ToastVariant = Success | Error | Warning | Info

/// Render a list of toasts
/// Takes tuples of (id, message, variant) for flexibility
let renderList
    (toasts: (System.Guid * string * ToastVariant) list)
    (onDismiss: System.Guid -> unit) =

    if List.isEmpty toasts then Html.none
    else
        Html.div [
            prop.className "fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm"
            prop.children [
                for (id, message, variant) in toasts do
                    toast variant message (fun () -> onDismiss id)
            ]
        ]

Warum Tuples statt Records?

// Record-Variante (mehr Boilerplate):
type ToastData = { Id: Guid; Message: string; Variant: ToastVariant }
let renderList (toasts: ToastData list) = ...

// Verwendung:
Toast.renderList
    (model.Toasts |> List.map (fun t -> { Id = t.Id; Message = t.Message; Variant = toToastVariant t.Type }))

// Tuple-Variante (kürzer):
let renderList (toasts: (Guid * string * ToastVariant) list) = ...

// Verwendung:
Toast.renderList
    (model.Toasts |> List.map (fun t -> (t.Id, t.Message, toToastVariant t.Type)))

Bei nur 3 Feldern sind Tuples akzeptabel und kürzer. Bei mehr Feldern wäre ein Record besser für Lesbarkeit.


Herausforderung 8: Money Component - Positive/Negative Farbcodierung

Das Problem

Geldbeträge sollten visuell unterscheidbar sein:

  • Positive Beträge: Neon-Grün mit optionalem Glow
  • Negative Beträge: Neon-Rot

Die Lösung: Conditional Styling mit F# Pattern Matching

module Client.DesignSystem.Money

type GlowStyle = NoGlow | GlowPositive | GlowAll

type MoneyProps = {
    Amount: decimal
    Currency: string
    Size: MoneySize
    Glow: GlowStyle
    ShowSign: bool
    ShowCurrency: bool
}

let money (props: MoneyProps) =
    let isPositive = props.Amount >= 0m

    let colorClass =
        if isPositive then "text-neon-green"
        else "text-neon-red"

    let glowClass = match props.Glow with
        | NoGlow -> ""
        | GlowPositive -> if isPositive then "text-glow-green" else ""
        | GlowAll -> if isPositive then "text-glow-green" else "text-glow-red"

    let signPrefix =
        if props.ShowSign then (if isPositive then "+" else "")
        else ""

    let formattedAmount =
        $"{signPrefix}{props.Amount:N2}"

    let currencySuffix =
        if props.ShowCurrency then $" {props.Currency}"
        else ""

    Html.span [
        prop.className $"font-mono font-semibold {sizeClass props.Size} {colorClass} {glowClass}"
        prop.text $"{formattedAmount}{currencySuffix}"
    ]

Design-Entscheidung: Monospace Font

Geldbeträge verwenden font-mono (JetBrains Mono), weil:

  1. Ziffern haben gleiche Breite - Zahlen in Listen/Tabellen alignen perfekt
  2. Professionelles Finance-UI-Gefühl
  3. Bessere Lesbarkeit bei schnellem Scannen

Ergebnis: View.fs - Von 240 auf 76 Zeilen

Die alte View.fs hatte ~240 Zeilen mit inline Navigation, Toast-Rendering, und Styling. Die neue Version:

module View

open Feliz
open State
open Types
open Client.DesignSystem

// Type Conversions (15 Zeilen)
let private toNavPage (page: Page) : Navigation.NavPage = ...
let private fromNavPage (navPage: Navigation.NavPage) : Page = ...
let private toToastVariant (toastType: ToastType) : Toast.ToastVariant = ...

// Main View (25 Zeilen)
let view (model: Model) (dispatch: Msg -> unit) =
    Navigation.appWrapper [
        Navigation.navigation
            (toNavPage model.CurrentPage)
            (fun navPage -> dispatch (NavigateTo (fromNavPage navPage)))

        Navigation.pageContent [
            match model.CurrentPage with
            | Dashboard -> Components.Dashboard.View.view model.Dashboard (DashboardMsg >> dispatch) ...
            | SyncFlow -> Components.SyncFlow.View.view model.SyncFlow (SyncFlowMsg >> dispatch) ...
            | Rules -> Components.Rules.View.view model.Rules (RulesMsg >> dispatch)
            | Settings -> Components.Settings.View.view model.Settings (SettingsMsg >> dispatch)
        ]

        Toast.renderList
            (model.Toasts |> List.map (fun t -> (t.Id, t.Message, toToastVariant t.Type)))
            (fun id -> dispatch (DismissToast id))
    ]

Das ist eine 68% Reduktion der Zeilen bei besserer Lesbarkeit und Type-Safety.


Statistiken

Dateien erstellt:

  • 14 neue F#-Dateien im DesignSystem-Ordner
  • ~130KB neuer Code

Komponenten:

  • Tokens.fs - 10 Module mit Design Tokens
  • Primitives.fs - 9 Layout-Primitives
  • Icons.fs - 22 SVG Icons + Spinner
  • Button.fs - 4 Varianten, 3 Größen, Loading/Disabled States
  • Card.fs - 4 Varianten, 3 Größen, Header/Body/Footer
  • Badge.fs - 7 Farben, 3 Styles, 3 Größen
  • Input.fs - Text, Password, Select, Textarea, Checkbox, Toggle
  • Stats.fs - Stat Cards mit Trends und Akzenten
  • Money.fs - Geldbetrags-Anzeige mit Farbcodierung
  • Table.fs - Responsive Tabellen mit Mobile Card-View
  • Loading.fs - Spinner, Skeletons, Progress
  • Toast.fs - Benachrichtigungen
  • Modal.fs - Dialoge
  • Navigation.fs - Desktop Top-Nav + Mobile Bottom-Nav

Build & Tests:

  • Build: 0 Warnings, 0 Errors
  • Tests: 121/121 bestanden (115 Unit + 6 skipped Integration)

Milestones abgeschlossen:

  • R0: Theme Configuration
  • R1: Design Tokens & Primitives
  • R2: Core UI Components
  • R3: Data Display Components
  • R4: Feedback & Navigation

Lessons Learned

1. F# erzwingt gutes Design durch Kompilierreihenfolge

Was in JavaScript ein Runtime-Problem wäre (zirkuläre Imports), ist in F# ein Compile-Error. Das zwingt dich, über Abhängigkeiten nachzudenken und saubere Schnittstellen zu definieren.

2. Discriminated Unions > Boolean Flags

Statt:

let button isPrimary isLoading isDisabled = ...

Besser:

type ButtonVariant = Primary | Secondary | Ghost | Danger
let button variant isLoading isDisabled = ...

Der Compiler verhindert button true true false - was ist was?

3. Convenience Functions sind essentiell

Ein Design System ist nur nützlich, wenn es einfach zu benutzen ist. Button.primary "Save" onClick ist besser als ein 10-Zeilen Props-Record für den häufigsten Fall.

4. Mobile-First ist nicht optional

48px Touch-Targets, Safe Area Padding, Bottom-Navigation auf Mobile - das muss von Anfang an eingeplant werden, nicht nachträglich hinzugefügt.

5. Type Conversions an der Grenze, nicht überall

Statt den gesamten Code mit Conversions zu verunreinigen, definiere klare Grenzen (hier: View.fs) wo Typen konvertiert werden. Der Rest des Codes arbeitet mit dem “richtigen” Typ für seinen Kontext.


Nächste Schritte

Phase 2 (Component Library) ist abgeschlossen. Phase 3 steht an: Die bestehenden Views (Dashboard, Settings, SyncFlow, Rules) müssen jetzt die neuen Komponenten nutzen. Das wird weitere ~4 Milestones erfordern, aber der schwierige Teil - die Grundlagen - ist gelegt.

Das Design System ist bereit. Zeit, es zu benutzen.


Key Takeaways für Neulinge

  1. Baue von den Fundamenten aufwärts: Tokens → Primitives → Components → Views. Jede Schicht baut auf der vorherigen auf.

  2. Nutze F#s Typsystem: Discriminated Unions für Varianten, Records für Props, Module für Namespacing. Der Compiler ist dein Freund.

  3. Halte Komponenten unabhängig: Wenn du zirkuläre Abhängigkeiten hast, definiere lokale Typen und konvertiere an den Grenzen. Das macht Komponenten wiederverwendbar und testbar.