DaisyUI raus, Prototyp rein: Eine CSS-Framework-Migration in einer F# Elmish App
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:
- DaisyUI als Tailwind-Plugin (liefert
.btn,.card,.modal-box,.toggleetc.) - Ein F# DesignSystem (
src/Client/DesignSystem/) mit 18 Modulen, die DaisyUI-Klassen in F#-Funktionen wrappen - 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:
-
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.
-
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.
-
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-100 → bg-surface-card, text-base-content → text-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
- Konsistenz: Alle Seiten nutzen jetzt dasselbe Token-System. Keine Mischung aus DaisyUI-HSL und Custom-Hex mehr.
- Bundle-Groesse: DaisyUI’s Komponentenbibliothek ist raus. ~50% weniger CSS.
- Kontrolle: Jede CSS-Klasse ist jetzt explizit definiert, nicht von einem Framework generiert.
- Prototyp-Naehe: Settings, Rules, und SyncFlow sehen jetzt aus wie der Prototyp.
- Kein Dashboard-Umweg: SyncFlow ist direkt die Startseite.
Key Takeaways
-
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.
-
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.
-
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 buildunddotnet test. Nur ein systematischer Review hat sie gefunden.