DaisyUI raus, Prototyp rein: Eine CSS-Framework-Migration in einer F# Elmish App

Heute habe ich BudgetBuddys gesamtes CSS-Framework ausgetauscht. DaisyUI 5.5.5 raus, reines Custom CSS mit CSS-Variablen rein. 42 Dateien, ~1750 Zeilen geaendert, ~1550 geloescht. Und am Ende ein automatisierter Code-Review, der drei kritische Bugs gefunden hat, die ich uebersehen hatte.

Ausgangslage: Warum DaisyUI weg musste

BudgetBuddy hatte ein interessantes Schichtenproblem. Es gab:

  1. DaisyUI als Tailwind-Plugin (liefert .btn, .card, .modal-box, .toggle etc.)
  2. Ein F# DesignSystem (src/Client/DesignSystem/) mit 18 Modulen, die DaisyUI-Klassen in F#-Funktionen wrappen
  3. Einen HTML-Prototypen (prototypes/unified-responsive-v1.html) der das Ziel-Design definiert — komplett ohne DaisyUI

Der Prototyp war Mobile-First designt mit eigenem Token-System: CSS-Variablen fuer Farben (--bg-card: #111128), Radien (--radius-sm: 8px), Easing-Kurven (--ease-out-quint) und Animationen. DaisyUI hatte eigene HSL-Variablen (--b1, --p, --su), ein Theme-System ([data-theme="dark"]), und eigene Komponenten-Klassen. Die zwei Welten existierten parallel und kollidierten an mehreren Stellen.

Das Ergebnis: Settings-Formulare sahen anders aus als der SyncFlow (der bereits ans Prototyp-Design angepasst war). Die Rules-Seite verwendete DaisyUI-Dropdowns, waehrend der Rest Custom-CSS nutzte. Toggles waren mal DaisyUI-Slider, mal Prototype-Checkboxen.

Herausforderung 1: Die Migrationsstrategie — Alles auf einmal oder inkrementell?

Das Problem

DaisyUI ist kein einfaches Stylesheet das man loescht. Es liefert Basis-Styles fuer .btn, .card, .badge, .input, .select etc. Entfernt man das Plugin, haben alle diese Klassen ploetzlich null Styling. Die App waere sofort kaputt.

Gleichzeitig verwendet der F#-Code diese Klassen hundertfach — nicht direkt, sondern durch die DesignSystem-Abstraktionsschicht. Button.primary generiert intern "btn btn-primary bg-gradient-to-br from-neon-orange...". Die DaisyUI-Klassen sind also eingekapselt, aber trotzdem ueberall.

Die Loesung: 5-Phasen-Migration mit Parallelitaet

Ich habe mich fuer eine additive Migration entschieden:

Phase 0: CSS-Variablen und neue Komponenten-Klassen zum bestehenden Stylesheet hinzufuegen — DaisyUI bleibt drin. Die neuen Klassen (.input-field, .select-field, .proto-toggle) existieren parallel.

Phase 1-2: Die 15+ DesignSystem-F#-Dateien migrieren — jetzt referenzieren sie die neuen Klassen statt DaisyUI. Da die DesignSystem-Module eine Abstraktionsschicht sind, aendert sich fuer die Component-Views nichts.

Phase 3: Die Component-Views (Settings, Rules, Dashboard, SyncFlow) aktualisieren — hier waren DaisyUI-Klassen “durchgesickert”, also direkt verwendet statt ueber das DesignSystem.

Phase 4: DaisyUI entfernen — jetzt sicher, weil nichts mehr darauf referenziert.

Phase 5: Verifikation.

Der entscheidende Trick: In Phase 0 koexistieren beide Systeme. Die neuen CSS-Klassen stehen am Ende des Stylesheets und gewinnen durch die CSS-Kaskade bei Namenskollisionen (.btn, .card, .badge). Aber da der F#-Code noch die alten Klassen referenziert, bleibt alles funktional.

/* Phase 0: Neue Klassen hinzufuegen, DaisyUI bleibt */
.input-field {
  width: 100%;
  padding: 10px 14px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 14px;
  transition: border-color var(--duration-fast) ease,
              box-shadow var(--duration-fast) ease;
}
.input-field:focus {
  border-color: var(--color-neon-teal);
  box-shadow: 0 0 0 2px var(--neon-teal-glow);
}

Herausforderung 2: Das Token-Mapping — Zwei Farbsysteme vereinen

Das Problem

DaisyUI nutzt HSL-Variablen: hsl(var(--b1)) fuer den Hintergrund, hsl(var(--bc)) fuer Text. Der Prototyp nutzt direkte Hex/RGBA-Werte: --bg-card: #111128, --text-primary: #e8e8f0.

Tailwind CSS 4 hat ein @theme-System, das CSS-Variablen zu Utility-Klassen mapped. Also --color-surface-card: var(--bg-card) erzeugt automatisch die Klasse bg-surface-card. Das war die Bruecke.

Die Loesung: Dreischichtige Token-Architektur

:root CSS-Variablen  →  @theme Tailwind-Mapping  →  F# Token-Strings
--bg-card: #111128   →  --color-surface-card     →  "bg-surface-card"
--text-primary       →  --color-text-primary      →  "text-text-primary"
--border             →  --color-border-default    →  "border-border-default"

Das Mapping in styles.css:

@theme {
  --color-surface-app: var(--bg-app);
  --color-surface-card: var(--bg-card);
  --color-surface-elevated: var(--bg-elevated);
  --color-surface-input: var(--bg-input);
  --color-surface-hover: var(--bg-hover);
  --color-border-default: var(--border);
  --color-border-subtle: var(--border-subtle);
  --color-text-primary: var(--text-primary);
  --color-text-secondary: var(--text-secondary);
  --color-text-muted: var(--text-muted);
}

Und in Tokens.fs:

module Colors =
    let textPrimary = "text-text-primary"    // war: "text-base-content"
    let textSecondary = "text-text-secondary" // war: "text-base-content/70"
    let textMuted = "text-text-muted"         // war: "text-base-content/60"

module Backgrounds =
    let dark = "bg-surface-card"      // war: "bg-base-100"
    let surface = "bg-surface-elevated" // war: "bg-base-200"
    let elevated = "bg-surface-input"   // war: "bg-base-300"

Warum diese Indirektion? Weil der F#-Code Token-Namen wie Backgrounds.dark verwendet, nicht CSS-Klassen direkt. Aendert sich das Farbschema, aendere ich eine Zeile in Tokens.fs — nicht 150 Stellen im Code.

Ein schoener Nebeneffekt: Der SyncFlow hatte bereits --sf-*-Variablen die identisch zu den Prototyp-Tokens waren (gleicher Designer, gleicher Prototyp). Diese konnte ich einfach als Aliase auf die neuen Root-Tokens umbiegen:

:root {
  --sf-bg-card: var(--bg-card);    /* war: #111128 (hardcoded) */
  --sf-border: var(--border);       /* war: #2a2a4a (hardcoded) */
  /* ... */
}

Herausforderung 3: Der Toggle-Rewrite — Von Slider zu Checkbox

Das Problem

DaisyUI hat ein Slider-Toggle (wie iOS-Switch): eine Kapsel mit Kreis der hin- und herschiebt. Der Prototyp zeigt ein quadratisches 20x20px Kaestchen mit SVG-Checkmark. Zwei komplett verschiedene HTML-Strukturen:

DaisyUI (vorher):

<input type="checkbox" class="toggle toggle-sm [--tglbg:...]" />

Prototyp (nachher):

<label class="proto-toggle">
  <input type="checkbox" />
  <span class="toggle-track">
    <svg class="toggle-check" viewBox="0 0 10 10">
      <path d="M2 5l2.5 2.5L8 3" />
    </svg>
  </span>
</label>

Warum ein neues CSS-Pattern statt DaisyUI-Override?

Ich haette DaisyUI’s .toggle per CSS umstylen koennen. Aber das waere fragil — DaisyUI erwartet eine bestimmte HTML-Struktur fuer den Slider, und ein visueller Override zu einem Checkbox-Look wuerde die interne Logik unterwandern.

Stattdessen: Eigene CSS-Klasse .proto-toggle mit eigener HTML-Struktur. In F# wird das zu einer Feliz-Komposition:

let toggle (isChecked: bool) (onChange: bool -> unit) (label: string option) =
    Html.label [
        prop.className "flex items-center gap-3 cursor-pointer"
        prop.children [
            Html.label [
                prop.className "proto-toggle"
                prop.onClick (fun e -> e.stopPropagation())
                prop.children [
                    Html.input [
                        prop.type' "checkbox"
                        prop.isChecked isChecked
                        prop.onChange (fun (e: Browser.Types.Event) ->
                            let target = e.target :?> Browser.Types.HTMLInputElement
                            onChange target.``checked``)
                    ]
                    Html.span [
                        prop.className "toggle-track"
                        prop.children [
                            Svg.svg [
                                svg.className "toggle-check"
                                svg.viewBox(0, 0, 10, 10)
                                svg.children [
                                    Svg.path [
                                        svg.d "M2 5l2.5 2.5L8 3"
                                        svg.fill "none"
                                        svg.stroke "currentColor"
                                        svg.strokeWidth 2
                                    ]
                                ]
                            ]
                        ]
                    ]
                ]
            ]
            // Optional label text
            match label with
            | Some l -> Html.span [ prop.className "text-text-primary"; prop.text l ]
            | None -> ()
        ]
    ]

Das CSS dazu nutzt die Prototyp-Animationstokens:

.proto-toggle input:checked + .toggle-track {
  background: var(--color-neon-teal);
  border-color: var(--color-neon-teal);
}
.proto-toggle .toggle-check {
  opacity: 0;
  transform: scale(0.5);
  transition: opacity var(--duration-micro) var(--ease-out-cubic),
              transform var(--duration-fast) var(--ease-out-quint);
}
.proto-toggle input:checked + .toggle-track .toggle-check {
  opacity: 1;
  transform: scale(1);
}

Das Ergebnis: Ein knackiges Check-Haekchen das mit ease-out-quint reinzoomt. Subtil, aber es fuehlt sich gut an.

Herausforderung 4: Design-Entscheidungen — Was aus dem Prototyp uebernehmen, was nicht?

Die drei kritischen Fragen

Der Prototyp definiert das Ziel-Design, aber nicht jede Entscheidung war 1:1 uebertragbar:

  1. Primary-Button-Farbe: Der Prototyp hat Teal-to-Green Gradient, die App hat Orange. Entscheidung: Orange beibehalten — das ist die Markenfarbe fuer CTAs in BudgetBuddy.

  2. Settings als Read-Only: Der Prototyp zeigt Settings nur als Anzeige (Werte, kein Edit-Modus). Die App hat Edit-Formulare die funktional noetig sind. Entscheidung: Edit-Modus beibehalten, aber die Read-Only-Darstellung und die Formulare ans neue Design anpassen.

  3. Toggle-Style: Slider vs. Checkbox. Entscheidung: Checkbox wie Prototyp — kompakter, passt besser zum dichten Transaction-Layout.

Diese Entscheidungen habe ich bewusst vor der Implementierung geklaert (per interaktiver Rueckfrage). Das hat verhindert, dass ich die halbe App umbaue und dann hoere “nee, Orange sollte bleiben”.

Herausforderung 5: Parallelisierung — 15+ Dateien gleichzeitig migrieren

Das Problem

Die DesignSystem-Migration betraf 18 Dateien mit aehnlichen Aenderungen: bg-base-100bg-surface-card, text-base-contenttext-text-primary, usw. Jede Datei einzeln durchzugehen waere monoton und fehleranfaellig.

Die Loesung: Parallele Agenten mit klarem Mapping

Ich habe die Arbeit auf mehrere spezialisierte Agenten aufgeteilt, die gleichzeitig laufen:

  • Agent 1: Card.fs, Badge.fs, Table.fs, Stats.fs (Daten-Display-Komponenten)
  • Agent 2: Loading.fs, Toast.fs, ErrorDisplay.fs, PageHeader.fs, Money.fs, Icons.fs, Primitives.fs, Navigation.fs, Modal.fs, BottomSheet.fs, Form.fs (Rest)
  • Agent 3: Settings/View.fs (DaisyUI-Leaks in Formularen)
  • Agent 4: Rules/View.fs (DaisyUI-Dropdown-Struktur)
  • Agent 5: Dashboard/View.fs, SyncFlow Views (verstreute Farbreferenzen)

Jeder Agent bekam dasselbe Token-Mapping und arbeitete mit Serena’s symbolischen Code-Tools (LSP-basiert). Das Ergebnis: Alle 15+ DesignSystem-Dateien in einem Durchgang migriert, danach ein dotnet build — 0 Fehler.

Herausforderung 6: Code-Review entdeckt drei kritische Bugs

Das Problem

Nach der Migration lief dotnet build und dotnet test fehlerfrei (457 Tests pass). Alles gut? Nein.

Ich habe einen automatisierten Code-Review-Agenten ueber alle 42 geaenderten Dateien laufen lassen. Sein Ergebnis: 3 kritische Issues.

Die drei Bugs

1. Loading.fs — Vergessene DaisyUI-Spinner

Die spinner-Funktion war korrekt auf proto-spinner migriert. Aber ring und dots (alternative Spinner-Varianten) verwendeten noch loading loading-ring und loading loading-dots. Mit DaisyUI entfernt: unsichtbare Spinner.

2. Input.fs — Unsichtbare Checkboxen

Die checkbox und checkboxSimple Funktionen hatten noch checkbox checkbox-sm [--chkbg:...] [--chkfg:...] — reine DaisyUI-Klassen mit internen CSS-Custom-Properties. Ohne DaisyUI: nackte Browser-Checkboxen ohne Styling.

3. ErrorDisplay.fs — Fehlende Fehler-Farben

text-error, bg-error/20, bg-error/5 — die error-Farbe war von DaisyUI definiert. Ohne DaisyUI: weisser Text auf transparentem Hintergrund. Fehleranzeigen waeren unsichtbar gewesen.

Warum ich das uebersehen habe

Alle drei Bugs haben eine Gemeinsamkeit: dotnet build kann sie nicht finden. Es sind CSS-Klassen in String-Literalen — der Compiler sieht nur "loading loading-ring" als gueltigen String. Erst ein Code-Review (oder visuelles Testing) findet solche Brueche.

Die Fixes

// Loading.fs: ring/dots → proto-spinner
let ring size color = spinner size color  // Gleicher Spinner, anderer Name

// Input.fs: DaisyUI checkbox → proto-toggle Pattern
let checkbox isChecked onChange label =
    Html.label [
        prop.className "proto-toggle"
        // ... SVG checkmark structure (same as toggle)
    ]

// ErrorDisplay.fs: error → neon-red
// text-error → text-neon-red
// bg-error/20 → bg-neon-red/20
// border-error → border-neon-red

Lesson Learned

String-basierte CSS-Klassen sind eine Schwachstelle in F#-Frontends. Der Compiler schuetzt dich bei Types, Pattern Matching, und API-Aufrufen. Aber prop.className "loading loading-spinner" ist fuer ihn nur ein String. Hier ist ein Code-Review-Schritt nach CSS-Migrationen essentiell.

Ein moeglicher Ansatz fuer die Zukunft: Alle CSS-Klassen als F#-Konstanten in Tokens.fs definieren und niemals rohe Strings in Views verwenden. Dann wuerde ein Rename in Tokens.fs einen Compile-Error in jeder View erzeugen.

Herausforderung 7: MVU-Integritaet bei grossflaechigen Aenderungen

Das Problem

Bei 42 geaenderten Dateien besteht das Risiko, dass die MVU-Architektur (Model-View-Update) verletzt wird — versehentlich Side-Effects in Views, mutable State ausserhalb des Models, oder Business-Logik in View-Dateien.

Der Check

Ein zweiter Review-Durchlauf, diesmal fokussiert auf MVU-Regeln:

  • Views muessen pure Functions vom Model sein
  • Alle State-Aenderungen gehen durch Msg → update
  • Side-Effects nur via Cmd
  • Model als Single Source of Truth

Ergebnis: Keine Verletzungen. Aber der Review hat etwas Wichtiges gefunden: Der ForceImportDuplicates-Button war waehrend des Redesigns verschwunden. Die Msg und der Handler existierten noch in Types.fs und State.fs, aber der UI-Trigger war weg.

Das ist kein MVU-Bug, aber ein Feature-Bug: Wenn ein User in YNAB eine Transaktion loescht und sie neu importieren will, braucht er diesen Button. Er wurde in den expanded Action-Chips der TransactionRow wiederhergestellt — sichtbar nur bei Duplikat-Transaktionen.

Ergebnis

Zahlen

Metrik Vorher Nachher
CSS-Dependencies Tailwind + DaisyUI Tailwind only
CSS-Bundle ~200KB+ 93KB
Navigation-Tabs 4 (Dashboard, Sync, Rules, Settings) 3 (Sync, Rules, Settings)
DaisyUI-Klassen in F# ~150+ Referenzen 0
Tests 460 pass 457 pass (3 Dashboard-Tests entfernt)
Dateien geaendert 42

Was sich verbessert hat

  1. Konsistenz: Alle Seiten nutzen jetzt dasselbe Token-System. Keine Mischung aus DaisyUI-HSL und Custom-Hex mehr.
  2. Bundle-Groesse: DaisyUI’s Komponentenbibliothek ist raus. ~50% weniger CSS.
  3. Kontrolle: Jede CSS-Klasse ist jetzt explizit definiert, nicht von einem Framework generiert.
  4. Prototyp-Naehe: Settings, Rules, und SyncFlow sehen jetzt aus wie der Prototyp.
  5. Kein Dashboard-Umweg: SyncFlow ist direkt die Startseite.

Key Takeaways

  1. CSS-Framework-Migrationen brauchen eine Abstraktionsschicht. Das DesignSystem hat hier den Unterschied gemacht. Statt 150+ Views zu aendern, habe ich 18 DesignSystem-Dateien aktualisiert — der Rest hat die neuen Klassen automatisch uebernommen.

  2. Additive Migration vor subtraktiver. Erst die neuen CSS-Klassen hinzufuegen (Phase 0), dann den F#-Code umstellen (Phase 1-3), dann das alte Framework entfernen (Phase 4). Niemals gleichzeitig.

  3. Code-Review nach CSS-Migrationen ist nicht optional. Der Compiler hilft bei String-basierten CSS-Klassen nicht. Drei von drei kritischen Bugs waren “unsichtbar” fuer dotnet build und dotnet test. Nur ein systematischer Review hat sie gefunden.