Von CSS-Chaos zu F# Design System: Eine Typensichere UI-Komponentenbibliothek aufbauen
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:
- Wiederholter Code: Jede Seite hatte ihre eigene Navigation-Implementation
- Keine Konsistenz: Farben, Abstände und Animationen variierten
- Schwer wartbar: Eine Änderung am Theme erforderte Änderungen in dutzenden Dateien
- 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
- Downgrade auf Tailwind 3
- Pro: Sofort stabil
- Contra: Verpasse die neue CSS-first Konfiguration, technische Schuld
- Upgrade auf stabile Versionen (gewählt)
- Pro: Zukunftssicher, neue Features
- Contra: Migration-Aufwand
- 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:
@pluginist die neue DaisyUI 5 Syntax@themedefiniert 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
- String-Konstanten
- Pro: Einfach
- Contra: Keine logische Gruppierung
- Verschachtelte Module (gewählt)
- Pro: Namespace-Organisation, IntelliSense-Support
- Contra: Mehr Boilerplate
- 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:
- Direkter CSS-Zugriff: Im View-Code will man
Colors.neonTeal, nichtcolorClass NeonColor.Teal - Kombination von Klassen:
$"{Colors.neonTeal} {Glows.teal}"ist lesbarer als verschachtelte Funktionsaufrufe - 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:
- Keine Props-Records definieren
- Kürzerer Code
- Compiler kann Typen besser inferieren
- 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
- DesignSystem nach Types.fs kompilieren
- Pro: Funktioniert
- Contra: DesignSystem wird von Types abhängig
- Interface mit generischem Page-Typ
- Pro: Lose Kopplung
- Contra: Komplexer, mehr Boilerplate
- 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?
- Isolation: Das DesignSystem kann in anderen Projekten wiederverwendet werden
- Testbarkeit: Navigation kann unabhängig getestet werden
- Explizite Grenzen: Die Conversion Functions dokumentieren die Schnittstelle
- 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:
- Ziffern haben gleiche Breite - Zahlen in Listen/Tabellen alignen perfekt
- Professionelles Finance-UI-Gefühl
- 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 TokensPrimitives.fs- 9 Layout-PrimitivesIcons.fs- 22 SVG Icons + SpinnerButton.fs- 4 Varianten, 3 Größen, Loading/Disabled StatesCard.fs- 4 Varianten, 3 Größen, Header/Body/FooterBadge.fs- 7 Farben, 3 Styles, 3 GrößenInput.fs- Text, Password, Select, Textarea, Checkbox, ToggleStats.fs- Stat Cards mit Trends und AkzentenMoney.fs- Geldbetrags-Anzeige mit FarbcodierungTable.fs- Responsive Tabellen mit Mobile Card-ViewLoading.fs- Spinner, Skeletons, ProgressToast.fs- BenachrichtigungenModal.fs- DialogeNavigation.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
-
Baue von den Fundamenten aufwärts: Tokens → Primitives → Components → Views. Jede Schicht baut auf der vorherigen auf.
-
Nutze F#s Typsystem: Discriminated Unions für Varianten, Records für Props, Module für Namespacing. Der Compiler ist dein Freund.
-
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.