Remove JavaFX

The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main
source trees is empty, nothing imports javafx, neither classpath resolves it,
and org.openjfx is out of the build and the version catalog.

The finding that shaped the work: the JavaFX main window was built on every
start and never shown -- primaryStage's setScene and show were both commented
out. Most of those 11,655 lines were dead weight rather than a port. But not
all of them, and that is the trap this commit is really about: dead-looking
methods still fed live Compose sinks. They were cut at their seams instead of
deleted, and a sweep script that follows calls was written to find them,
because reading the code does not reveal them.

In order: the colour derivation frozen as checked-in test data, so the pin
survives JavaFX being removed instead of silently comparing against itself;
the live feeds lifted out of the never-shown window and given names and tests;
the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now
delivers to the AWT event dispatch thread, the ten Alerts are modal Compose
dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split
JavaFX did not need, and extends Application, start(Stage), stop and launch are
gone -- main builds the runtime and a shutdown hook replaces stop().

An independent review found two deadlocks in that move, both the same failure
class, both invisible to the build: a blocking wait on the event thread for
work that can only advance on the event thread. The profile picker waited on a
latch and froze the client for good; ComposeWindowHost.close waited on
Thread.join and returned with the window still marked open, which made a
profile switch raise the dead runtime's window and leave the rebuilt profile
without its Connect button. A third came out of fixing them: a ComposeDialog
built off the event thread cannot create its GL context and never paints.

Three defects only a GUI run showed, never the build: an NPE on a deleted
field, a Compose menu still hanging on deleted MenuItems, and a category
bookkeeping field that looked dead but is read by the outgoing-message category
resolution -- deleting it would have sent messages in the wrong category.

The binding docs and the manual no longer describe a JavaFX client.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:36:01 +02:00
1 parent cc8c90204e
commit fe4215bce9
70 files changed
+11228 -15201

No files matched your search

@@ -0,0 +1,668 @@
# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird
Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`.
Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`.
> **Teil 4 abgeschlossen am 2026-10-03.** Etappe 4 und 5 der Spezifikation sind in einem
> Zug umgesetzt. `UiDispatcher` liegt jetzt auf dem AWT-Ereignisfaden (`AwtUiDispatcher`, 5
> Vertragstests), `JavaFxUiDispatcher` ist gelöscht. Alle zehn `Alert`-Aufrufe sind
> Compose-Dialoge (`ComposeAlert`: `show`/`acknowledge`/`confirm`/`showWithLink`, modal über
> `ComposeDialog`, damit die vier blockierenden ihre `showAndWait`-Semantik behalten).
> `getHostServices().showDocument` → `ExternalDocuments` auf `java.awt.Desktop`, mit der
> Schema-Entscheidung Mail gegen Web, die JavaFX nicht brauchte. `javafx.stage.Screen` →
> `ScreenBounds` auf `GraphicsEnvironment`. Der Lebenszyklus ist frei: kein
> `extends Application`, kein `init`/`start(Stage)`/`stop`, kein `launch`; `main` liest die
> Kommandozeile selbst und ruft das neue `startRuntime()`, ein Abschalthaken ersetzt `stop()`.
> `ApplicationRuntimeLauncher`, `LayoutAutosave` und `GuiUtils` sind JavaFX-frei.
> **Bilanz:** Dateien mit `javafx.` unter `src/main` von 16 auf **11**, `javafx`-Importe in
> `Kst4ContestApplication` von 41 auf **19**, Datei von 5.117 auf 4.933 Zeilen. Build grün,
> **821 Tests, 0 Fehler** (Grundlinie 800 + 21 neue).
>
> **Zwei harte Lehren aus Teil 4:**
>
> 1. **Der grüne Build sah den Startabbruch nicht.** Der erste Start nach dem Wegfall von
> `launch()` starb mit `IllegalStateException: Toolkit not initialized` in
> `Kst4ContestApplication.<init>` — über `javafx.scene.control.Control.<clinit>`. Ursache:
> die Klasse hält noch **19 abgehängte JavaFX-Steuerelemente** als Wertbehälter
> (`txt_chatMessageUserInput` und Geschwister). Sie hängen in keiner Szene, aber das
> Erzeugen des ersten läuft durch `Control`s statischen Initialisierer, der das Toolkit
> braucht. Behelf: `startJavaFxToolkitForTheRemainingDetachedControls()` als erste Zeile in
> `main` (`Platform.startup`, `setImplicitExit(false)`). **Diese Methode fällt in Etappe 6
> mit den 19 Steuerelementen** — sie ist ausdrücklich so dokumentiert.
> 2. **Zwei `Platform.runLater`-Stellen durften *nicht* auf den Dispatcher.** Ihr Zweck ist
> das Aufschieben über den laufenden Ereignisdurchlauf hinaus, und `runOnUi` führt auf dem
> UI-Faden sofort aus — genau der Fall dort. Sie liegen jetzt auf `EventQueue.invokeLater`:
> die Fokusübergabe nach dem Vorbelegen von `/cq` und das Zurücksetzen von
> `markOperatorChatMemberSelectionIntent`. Der Kommentar an der ersten Stelle hatte das
> vorausgesehen; wer ihn überliest, baut einen Fokusverlust und eine falsch zugeordnete
> Bedienerabsicht ein.
>
> Zusätzlich: `LayoutAutosave` benutzte `javafx.animation.PauseTransition`, was ein laufendes
> JavaFX-Toolkit braucht — jetzt `javax.swing.Timer`, und die Klasse nimmt die Schreibaktion
> als `Runnable` statt der `ChatPreferences` (deren Konstruktor ins Benutzerprofil schreibt
> und darum nicht testbar war). Erstmals mit Tests: vier, darunter „ein Stoß von 20
> Ereignissen wird zu einem Schreibvorgang". **Offen (Etappe 6):** die 11 Restdateien, die 19
> Steuerelemente, `org.openjfx` aus Build und Versionskatalog.
> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`.
> **Etappe 6 abgeschlossen am 2026-10-04 — JavaFX ist raus.** Nachgewiesen, nicht
> geschlussfolgert: `grep -rl "javafx\." core/src/main app-desktop/src/main` ist leer, es
> gibt im ganzen Projekt keinen `import javafx` mehr, `:app-desktop:dependencies` zeigt
> JavaFX auf **keinem** der beiden Klassenpfade, der Build ist grün mit **849 Tests**, und
> die Anwendung läuft ohne JavaFX: Einstellungen gelesen, Datenbank verbunden, ON4KST
> verbunden, vollständige Benutzerliste, Nachrichten geparst, Frequenzerkennung — null
> Ausnahmen, kein `QuantumRenderer`-Faden. `Kst4ContestApplication`: 11.179 → **4.165**
> Zeilen.
>
> **Zwei Stellen waren lebend und mussten ersetzt werden, nicht gelöscht:**
> der 300-ms-Entpreller der Benutzerliste (`PauseTransition` → `CoalescingTrigger` auf
> `javax.swing.Timer`, fünf Tests) und `FxRosterBinding` → `RosterListBinding`
> (`ObservableList` → gewöhnliche Liste plus Rückruf; `SelectedStationMessagesFeed.push`
> nimmt ohnehin nur ein `List` und kopiert es). Beide waren vorher ungetestet.
>
> **Die Falle dieser Etappe**, und sie wäre ein lautlos falsch kategorisiertes Senden
> gewesen: `lastAutoPreparedCqTargetCallsign` und `-Category` sehen wie toter Schatten aus,
> werden aber von `resolveOutgoingChatCategory` gelesen, das Compose über
> `resolveOutgoingChatCategoryFromCompose` ruft. `ChatInputState` führt den vorbelegten
> Text selbst mit, aber **keine Kategorie** — dieser Zustand hat kein Compose-Gegenstück.
> Geschrieben wurde er nur von der Methode, die zu löschen war. Also an der Naht getrennt:
> `prepareCqTextForCallsign` → `rememberAutoPreparedCqTarget`, Kategoriebuchführung bleibt,
> Textfeld und Überschreibschutz fallen.
>
> **Zwei vorbestehende Defekte kamen dabei heraus und sind behoben:**
> (1) Niemand rief `refresh()` auf dem Compose-Spiegel der Kurztasten und Schnipsel — die
> Haken des Einstellungsfensters gingen auf den JavaFX-Knopfbau in `flwPane_textSnippets`,
> das seit dem Abklemmen `null` ist. Eine bearbeitete Kurztaste erreichte den Bediener erst
> nach einem Fensterneubau. (2) Das Schließen des Hauptfensters beendete die Anwendung
> nicht: `ComposeWindowHost` gab jedem Fenster Composes Vorgabe
> `onCloseRequest = ::exitApplication`, die nur das `application {}` dieses Fensters
> beendet — Fenster weg, Prozess lief mit offener Verbindung weiter. Von Marc gefunden,
> weil ein Tiling-Fenstermanager genau so schließt. Das Hauptfenster führt jetzt in
> `closeWindowEvent`. Mit demselben Griff kam heraus, dass die **Fenstergröße** gar nicht
> mehr gespeichert wurde — der Verzicht war mit „solange beide Fenster leben" begründet,
> und das zweite Fenster gibt es nicht mehr.
>
> **Namensfallen, an denen ein Löschen nach bloßem Namen scheitert:** `TimelineView` und
> `PathProfileChart` existierten doppelt, je einmal JavaFX und einmal Compose. Gelöscht
> wurde nur die Java-Fassung. `TimelineView.CandidateEvent` war ein reines Zwischen-DTO,
> das 1:1 in `TimelineCandidate` umkopiert wurde; der Erbauer baut jetzt direkt den
> Compose-Typ, vorher mit einem Test festgenagelt, weil drei der zehn Felder `Int` sind und
> eine Vertauschung lautlos durchgeht. Und `RosterMirror` war schon vergeben — die
> Compose-Fassung in `MainWindowState.kt`; die Java-Klasse heißt deshalb
> `RosterListBinding`.
>
> **`TableLayoutManager` bleibt**, auf `calculateInitialContentWidth` zusammengeschrumpft:
> sechs Compose-Dateien nennen die Klasse namentlich, wer dem Verweis folgt, soll die
> Regel finden und keine Lücke. **`MessageVariableResolverTest`** lag in `src/main/java`
> und wurde nie ausgeführt — verschoben, daher die Testzahl von 842 auf 848.
> **Teil 4, Prüfung und Korrekturen vom 2026-10-04.** Die Abschlussprüfung durch einen
> frischen Prüfer fand **zwei Critical-Fehler, beide dieselbe Fehlerklasse** — und beide
> waren im grünen Build unsichtbar. Behoben in `72d35f84`; Build grün, **835 Tests**.
>
> **Die Fehlerklasse, die jeder künftige Umbau hier treffen wird: ein blockierendes Warten
> auf dem Ereignisfaden.** Seit der Dispatcher dort liegt, laufen alle Menüaktionen auf dem
> AWT-Ereignisfaden. Skikos `SwingDispatcher` stellt **jede** Fortsetzung von
> `application { }` über `SwingUtilities.invokeLater` zu und überschreibt
> `isDispatchNeeded` nicht — im Bytecode nachgeprüft. Wer auf diesem Faden auf etwas
> wartet, das nur als Ereignis auf diesem Faden vorankommt, verklemmt ohne Zeitlimit.
>
> 1. **`OperatorProfilePickerWindow`** wartete auf einem `CountDownLatch`. Menü → Profil
> wechseln fror die Anwendung dauerhaft ein. Jetzt ein modaler `ComposeDialog`: der
> blockiert über eine verschachtelte Ereignisschleife und zeichnet dabei weiter.
> 2. **`ComposeWindowHost.close()`** wartete mit `Thread.join`. Jeder Fensterschalter im
> Windows-Menü fror für das Zeitlimit ein und kehrte zurück, **während das Fenster noch
> als offen galt** — womit ein Profilwechsel das Fenster der toten Laufzeit hervorhob und
> das neue Profil ohne Einstellungsfenster, also ohne Connect-Knopf, dastand. Genau der
> Defekt, den das Javadoc derselben Klasse als behoben beschreibt. Jetzt eine
> `SecondaryLoop`, deren `exit()` über `invokeLater` gepostet wird, um das dokumentierte
> exit-vor-enter-Wettrennen zu schließen.
>
> **Eine dritte Lehre, die der Fix-Durchgang selbst erzeugt hat:** ein `ComposeDialog` ist
> eine Swing-Komponente um eine Skia-Fläche und **muss auf dem Ereignisfaden erzeugt
> werden**. Vom Hauptfaden aus erzeugt, bekommt skiko keinen GL-Kontext
> (`Can't wrap nullptr` → `RenderException: Cannot init graphic context`), der Dialog
> zeichnet nie, und ein modales Warten darauf endet nie. Drei von drei Startversuchen.
> Gefunden nur durch Starten der Anwendung und einen `jcmd Thread.print`; kein Test sah es.
> Dafür gibt es jetzt `openOnEventThread` und zwei Tests. **Merksatz: der grüne Build sagt
> über Fadenzugehörigkeit nichts.**
>
> **Knopfreihenfolge:** die JavaFX-`ButtonBar`-Reihenfolgen wurden gemessen, nicht erinnert —
> `LINUX = L_HE+UNYACBXIO_R`, `MAC_OS = L_HE+U+FBIX_NCYOA_R`,
> `WINDOWS = L_E+U+FBXI_YNOCAH_R`. Der Beenden-Dialog nutzte `ButtonType.YES`, der
> Profilwechsel `OK_DONE`, und die beiden ordnen auf Linux **gegenläufig**. `ComposeAlert`
> trägt deshalb die Knopfart und ordnet nach diesen Zeichenketten. Der Prüfer hatte hier
> aus dem Gedächtnis eine macOS-Reihenfolge für Linux behauptet und das nach dem Nachmessen
> zurückgezogen — beide Seiten irrten, die Messung entschied.
>
> **Zwei offene Entscheidungen für Marc**, bewusst nicht im Fix-Durchgang erledigt: die
> Dialoge haben die Warn-/Fehler-/Info-Symbole der JavaFX-`AlertType`s verloren (Symbolwahl
> ist Gestaltung, keine Fehlerbehebung), und die vier blockierenden Dialoge sind jetzt
> `APPLICATION_MODAL`, sperren also die Eingabe in den Compose-Fenstern, solange sie offen
> sind — was der toolkit-fremde JavaFX-`Alert` nicht tat. Ebenfalls unverifiziert:
> `ScreenBounds` auf einem skalierten Bildschirm (hier ein unskalierter, beide Quellen
> lieferten denselben Wert).
> **Teil 4, Sondenergebnis vom 2026-10-03.** Ein modaler
> `androidx.compose.ui.awt.ComposeDialog` (Compose 1.8.2) hält seinen Aufrufer an **und**
> zeichnet sich dabei — gemessen von beiden Fäden: vom AWT-Ereignisfaden 1514 ms blockiert,
> komponiert; von einem Fremdfaden 1503 ms blockiert, komponiert. Damit ist die
> `showAndWait`-Semantik der vier blockierenden Dialoge erhaltbar, ohne den Ereignisfaden zu
> verklemmen. Das ist die Voraussetzung für den Dispatcher-Tausch: nach ihm laufen alle
> Menüaktionen über `ComposeMenuActions` auf dem Ereignisfaden, und das Muster von
> `OperatorProfilePickerWindow` (eigener Faden plus `CountDownLatch`) würde dort verklemmen,
> weil es genau den Faden blockiert, auf dem das neue Fenster zeichnen will. Der
> Einzelkonstruktor `ComposeDialog(Dialog.ModalityType)` existiert und wird benutzt.
> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`.
> **Teil 3 abgeschlossen am 2026-10-02.** Der gesamte durchsetzte JavaFX-Aufbau in
> `Kst4ContestApplication` ist gelöscht; die Klasse ist von 11.179 auf ~5.100 Zeilen
> geschrumpft, der Durchlauf meldet `0 of 0 init methods`. Erledigt in Scheiben: die drei
> Statusindikatoren an ihrer Naht getrennt (S1), `initMenuBar` samt macOS-Menü gelöscht
> (S2), der tote `scn_ChatwindowMainScene`-`try`-Block aus `start()` entfernt und die
> lebenden Trigger (Sked-Reminder, Zeitleisten-Listener) herausgerettet (S3), die sieben
> Tabellen-Builder, der Info-Pane-Cluster und die Snippet-Kontextmenüs gelöscht/entflochten
> (S3b–S4), und zuletzt der tote `timelineView`, die Szenen-Theming-Maschinerie
> (`registerThemedScene`/`themedScenes`) und die tote CW-/Voice-Ausgabe samt
> `javafx.scene.media`-Importen (S6). Zwei Laufzeitfunde aus GUI-Abnahmen wurden behoben:
> ein NPE auf dem gelöschten `flwpne_StatusBar` in `updateStatusButton`, und die
> Compose-Menü-Regression — die 16 Menüaktionen hingen über `ComposeMenuActions` an den
> gelöschten JavaFX-`MenuItem`s und wurden auf eigene `menuAction…`-Methoden umgestellt
> (vorgezogen aus Etappe 5). Build nach jeder Scheibe grün, 800 Tests. **Offen (Etappe
> 4–6):** `UiDispatcher`-Tausch, Lebenszyklus (`extends Application`, die zehn Alerts),
> die Restdateien und das Entfernen von `org.openjfx` aus dem Build; in
> `Kst4ContestApplication` stehen noch ~41 `javafx`-Importe.
> **Zweite Fassung.** Die erste kam aus einer Prüfung, die nur fragte „wer ruft diese
> Methode auf?". Eine Abschlussprüfung hat daran zwei kritische Fehler gefunden: zwei
> Blöcke, die als folgenlos löschbar geführt waren, speisen in Wahrheit die
> Compose-Oberfläche — die Nachrichten der ausgewählten Station und die Zeitleiste.
> Beide hätte Teil 2 gelöscht, ohne dass ein Test oder der Übersetzer etwas gemerkt
> hätte. Die Methode ist unten berichtigt; sie ist der eigentliche Inhalt dieses
> Dokuments.
## Wie geprüft werden muss
Drei Fragen pro Kandidat, nicht eine:
1. **Wer ruft ihn?** Auch aus dem Compose-Pfad heraus — die Compose-Fenster rufen in
`Kst4ContestApplication` zurück, etwa über `focusChatMemberAndPrepareCq` bei
`:10720`, `:10884` und `:10916`.
2. **Was tut er innen?** Eine 200-Zeilen-Methode, die eine JavaFX-Tabelle baut, kann
mittendrin einen Listener anmelden, der in eine **Compose**-Tabelle schreibt. Genau
das war Fehler eins.
3. **Meldet er Controller-Listener an?** Die überleben die Oberfläche, die sie
angemeldet hat, und sind oft das Einzige, was ein Compose-Fenster speist.
Frage 2 braucht einen **transitiven** Durchlauf, keinen einstufigen Grep. Das ist
bezahltes Lehrgeld: ein einstufiger Grep über die Methodenrümpfe fand zwei Fälle, die
Abschlussprüfung einen dritten, und erst der transitive fand den vierten. Ein Rumpf,
der sauber aussieht, kann eine Hilfsmethode rufen, die schreibt.
Der Durchlauf: jede `init…`-Methode bis Tiefe 3 über ihre Aufrufziele verfolgen und auf
diese Bezeichner prüfen —
```
composeMainWindowState MainWindowHost SettingsWindow MonitorWindow UpdateWindow
StationMapWindow OperatorProfilePickerWindow replaceRows replaceSkeds
replaceCandidates DataTableState composeMemberListener
```
**Nicht** auf das Wort „compose" prüfen. Menüeinträge öffnen Compose-Fenster, indem sie
`SettingsWindow` und Geschwister rufen; das Wort kommt dort nicht vor.
Der Durchlauf liegt als Skript daneben, damit der nächste Leser messen statt glauben
kann:
```bash
python3 docs/superpowers/notes/javafx-compose-sweep.py
```
Am 1. Oktober 2026 meldete es **drei** der 18 `init…`-Methoden als Compose-erreichend.
Die vierte Zuführung — die Zeitleiste — sitzt in `start()`, ist keine `init…`-Methode
und wird vom Skript nicht erfasst. Wer die Liste `COMPOSE_TOKENS` im Skript nicht
pflegt, sobald ein neues Compose-Fenster dazukommt, bekommt eine Methode fälschlich als
sauber gemeldet.
Ergebnis: **vier** der 18 `init…`-Methoden erreichen Compose-Zustand —
`initFurtherInfoAbtCallsignMSGTable`, `initMenuBar`,
`initConnectionStateIndicatorButton` und (über `start()`, keine `init…`-Methode) die
Zeitleiste. Die übrigen 15 sind bis Tiefe 3 sauber.
---
## initFurtherInfoAbtCallsignMSGTable — **zu entflechten**
**Belege:**
- Erreicht aus dem **Compose**-Pfad: `:10720` / `:10884` / `:10916` →
`focusChatMemberAndPrepareCq(member, false)` (`:2728`) →
`handleChatMemberSelectionChanged` (`:2770`) →
`generateFurtherInfoAbtSelectedCallsignBP` (`:2799`) → `:893`.
- Innen bei `:3400`–`:3409`: `selectedCallSignInfoMessageBinding` wird erzeugt, und sein
Listener schiebt in `composeMainWindowState.getSelectedStationMessages().replaceRows(...)`
— eine **Compose**-Tabelle, erzeugt bei `:10492`, übergeben bei `:10588`.
- Alle Schreiber von `selectedStationMessages` (`:319`, `:3405`, `:10659`, `:10866`)
laufen über diese Bindung.
**Folgerung:** Wird die Methode gelöscht, bleibt `selectedCallSignInfoMessageBinding`
`null`, alle vier Zuführungen sind null-geprüft und tun still nichts, und die Tabelle
„Nachrichten der ausgewählten Station" im Compose-Hauptfenster bleibt für immer leer.
Der Build bleibt grün. Die Bindung muss **vorher** aus der Tabellenkonstruktion
herausgelöst werden.
**Scheibe C erledigt am 2026-10-02.** Die Erzeugung sitzt jetzt in
`rebuildSelectedCallSignInfoMessageBinding()` ohne `TableView`-Bezug;
`initFurtherInfoAbtCallsignMSGTable` ruft sie nur noch vor dem `setItems`. Der Durchlauf meldet die
Methode weiterhin als Compose-erreichend, aber über die neue Hilfsmethode
(`-> rebuildSelectedCallSignInfoMessageBinding @:selectedStationMessagesFeed`), nicht
mehr über den Tabellen-Builder selbst. Damit kann die Tabelle in der nächsten Scheibe
fallen, ohne die Compose-Tabelle leerzuziehen.
## `start()`, Zeilen 7356–9573 — **zu entflechten, kein toter Zweig**
**Belege:**
- 2.217 Zeilen, in denen toter Oberflächenaufbau und **lebende** Verdrahtung
durcheinanderliegen.
- `:7379` `timelineView = new TimelineView()` und `:7428`
`chatcontroller.getActiveSkeds().addListener(skeds -> updateTimelineVisuals())`.
- `updateTimelineVisuals` (`:4885`) steigt bei `:4886` sofort aus, wenn `timelineView`
`null` ist — und schiebt bei `:4914`–`:4919` in `composeMainWindowState.getTimeline()`:
Skeds, Kandidaten, Antennenazimut und Keulenbreite.
- Weitere lebende Anmeldungen im selben Rumpf: `:7431` `uiPulse()`, `:7446`
`getActualQTF()`.
- `:9565` ruft `openSettingsWindow()`.
### Nachtrag nach Teil 2: das Ziel ist abgelöst, der Auslöser nicht
Teil 2 hat `TimelineFeed` eingeführt und die Wache auf `timelineView` aus
`updateTimelineVisuals` entfernt. Das löst das **Ziel** der Zuführung vom
JavaFX-Aufbau. Die **Auslöser** liegen weiter in `start()`:
| Stelle | Was | Status |
|---|---|---|
| `:6896`, `:6905` | `timelineView`-Breite/Höhe | **tot** — der Knoten wird nie gezeigt |
| `:6931` | `chatcontroller.getActiveSkeds().addListener(...)` | **lebend** |
| `:6935` | `chatcontroller.getScoreService().uiPulse().addListener(...)` | **lebend** |
| `:6951` | `getActualQTF().addListener(...)` — fasst auch `timelineView` an | **lebend, gemischt** |
| `:9073` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** — die Erstbefüllung |
**Warnung für Teil 3:** `updateTimelineVisuals` hat ausschließlich Aufrufer innerhalb
von `start()` (6859–9075). Wer diesen Rumpf löscht, ohne die drei
Controller-Listener und die Erstbefüllung vorher umzuhängen, nimmt der
Compose-Zeitleiste jeden Anstoß — bei grünem Build und ohne Übersetzungsfehler, also
genau die Fehlerklasse, die dieses Dokument verhindern soll. Bei `:6951` muss
zusätzlich der `timelineView`-Zugriff herausfallen.
### Und es ist mehr als die Zeitleiste
Eine erste Fassung dieser Warnung nannte nur die vier Zeitleisten-Zeilen. Eine
Abschlussprüfung hat gezeigt, dass das zu eng ist. **In `start()` liegt nicht nur der
Anstoß, sondern auch die Zuführung selbst:**
| Was | Wo | Folge beim Löschen von `start()` |
|---|---|---|
| Alle drei Zuführungen werden erzeugt | `:10137`–`:10141`, in `openComposeMainWindowIfRequested` (`:9919`), gerufen aus `start()` bei `:9049` | `timelineFeed`, `selectedStationMessagesFeed` und `connectionStateFeed` bleiben `null`; jeder `push` ist ein bewachtes Nichts. Zeitleiste, Nachrichtentabelle **und** Verbindungsanzeige werden leer |
| Auslöser der Nachrichten-Zuführung | der Listener in `initFurtherInfoAbtCallsignMSGTable` (`:3411`) samt der Bindung, die er beobachtet | die Nachrichtentabelle bekommt keinen Anstoß mehr |
| Auslöser der Zeitleiste | `:6931`, `:6935`, `:6951`, `:9073` | siehe oben |
**Damit ist eine Behauptung aus dem Plan zu Teil 2 falsch.** Dort stand: „Nach diesem
Plan ist der tote Aufbau zum ersten Mal wirklich tot." Das gilt nicht. Teil 2 hat die
**Ziele** der Zuführungen abgelöst — sie haben Namen, Tests und einen einzigen
Schreibweg. Ihre **Erzeugung** und ihre **Auslöser** liegen weiter im Aufrufbaum von
`start()`. Teil 3 muss beides umhängen, bevor dort irgendetwas fällt; `start()` ist
nicht zu beschneiden, sondern Zeile für Zeile zu trennen — was dieses Dokument weiter
oben schon über `start()` sagt und was für die Zuführungen genauso gilt.
Der Grund, warum das erst nachträglich auffiel: `openComposeMainWindowIfRequested` liest
sich wie Compose-Verdrahtung und ist es auch — sie hängt nur am falschen Aufrufer.
**Folgerung:** Die Zeitleiste über dem Eingabefeld des **Compose**-Fensters hängt an
einem Objekt, das wie toter JavaFX-Aufbau aussieht. Ein Löschen des „toten Zweigs"
nimmt ihr Skeds, Kandidaten und Antennenrichtung — ohne Übersetzungsfehler und ohne
Testfehler. `start()` ist Zeile für Zeile zu trennen, nicht am Stück zu löschen.
### Die empfohlene Form für Teil 3: die Erzeugung umhängen, nicht die Warnung lesen
Diese Warnung ist ein Dokument, und ein Dokument schützt nur den, der es liest. Die
Abschlussprüfung hat die bessere Form benannt, und sie gehört hierher, damit Teil 3
nicht erst darauf kommen muss:
> Die drei Zuführungen dort erzeugen, wo `composeMainWindowState` **deklariert** wird,
> nicht dort, wo `start()` sie zufällig erreicht.
Dann kann das Umhängen von `start()` die Zuführungen nicht mehr mit `null`
zurücklassen, weil ihr Leben nicht mehr an `start()` hängt. Die Garantie wird
strukturell statt dokumentiert.
Das ist **Scheibe B von Teil 3**, nicht Teil 2 — dort wäre es ein Umbau ohne Auftrag.
Wer Scheibe B beginnt, fängt damit an.
`FeedIsTheOnlyWriterTest` deckt diesen Fall ausdrücklich **nicht** ab: der Test prüft,
wer schreibt, nicht wann die Zuführung entsteht. Ein grüner Lauf ist hier kein
Freibrief.
Inzwischen gibt es dafür `FeedLifetimeIsTiedToTheWindowTest`: er prüft, dass alle drei
Zuführungen in derselben Methode entstehen wie `composeMainWindowState`. Damit ist die
Kopplung strukturell und nicht mehr nur hier beschrieben. Die empfohlene Form oben
bleibt trotzdem die bessere Endgestalt — der Test hält den Zustand fest, er stellt ihn
nicht her.
### Ein Aktualisieren, zwei Zustellungen — vorgefunden, nicht neu
`applySelectedCallSignInfoFilter` (`:317`) und die Stationsauswahl (`:10380`) rufen beide
`selectedCallSignInfoMessageBinding.refresh()` **und** schieben anschließend in einem
`Platform.runLater` selbst nach. `refresh()` geht über `FxRosterBinding:75` nach
`applyNow`, ändert die Spiegelliste, und der Listener bei `:3410` schiebt dadurch schon.
Jeder Filterwechsel und jede Stationsauswahl ersetzt die Tabelle also **zweimal** mit
demselben Inhalt.
Das ist geordnet und harmlos, aber es ist festzuhalten, weil es leicht falsch gelesen
wird: „ein `push` ist eine Zustellung" gilt für die Zuführungsklasse und **nicht** für
das System. Entstanden ist es nicht mit den Zuführungen — der Listener stand schon vor
`21c8e62a` dort, die Zuführung hat die Doppelung geerbt. Aufzulösen ist sie, wenn in
Teil 3 die Bindung aus dem JavaFX-Aufbau gelöst wird: dann fällt einer der beiden Wege
ohnehin weg. Vorher einen davon zu entfernen hieße raten, welcher bleibt.
### „more" holt ein offenes Kandidatenfenster nicht nach vorn
`ComposeWindowHost.show` kehrt bei `!open.compareAndSet(false, true)` zurück (`:106`).
Das JavaFX-Original erzeugte je Druck eine frische `Stage` und hob sie damit an. Wer
also zum Hauptfenster zurückklickt und erneut auf „more" drückt, sieht nichts
geschehen.
**Es betraf nur das Kandidatenfenster — eine erste Fassung dieses Abschnitts behauptete
das Gegenteil.** `ComposeWindowHost` trägt zwar sechs Fenster, aber fünf davon sichern
die Stelle beim Aufruf ab und erreichen die frühe Rückkehr nie:
| Fenster | Aufruf | erreicht `show()` bei offenem Fenster? |
|---|---|---|
| Einstellungen | `:5393`, `isOpen` → `close()` | nein |
| Monitor | `:5376`, dieselbe Form | nein |
| Karte | `:400`/`:406`, `toggle` bzw. `isShowing()` | nein |
| Update | `:10610`, einmal beim Start | nein |
| Hauptfenster | `:10185`, einmal | nein |
| **Kandidaten** | `:4772`, ungeprüft, nur öffnen, kein Umschaltweg | **ja** |
Damit ist es **keine geerbte Eigenschaft des Hosts, sondern eine mit dieser Portierung
eingeführte Regression**. Dazu passt, dass `TopPriorityCandidatesWindow.isOpen` mit
`@JvmStatic` angelegt wurde und **keinen Aufrufer** hat: die Prüfung wurde gebaut und
nie angeschlossen.
Die erste Fassung schrieb „betrifft alle sechs" und überzeichnete damit die Kosten der
Reparatur — und zwar gegenüber dem Betreiber, der genau danach entschieden hat. Die
Entscheidung wurde mit der berichtigten Lage noch einmal gestellt.
**Behoben am 2026-10-01, nach erneuter Entscheidung des Betreibers, im Host.** `show`
holt ein offenes Fenster jetzt nach vorn, statt stillschweigend zurückzukehren. Die
Regel steckt in `raiseSteps`, getrennt von den AWT-Aufrufen, damit sie prüfbar ist: ein
minimiertes Fenster wird erst wiederhergestellt und dann angehoben — `toFront` auf einem
eingeklappten Fenster lässt es eingeklappt, also wäre das derselbe tote Knopf in anderer
Verkleidung — und ein nicht mehr anzeigbares Fenster wird in Ruhe gelassen, damit ein
Druck im Schließfenster nicht gegen das Schließen arbeitet.
Im Host und nicht an der Aufrufstelle, obwohl heute nur ein Fenster davon profitiert:
die vier Geschwister sind wirkungslos abgesichert, und ein künftiges Fenster, das `show`
ungeprüft ruft, macht sonst denselben Fehler noch einmal. Ein Umschalter an der
Aufrufstelle wäre die Alternative gewesen, hätte aber ein anderes Verhalten ergeben —
das JavaFX-Original schloss beim zweiten Druck nicht, es erzeugte eine frische `Stage`
und holte das Fenster damit nach vorn.
Was **nicht** geprüft ist: `toFront`. Nur das — und nicht, weil das Gerüst zu schwach
wäre, sondern weil das Ergebnis dem Fenstermanager gehört und aus der JVM gar nicht
beobachtbar ist. Es gibt kein „liege ich oben" zum Prüfen.
Eine erste Fassung zählte hier drei ungeprüfte AWT-Aufrufe auf und hatte in genau der
Zeile einen Fehler, die sie sich zu prüfen erspart hatte: das Wiederherstellen schrieb
`Frame.NORMAL`, also eine Null, statt das Bit zu löschen. `extendedState` ist ein
Bitfeld; ein Fenster, das maximiert und dann minimiert wurde, trägt 7, und die Null
hätte es wiederhergestellt-klein zurückgegeben — eine Maximierung weggeworfen, um die
niemand gebeten hatte. Herausgelöst als `deiconifiedState(current)`, reine Arithmetik,
drei Prüfungen; die alte Fehlerform wird rot.
Ein `requestFocus` stand ebenfalls in der ersten Fassung, ohne Beleg, dass es je etwas
bewirkt. `toFront` ist der dokumentierte Weg; `Component.requestFocus` auf einem Fenster
ignorieren die meisten Fenstermanager, und unter X11 entscheidet ohnehin die
Fokusdiebstahl-Sperre. Entfernt — zwei Schritte sind die ehrliche Regel.
## initConnectionStateIndicatorButton — **zu entflechten**
Vom transitiven Durchlauf gefunden; ein einstufiger Grep über den Rumpf übersieht ihn.
**Belege:**
- Der Rumpf (`:6180`–`:6198`) ist innen sauber, ruft aber bei `:6195`
`updateConnectionStateIndicator(...)`.
- Diese Methode (`:6200`) schreibt bei `:6217`–`:6219`
`composeMainWindowState.getSurroundings().setConnectionState(...)` und
`setConnectionDetail(...)` — den Verbindungszustand in der **Compose**-Statusleiste.
- Sie hat weitere, lebende Aufrufer außerhalb des toten Aufbaus: `:9844` und `:9871`.
- Danach fasst sie bei `:6224` ein JavaFX-Tooltip an (`tipConnectionStateIndicator`).
**Folgerung:** Eine gemischte Methode wie `updateTimelineVisuals`. Wird
`initConnectionStateIndicatorButton` gelöscht, verschwindet das Tooltip-Objekt, das
`:6224` anfasst, während `:9844`/`:9871` weiter rufen. Der Compose-Schreibzugriff ist
vorher aus dem JavaFX-Teil herauszulösen.
**Scheibe S1 erledigt am 2026-10-02.** `updateConnectionStateIndicator` ist an seiner
markierten Linie getrennt (Feed-Push und Log bleiben, der JavaFX-Indikator fällt);
`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton` und
`initBandUpgradeIndicatorButton` sind gelöscht. Mitgefunden:
`showBlinkingSkedWarnIndicator`/`showBlinkingBandUpgradeIndicator` waren dieselbe Naht — JavaFX-Knopf plus
Compose-Notice-Push. Beide behalten nur den Compose-Push; die Blink-Timelines, die
`hide…`-Gegenstücke und die Knopf-/Tooltip-Felder sind weg. Die Compose-Notice regelt
ihr Ausblenden selbst. `menuConnectionStateMacOs`/`menuItemConnectionStateDetailMacOs`
bleiben noch (fallen mit `initMenuBar`, S2).
## initMenuBar — **zu entflechten**
**Belege:**
- Zehn Compose-Berührungen innen (`:5863` `SettingsWindow`, `:5884`–`:5888`
`MonitorWindow`, und weitere): die Menüeinträge **öffnen** die Compose-Fenster.
- Installiert nur auf der Szene des Kandidatenfensters (`:4858`).
**Folgerung:** Nicht löschen, sondern die Aktionen übernehmen. Der Abgleich ist unten
gemacht — und er fällt kleiner aus als erwartet.
**Scheibe S2 erledigt am 2026-10-02.** `initMenuBar` und `installSharedSystemMenuBar`
(aufruferlos) sind gelöscht, mit ihnen das tote `Macros`-Menü, die zehn `Alert`-tragenden
Menü-Handler und die zwei macOS-Menüfelder `menuConnectionStateMacOs`/
`menuItemConnectionStateDetailMacOs`. Der `start()`-Aufruf und die Menüleisten-Einhängung
fielen mit; `flwpne_StatusBar` bleibt (wird von `updateStatusButton` mitbenutzt, fällt in
S6). **Noch stehen geblieben:** die `menuItem…`-Felder und ihr `setDisable`-Update im
ON4KST-Connection-Callback — die Felder sind seit dem Wegfall von `initMenuBar` `null`,
die Aufrufe null-geprüft und wirkungslos; sie gehören zum toten Aufbau und werden in S6
mit der Callback-Naht aufgeräumt. `macOsConnectionStateMenuTitle` und sein Test bleiben
bewusst (reine String-Funktion, macOS-Punkt der Spec, später zu klären). Der Durchlauf
meldet jetzt 1 of 10 — nur noch `initFurtherInfoAbtCallsignMSGTable` über die
rebuild-Hilfsmethode, die in S4 fällt.
### Abgleich mit `MainMenuBar.kt` (Stand 2026-10-01)
Die Leiste wird bei `:5546` zusammengesetzt:
`menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu);` — mit dem
Kommentar `// macromenu deleted`.
| JavaFX-Eintrag | Aktion | In Compose? |
|---|---|---|
| File → Connect (Beschriftung dynamisch) | `connect` | ja (`state.connectLabel`) |
| File → Disconnect | `disconnect` | ja |
| File → Switch operator profile... | `showOperatorProfileSwitchDialog` | ja |
| File → Exit + disconnect | beenden | ja |
| Options → Set QRG as name in Chat (main category) | QRG als Name setzen | ja |
| Options → Show me as away in chat | Away/Back | ja (`state.awayMenuLabel`) |
| Options → Show options / hide options | Einstellungsfenster | ja (dynamische Beschriftung) |
| Windows → Hide/Show cluster / stranger QSOs | Monitorfenster | ja (dynamische Beschriftung) |
| Windows → hide options | Einstellungsfenster | **fehlt** — in Compose mit „Show options" zusammengelegt |
| Windows → Use dark mode design | Design | ja |
| Windows → Use default mode design | Design | ja |
| Windows → Show / hide station map | Kartenfenster | ja |
| Info → Donate for kst4Contest development via PayPal | PayPal | ja |
| Info → `_______________________` (`help3`) | keine | **fehlt** — reiner Trenner |
| Info → Visit DARC X08-Homepage | Browser | ja |
| Info → Donate for OV3T´s plane feed service | Browser | ja |
| Info → Donate for ON4KST Chatservers… | **keine** — `setOnAction` ist auskommentiert | **fehlt**, und tot |
| Info → Contact the author using default mail app | Mailprogramm | ja |
| Info → Join kst4Contest newsgroup | Browser | ja |
| Info → About… | Infofenster | ja |
| **macOS:** LINK-Menü mit Zustandsdetail (`:5553`) | nur Anzeige, `setDisable(true)` | **fehlt** |
**Zu portieren:** nichts, was eine Aktion trägt. Die drei Lücken sind ein Trenner, ein
in Compose zusammengelegter Doppeleintrag und ein Eintrag, dessen Aktion seit
Längerem auskommentiert ist.
**Nur macOS:** Das `LINK`-Menü samt `menuItemConnectionStateDetailMacOs` wird nur bei
`PlatformUtils.isMacOs()` eingehängt, zusammen mit `setUseSystemMenuBar(true)`. Es
zeigt den Verbindungszustand schreibgeschützt in der Systemmenüleiste. Compose hat
dafür keine Entsprechung; ob das auf einem Mac auffällt, ist auf Linux nicht
entscheidbar.
**Tot, nicht fehlend:** Das Menü `Macros` (`:5361`–`:5370`) mit sechs Einträgen wird
gebaut, bekommt keine einzige Aktion und wird der Leiste **nie** hinzugefügt. Die
ursprünglichen Makro-Handler stehen bei `:3193` ff. auskommentiert. Mit `initMenuBar`
zu löschen, nicht zu portieren.
## FxRosterBinding — Klasse **bleibt**, ein Feld ist löschbar
**Belege:**
- `chatMemberListBinding` (`:299`): `list()` hat **genau einen** Leser, `:2517`
`tbl_chatMemberTable.setItems(...)` — die nie gezeigte Tabelle. Mit ihr löschbar.
- `selectedCallSignInfoMessageBinding` (`:307`) ist dieselbe Klasse und **lebend**
(siehe oben).
**Folgerung:** Das Feld `chatMemberListBinding` entfällt, die Klasse `FxRosterBinding`
nicht. Sie benutzt `ObservableList`/`FXCollections`; solange sie lebt, lebt dieser
JavaFX-Bezug. Ihr Ersatz ist eigene Arbeit in Teil 2.
## registerThemedScene / `themedScenes` — **folgenlos löschbar**, mit dem Kandidatenfenster
**Belege:**
- Zwei Aufrufer: `:7554` (tote Hauptszene), `:4856` (Kandidatenfenster, lebend).
- `applyTheme` (`:6111`) durchläuft `themedScenes` **und** benachrichtigt jedes
Compose-Fenster getrennt über `applyDarkMode` (`:6122`–`:6126`).
**Folgerung:** Die Compose-Themenumschaltung hängt nicht daran.
## LayoutAutosave — **bleibt**
**Belege:**
- `requestLayoutSave()` hat **vierzehn** Aufrufer, nicht drei: `:1564`, `:7563`,
`:7571`, `:8280`, `:9461`, `:9495`, `:10442`, `:10605`, `:10615`, `:10625`, `:11017`,
`:11062`, `:11096`, `:11125`. Sieben davon liegen oberhalb `:10400`, also im
Compose-Bereich; `:10442` ist die `requestSave()`-Umsetzung des Breiten-Rückrufs, den
die **Compose**-Tabellen bekommen (`:10432`–`:10444`).
**Warnung für Teil 2:** Eine frühere Fassung dieses Abschnitts zählte drei Aufrufer.
Wer danach aufräumt und `requestLayoutSave()` entfernt, weil „seine drei Aufrufer weg
sind", bricht sieben lebende Compose-Speicherungen.
**Folgerung:** Speichert die Spaltenbreiten der Compose-Tabellen. Bleibt. Die drei
JavaFX-Bezüge in `LayoutAutosave.java` sind in Teil 2 gesondert zu prüfen.
## Kandidatenfenster — **zu entflechten, und Sperrriegel der Etappe**
**Belege:**
- `showTopPriorityCandidatesWindow` (`:4806`) baut die einzige `Stage`, die dieser
Quelltext selbst erzeugt (`new Stage()` bei `:4810`, das einzige Vorkommen in der
Datei), gibt ihr eine `Scene` (`:4855`) und zeigt sie (`:4860`).
- **Nicht** die einzige JavaFX-Oberfläche, die erscheint: die zehn `Alert`-Dialoge
(`:476`, `:5756`, `:5777`, `:6040`, `:6693`, `:6741`, `:6794`, `:9755`, `:9787`,
`:9896`) bringen ihre Bühne selbst mit und sperren die Etappe genauso.
- Zwei Aufrufer: `:4706` (toter JavaFX-Knopf) und `:10707` aus dem **Compose**-Pfad.
- Trägt `registerThemedScene` (`:4856`) und `installSharedSystemMenuBar` (`:4858`).
**Folgerung:** Solange es steht, kann keine JavaFX-Abhängigkeit fallen — aber es ist
nicht das Einzige, was sie hält; die zehn Alerts müssen mit. Es steht trotzdem zuerst,
weil es als einziges eine eigene Bühne, ein Thema und eine Menüleiste mitbringt.
**Nicht geprüft:** macOS — und auf Linux auch **nicht prüfbar**:
`installSharedSystemMenuBar` steigt bei `:6075` sofort aus, wenn
`PlatformUtils.isMacOs()` falsch ist. Was dort sichtbar an der JavaFX-`MenuBar` hängt,
kann kein Lesen auf Linux beantworten; vor dem Entfernen auf einem Mac nachzuholen.
---
## Ergebnis in einem Satz
Von den 18 `init…`-Methoden sind 15 bis Tiefe 3 sauber und nach Prüfung ihrer Aufrufer
löschbar — vier davon haben gar keinen Aufrufer; **drei** erreichen die
Compose-Oberfläche und sind zu entflechten; dazu kommen `start()` selbst, die Klasse
`FxRosterBinding`, `LayoutAutosave`, das Kandidatenfenster und die zehn
`Alert`-Dialoge. „Toter Aufbau" ist deshalb die falsche Beschreibung: es ist
durchsetzter Aufbau.
## Die `init…`-Methoden
„Kein Aufrufer" heißt hier wirklich keiner: `getDeclaredMethod`, `getMethod(`,
`Class.forName` und `FXMLLoader` kommen in der Datei **null**mal vor — es gibt keine
reflektive oder FXML-Hintertür, über die eine der vier dennoch erreichbar wäre.
| Methode | Zeile | ca. Zeilen | Aufrufer | erreicht Compose? |
|---|---|---|---|---|
| `initShortcutTable` | 4561 | 38 | **keiner** | nein |
| `initNotifyAtCallSignTable` | 5146 | 100 | **keiner** | nein |
| `initTextSnippetsTable` | 5248 | 46 | **keiner** | nein |
| `initWkdStnTable` | 5441 | 286 | **keiner** | nein |
| `initChatMemberTableContextMenu` | 3173 | 43 | 5314, 5315, 8017, 8433 | nein |
| `initBottomGlobalMessageTabPane` | 3577 | 23 | 8257 | nein |
| `initDXClusterTable` | 4156 | 181 | 3590 | nein |
| `initChatToOtherMSGTable` | 4339 | 220 | 3594 | nein |
| `initSelectedCallSignCompactControlsPane` | 3451 | 108 | 1521 | nein |
| `initChatGeneralMSGTable` | 3607 | 169 | 8162 | nein |
| `initChatprivateMSGTable` | 3778 | 376 | 8015 | nein |
| `initChatMemberTable` | 1824 | 852 | 8293 | nein |
| `initTopPriorityListPane` | 4671 | 44 | 9442 | nein |
| `initSkedWarnIndicatorButton` | 6161 | 17 | 7611 | nein |
| **`initConnectionStateIndicatorButton`** | 6180 | 18 | 7605 | **ja — über `:6195` → `:6217`** |
| `initBandUpgradeIndicatorButton` | 6361 | 16 | 7634 | nein |
| **`initFurtherInfoAbtCallsignMSGTable`** | 3218 | 213 | 893 | **ja — :3404** |
| **`initMenuBar`** | 5729 | 337 | 7599 | **ja — 10 Stellen** |
## Reihenfolge für Teil 2
Nach jeder Scheibe `./gradlew clean build` — und bei jeder Scheibe, die etwas löscht,
vorher der Durchlauf aus „Wie geprüft werden muss".
**Vorab, ohne Löschen:**
- **A — Kandidatenfenster nach Compose.** Der Sperrriegel. Mit ihm fallen
`registerThemedScene` und `installSharedSystemMenuBar` als einzige lebende Nutzer.
- **B — Die Zeitleisten-Zuführung aus `start()` lösen.** `timelineView` und die
Listener bei `:7428`, `:7431`, `:7446` bekommen ein eigenes Zuhause, das nicht am
JavaFX-Aufbau hängt. Prüfbar: die Compose-Zeitleiste zeigt weiter Skeds und
Kandidaten.
- **C — `selectedCallSignInfoMessageBinding` aus `initFurtherInfoAbtCallsignMSGTable`
lösen.** Prüfbar: die Nachrichtentabelle der ausgewählten Station füllt sich noch.
- **D — Die Menüeinträge aus `initMenuBar` gegen `MainMenuBar.kt` abgleichen.**
- **E — Den Compose-Schreibzugriff aus `updateConnectionStateIndicator` lösen**
(`:6217`–`:6219`), damit `:9844`/`:9871` ihn weiter erreichen, wenn das
JavaFX-Tooltip fällt. Prüfbar: die Verbindungsanzeige in der Compose-Statusleiste
wechselt weiter.
**Dann löschen, Blätter zuerst:**
1. ~~Unreferenziert: `initShortcutTable`, `initNotifyAtCallSignTable`,
`initTextSnippetsTable`, `initWkdStnTable`~~ — **erledigt am 2026-10-01**,
474 Zeilen, Suite unverändert bei 769 Tests.
2. `initDXClusterTable`, `initChatToOtherMSGTable`, dann
`initBottomGlobalMessageTabPane` — ~424 Zeilen.
3. `initFurtherInfoAbtCallsignMSGTable` (nach C) und
`initSelectedCallSignCompactControlsPane` — ~321 Zeilen. Vorher prüfen, ob die
Aufrufer `:893` und `:1521` nach C noch erreichbar sind.
4. `initChatGeneralMSGTable`, `initChatprivateMSGTable` — ~545 Zeilen.
5. `initChatMemberTable` (852) und `initChatMemberTableContextMenu` — größte Scheibe.
6. `initTopPriorityListPane` und die drei Statusknöpfe — ~95 Zeilen;
`initConnectionStateIndicatorButton` erst nach E.
7. `initMenuBar` (337) — nach A und D.
8. Der Rest von `start()`, Zeile für Zeile: was nicht in B übernommen wurde, die
`scn_ChatwindowMainScene` samt Listenern, `themedScenes`, `chatMemberListBinding`
und die tote CW-Ausgabe (`:6966` ff.).
Erst danach sind `UiDispatcher`, Lebenszyklus, Dialoge und der Build an der Reihe.
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Which methods of Kst4ContestApplication can reach Compose state.
The question this answers is "is this block of JavaFX construction safe to delete",
and it is harder than it looks. Three weaker versions of it were tried first, and
each missed something the next one found:
- grepping a method body for the word "compose" misses menu handlers, which open
Compose windows by calling SettingsWindow and its siblings by name;
- grepping a method body for those names catches that, but misses a method whose
body is clean and which calls a helper that writes;
- only following the calls finds all four.
So: follow the calls, and key on the names of the things that hold Compose state,
not on the word "compose".
Usage, from the repository root:
python3 docs/superpowers/notes/javafx-compose-sweep.py
Prints one line per init… method. A method reported clean is clean to depth 3; the
depth is a limit, not a proof.
"""
import re
import sys
SOURCE = "app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"
# Everything that is, holds, or opens Compose state. Extend this when a new Compose
# window or table appears; a name missing here is a method wrongly reported clean.
COMPOSE_TOKENS = (
"composeMainWindowState",
"composeMemberListener",
"MainWindowHost",
"SettingsWindow",
"MonitorWindow",
"UpdateWindow",
"StationMapWindow",
"OperatorProfilePickerWindow",
"DataTableState",
"replaceRows",
"replaceSkeds",
"replaceCandidates",
# The feeds are the Compose sinks since part 2: the mixed init methods write
# Compose state through feed.push(...) rather than through a replace… call
# directly. Without these names the sweep reports those methods clean and would
# wave through a deletion that empties a Compose table with a green build.
"timelineFeed",
"selectedStationMessagesFeed",
"connectionStateFeed",
)
MAX_DEPTH = 3
METHOD_DECLARATION = re.compile(
r"^\t(?:(?:public|private|protected|static|final|synchronized)\s+)+"
r"[\w<>,\[\]\. ]+\s+(\w+)\s*\("
)
CALL = re.compile(r"\b(\w+)\s*\(")
def read_methods(lines):
"""Maps a method name to the line ranges of its declarations."""
methods = {}
for index, line in enumerate(lines):
match = METHOD_DECLARATION.match(line)
if not match or line.strip().startswith("//"):
continue
end = next(
(j + 1 for j in range(index + 1, len(lines)) if lines[j].startswith("\t}")),
len(lines),
)
methods.setdefault(match.group(1), []).append((index + 1, end))
return methods
def reaches_compose(name, methods, lines, seen=frozenset(), depth=0):
"""The path by which `name` reaches Compose state, or None."""
if depth > MAX_DEPTH or name in seen:
return None
seen = seen | {name}
for start, end in methods.get(name, []):
for k in range(start - 1, end):
if lines[k].strip().startswith("//"):
continue
for token in COMPOSE_TOKENS:
if token in lines[k]:
return [f"{name} @{k + 1}:{token}"]
for k in range(start - 1, end):
if lines[k].strip().startswith("//"):
continue
for callee in set(CALL.findall(lines[k])):
if callee in methods and callee != name:
path = reaches_compose(callee, methods, lines, seen, depth + 1)
if path:
return [f"{name} -> "] + path
return None
def main():
try:
lines = open(SOURCE, encoding="utf-8").read().split("\n")
except OSError as unreadable:
print(f"cannot read {SOURCE}: {unreadable}", file=sys.stderr)
print("run this from the repository root", file=sys.stderr)
return 1
methods = read_methods(lines)
inits = sorted(n for n in methods if n.startswith("init") and n[4:5].isupper())
live = 0
for name in inits:
path = reaches_compose(name, methods, lines)
if path:
live += 1
print(f"{name:42s} REACHES COMPOSE: {''.join(path)}")
else:
print(f"{name:42s} clean to depth {MAX_DEPTH}")
print(f"\n{live} of {len(inits)} init methods reach Compose state.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,506 @@
# JavaFX entfernen, Teil 1: Bestandsaufnahme und `derive`-Pin
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Den `derive`-Pin so einfrieren, dass er das Entfernen von JavaFX überlebt, und belegen, welcher Teil des nie angezeigten JavaFX-Aufbaus folgenlos gelöscht werden kann.
**Architecture:** Zwei voneinander unabhängige Stränge. Der erste ersetzt einen Reflexionsaufruf im Test durch eingecheckte Daten — klein, testgetrieben, zeitkritisch. Der zweite ist Verifikationsarbeit an `Kst4ContestApplication`: für jede verdächtige Bindung wird durch Lesen **und** durch einen Lauf der Anwendung festgestellt, ob sie den Betrieb speist oder nur die tote Tabelle. Ergebnis ist ein Befunddokument, auf dem Teil 2 aufsetzt.
**Tech Stack:** Java 21, Kotlin 2.2, Gradle, JUnit 5, Compose Multiplatform 1.8.2, JavaFX 21 (noch vorhanden).
**Spec:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`
## Warum nur Teil 1
Die Spezifikation hat sechs Etappen. Etappe 2 (toten Aufbau löschen), 4 (Dispatcher), 5 (Lebenszyklus) und 6 (Reste und Build) lassen sich heute **nicht ohne Platzhalter** schreiben: welcher Block gelöscht wird und was vorher zu entflechten ist, ist das Ergebnis von Etappe 1. Ein Plan, der das vorwegnimmt, wäre geraten.
Dieser Plan deckt Etappe 1 und Etappe 3 ab — die beiden, die heute vollständig spezifizierbar sind. Teil 2 wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt.
## Global Constraints
- Kommunikation mit dem Benutzer auf Deutsch; Quelltextkommentare und Javadoc ausschließlich auf Englisch (`AGENTS.md`).
- Keine neue Produktionsabhängigkeit ohne vorherige Freigabe (`AGENTS.md`).
- Kein Commit, Push, Merge, Tag oder Release ohne gesonderte ausdrückliche Freigabe (`AGENTS.md`). Die Commit-Schritte unten werden ausgeführt, weil dieser Plan sie benennt und der Benutzer ihn freigibt; alles darüber hinaus nicht.
- Der Build erlaubt historisch, dass Testfehler ignoriert werden. **Nicht** aus Exit-Code 0 auf „alle Tests grün" schließen — die XML-Ergebnisse unter `*/build/test-results/` lesen (`AGENTS.md`).
- Ausgangslage: `./gradlew clean build` ist grün, 766 Tests, 0 Fehler. Jede Rotfärbung ist ein Signal.
- Signierte Commits sind konfiguriert (`commit.gpgsign=true`, SSH). Schlägt das Signieren fehl, läuft der Bitwarden-Agent nicht — das ist kein Grund, unsigniert zu committen.
- In diesem Plan wird **keine** Produktionsdatei unter `src/main` gelöscht. Löschen ist Teil 2.
## Review Focus
Fünf Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier prüft, geordnet nach Wahrscheinlichkeit, dass sie jemanden treffen:
1. **Der eingefrorene `derive`-Pin könnte die falschen Werte einfrieren.** Werden die Daten aus der eigenen Umsetzung statt aus JavaFX erzeugt, prüft der Test danach nur noch sich selbst. Aufgabe 1 erzeugt sie deshalb nachweislich über den Reflexionsaufruf und Aufgabe 2 prüft, dass mindestens ein Datensatz von einer absichtlich verfälschten Umsetzung abgelehnt wird.
2. **Die eingefrorenen Daten könnten den benutzten Wertebereich nicht abdecken.** `derive` wird mit 26.4 % und 35 % auf Stylesheet-Farben gerufen; Daten nur für Weiß und Schwarz hülfen nicht. Aufgabe 1 deckt die Prozentwerte und Farben ab, die der Test heute benutzt, und zusätzlich die Sprungstellen der Helligkeitsstaffel (0.2, 0.3, 0.4, 0.5, 0.6, 0.85).
3. **Der Befund aus Aufgabe 5 könnte eine Bindung übersehen, die nur bei bestimmten Einstellungen lebt.** Ein Profil mit anderen aktiven Bändern oder abgeschalteter Karte durchläuft andere Zweige. Aufgabe 4 prüft deshalb gegen ein zweites Profil.
4. **Ein Listener könnte nur beim Profilwechsel lebendig werden.** `closeOwnedStages` und der Wiederaufbau laufen nur dort. Aufgabe 4 schließt einen Profilwechsel ein.
5. **Die Systemmenüleiste könnte auf macOS anders leben als auf Linux.** Geprüft wird auf Linux; der Befund hält das ausdrücklich als ungeprüft fest, statt es offenzulassen.
---
## File Structure
| Datei | Verantwortung | Aufgabe |
|---|---|---|
| `app-desktop/src/test/resources/javafx-derive-reference.txt` | **neu** — die von JavaFX gelieferten Werte als eingecheckte Daten | 1 |
| `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt` | **ändern** — vergleicht gegen die Daten statt gegen den Reflexionsaufruf | 1, 2 |
| `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` | **ändern** — nur der veraltete Kommentar | 2 |
| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **neu** — der Befund, auf dem Teil 2 aufsetzt | 3, 4, 5 |
---
## Task 1: Die JavaFX-Werte als Daten einfrieren
**Files:**
- Create: `app-desktop/src/test/resources/javafx-derive-reference.txt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
- Test: dieselbe Datei
**Interfaces:**
- Consumes: `JavaFxStylesheet.derive(color: Color, percent: Double): Color` — unverändert.
- Produces: die Ressource `javafx-derive-reference.txt`, Zeilenformat
`<rgbHex>;<percent>;<erwarteterRgbHex>`, z. B. `#ececec;26.4;#f4f4f4`. Drei
Kommentarzeilen am Anfang, jede mit `#`. Aufgabe 2 schreibt den Leser dafür.
Ausserdem bleibt `deriveWithJavaFx(rgb: String, percent: Double): String` im Test
erhalten — Aufgabe 2 entfernt nur seinen Aufruf aus dem laufenden Vergleich, nicht
die Methode.
- [ ] **Step 1: Den bestehenden Test lesen, bevor etwas geändert wird**
Run: `sed -n '1,60p;160,180p' app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
Erwartet: `deriveWithJavaFx(rgb, percent)` ruft über `Class.forName("com.sun.javafx.util.Utils").getMethod("deriveColor", ...)` das echte JavaFX. Der Test `derive gives the values JavaFX gives` vergleicht drei Größen: die erwarteten Werte aus der Tabelle, `JavaFxStylesheet.derive` und `deriveWithJavaFx`. Notiere den genauen Namen der Hilfsmethoden `parse`, `hex` und `assertChannelsClose` — sie werden unten weiterbenutzt.
- [ ] **Step 2: Einen Generator schreiben, der die Daten aus JavaFX erzeugt**
Ein JUnit-Test, der die Datei schreibt statt etwas zu behaupten. Er wird einmal benutzt und bleibt danach als `@Disabled` stehen, damit nachvollziehbar ist, woher die Daten kommen.
In `JavaFxStylesheetTest.kt` ergänzen:
```kotlin
/**
* Writes the reference data the frozen test compares against.
*
* Disabled because it is a generator, not an assertion: it runs once, while JavaFX
* is still on the classpath, and its output is checked in. Kept rather than deleted
* so that the provenance of the data is readable — the values come from
* com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole
* point of pinning them.
*
* To regenerate: remove @Disabled, run, restore @Disabled.
*/
@org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present")
@Test
fun `generate the JavaFX reference data`() {
val colours = listOf(
"#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff",
"#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999",
"#d9d9d9", "#f4f4f4", "#63067a", "#ff9900",
)
val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0)
val lines = buildList {
for (rgb in colours) {
for (percent in percents) {
add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}")
}
}
}
val target = java.io.File("src/test/resources/javafx-derive-reference.txt")
target.parentFile.mkdirs()
target.writeText(
"# Generated from com.sun.javafx.util.Utils.deriveColor by\n" +
"# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" +
"# Format: <rgbHex>;<percent>;<expectedRgbHex>\n" +
lines.joinToString("\n") + "\n"
)
println("wrote ${lines.size} pairs to ${target.absolutePath}")
}
```
Die Farbliste deckt ab: Weiß und Schwarz (Ränder), die Modena-Basis `#ececec`, die Abendblech-Basis `#373e43`, Marker- und Gitterfarben aus der Karte, und Graustufen, die die Sprungstellen der Helligkeitsstaffel treffen (0.2, 0.3, 0.4, 0.5, 0.6, 0.85 — siehe Review Focus 2). Die Prozentwerte decken die im Produktionscode benutzten 26.4 und 35 ab, dazu negative und positive Extreme.
- [ ] **Step 3: Den Generator einmal laufen lassen**
`@Disabled` vorübergehend auskommentieren, dann:
Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain -i 2>&1 | grep "wrote"`
Erwartet: eine Zeile `wrote 160 pairs to /…/app-desktop/src/test/resources/javafx-derive-reference.txt`.
Danach `@Disabled` wieder einkommentieren.
- [ ] **Step 4: Prüfen, dass die Daten wirklich von JavaFX stammen**
Run: `head -5 app-desktop/src/test/resources/javafx-derive-reference.txt && wc -l app-desktop/src/test/resources/javafx-derive-reference.txt`
Erwartet: drei Kommentarzeilen, dann Zeilen der Form `#ffffff;-80.0;#333333`. Insgesamt 163 Zeilen.
Stichprobe von Hand: `#ffffff;-30.0;…` muss ein mittleres Grau sein, nicht Weiß. Ist die Spalte identisch mit der ersten, hat der Generator nicht JavaFX gefragt — dann Schritt 2 prüfen, **nicht** weitermachen.
- [ ] **Step 5: Commit**
```bash
git add app-desktop/src/test/resources/javafx-derive-reference.txt \
app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt
git commit -m "Freeze the JavaFX colour-derivation reference as test data
JavaFxStylesheetTest compares derive() against com.sun.javafx.util.Utils
at runtime, through reflection. That comparison stops working the moment
JavaFX leaves the classpath, and the test would then quietly fall back to
checking derive() against itself.
The pairs are generated from the real JavaFX implementation while it is
still here and checked in. The generator stays, disabled, so the
provenance of the numbers is readable."
```
---
## Task 2: Den Test gegen die Daten prüfen lassen
**Files:**
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` (nur Kommentar)
- Test: dieselbe Testdatei
**Interfaces:**
- Consumes: `javafx-derive-reference.txt` aus Aufgabe 1.
- Produces: nichts, was spätere Aufgaben benutzen.
- [ ] **Step 1: Den Test schreiben, der gegen die Daten prüft**
In `JavaFxStylesheetTest.kt` ergänzen:
```kotlin
/** The reference pairs, as `(rgbHex, percent, expectedRgbHex)`. */
private fun referencePairs(): List<Triple<String, Double, String>> {
val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt")
?: error("javafx-derive-reference.txt is missing from the test resources")
return resource.bufferedReader().readLines()
.filterNot { it.isBlank() || it.startsWith("#") }
.map { line ->
val (rgb, percent, expected) = line.split(";")
Triple(rgb, percent.toDouble(), expected)
}
}
/**
* derive() against the values JavaFX itself produced.
*
* Against checked-in data rather than a live reflection call, because the live call
* disappears with JavaFX and would take the only real check with it — leaving a
* test that compares derive() to derive().
*/
@Test
fun `derive matches the frozen JavaFX reference`() {
val pairs = referencePairs()
assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" }
pairs.forEach { (rgb, percent, expected) ->
assertChannelsClose(
parse(expected),
JavaFxStylesheet.derive(parse(rgb), percent),
"derive($rgb, $percent%)",
)
}
}
```
- [ ] **Step 2: Den Test laufen lassen und sehen, dass er grün ist**
Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain > /tmp/t1.txt 2>&1; python3 -c "
import glob,xml.etree.ElementTree as ET
for f in glob.glob('app-desktop/build/test-results/test/TEST-*JavaFxStylesheetTest.xml'):
r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')} skipped={r.get('skipped')}\")
for tc in r.iter('testcase'):
for b in list(tc.iter('failure'))+list(tc.iter('error')): print(tc.get('name'), (b.get('message') or '')[:200])"`
Erwartet: `failures=0`, `skipped=1` (der Generator).
Ein grüner Test ist hier **noch kein Beweis** — er könnte grün sein, weil die Daten aus der eigenen Umsetzung stammen. Das prüft der nächste Schritt.
- [ ] **Step 3: Beweisen, dass der Test den Fehler fängt, den er fangen soll**
Review Focus 1. Die Produktionsumsetzung vorübergehend verfälschen:
In `JavaFxStylesheet.kt`, in `derive`, `calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)` ersetzen durch `calcBrightness = (calcBrightness * 1.05).coerceIn(-1.0, 1.0)`.
Run: denselben Befehl wie Schritt 2.
Erwartet: `failures=1`, und die Meldung nennt ein konkretes `derive(#…, …%)`. Fällt der Test **nicht**, sind die Daten wertlos — dann stammen sie nicht von JavaFX; zurück zu Aufgabe 1.
Danach die Verfälschung zurücknehmen und erneut laufen lassen: `failures=0`.
- [ ] **Step 4: Den Reflexionsvergleich aus dem laufenden Test nehmen**
Die Zeile, die `deriveWithJavaFx` im Test `derive gives the values JavaFX gives` aufruft (um `:50`), entfernen, sodass dieser Test nur noch seine eigene Tabelle gegen `JavaFxStylesheet.derive` prüft. `deriveWithJavaFx` selbst **bleibt** — der Generator in Aufgabe 1 braucht es.
Begründung als Kommentar über den entfernten Vergleich setzen:
```kotlin
/*
* No live JavaFX comparison here. It lives in the frozen reference data
* instead (javafx-derive-reference.txt), because a reflection call into
* com.sun.javafx stops working when JavaFX is removed and would take the
* only real check with it.
*/
```
- [ ] **Step 5: Den veralteten Kommentar in der Produktionsdatei berichtigen**
In `JavaFxStylesheet.kt` beschreibt der Kommentar über `derive` (um `:116`–`:129`) eine Reflexionsfassung, die es nicht mehr gibt. Ersetzen durch:
```kotlin
/**
* JavaFX `derive(colour, percent)`, reimplemented.
*
* No JavaFX call and no reflection: this is the algorithm itself. The brightness it
* derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive
* branch follows a staircase rather than a formula, which is why an earlier
* approximation here was measurably wrong.
*
* Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this
* against values the real `com.sun.javafx.util.Utils.deriveColor` produced, frozen
* into `javafx-derive-reference.txt` so the check survives JavaFX being removed.
*/
```
- [ ] **Step 6: Die ganze Suite laufen lassen**
Run: `./gradlew clean build --console=plain > /tmp/b.txt 2>&1; echo "exit=$?"; python3 -c "
import glob,xml.etree.ElementTree as ET
t=f=0
for p in glob.glob('*/build/test-results/test/TEST-*.xml'):
r=ET.parse(p).getroot(); t+=int(r.get('tests')); f+=int(r.get('failures'))+int(r.get('errors'))
print(f'tests={t} failures={f}')"`
Erwartet: `exit=0`, `failures=0`, und `tests` um eins höher als die 766 vom Ausgangsstand.
- [ ] **Step 7: Commit**
```bash
git add app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt \
app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt
git commit -m "Check derive() against the frozen reference instead of live JavaFX
The reflection call stays only in the disabled generator. The running
test now reads checked-in values, so it keeps its meaning once JavaFX is
gone rather than silently comparing derive() with itself.
Verified by breaking derive() on purpose and watching the test fail.
Also corrects the doc comment on derive(), which still described a
reflection delegation that the code has not done for some time."
```
---
## Task 3: Feststellen, ob `FxRosterBinding` den Betrieb speist
**Files:**
- Create: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`, `app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java`
**Interfaces:**
- Consumes: nichts.
- Produces: das Befunddokument mit dem Abschnitt „FxRosterBinding". Aufgabe 5 fasst zusammen, Teil 2 baut darauf auf.
**Keine Produktionsdatei wird in dieser Aufgabe geändert.**
- [ ] **Step 1: Alle Nutzungen des Spiegels auflisten**
Run: `grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Erwartet: Erzeugung um `:2513`, Nullsetzung um `:6877`, eine Prüfung um `:1766`. Jede weitere Fundstelle notieren.
- [ ] **Step 2: Feststellen, wer den gespiegelten `ObservableList` liest**
Run: `grep -n "chatMemberListBinding.get\|chatMemberListBinding\.\(mirror\|list\|items\)" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java; grep -n "public" app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java`
Erwartet: die öffentliche Fläche von `FxRosterBinding` und jede Stelle, die den Spiegel abruft. Für jede Stelle festhalten: landet sie in einer `TableView` (tot) oder in etwas, das Compose liest (lebend)?
- [ ] **Step 3: Feststellen, woher die Compose-Stationsliste ihre Zeilen bekommt**
Run: `grep -n "DataTableState<ChatMember>\|setRows\|rows =" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | head -20; grep -n "fun setRows\|var rows\|val rows" app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt`
Erwartet: eine durchgehende Kette von `ObservableRoster` bis `DataTableState.rows`. Notiere sie Glied für Glied. Führt die Kette durch `chatMemberListBinding`, ist der Spiegel **lebend**.
- [ ] **Step 4: Den Befund schreiben**
`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` anlegen:
```markdown
# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird
Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`.
Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`.
Jeder Block bekommt genau einen von zwei Töpfen:
**folgenlos löschbar** oder **zu entflechten** (mit der Stelle, die davon abhängt).
## FxRosterBinding (`chatMemberListBinding`)
**Topf:** <folgenlos löschbar | zu entflechten>
**Belege:**
- Erzeugt bei `Kst4ContestApplication.java:<Zeile>`, genullt bei `:<Zeile>`.
- Gelesen von: <Liste der Stellen, je mit Zeile und „tot/lebend">
- Die Compose-Stationsliste bezieht ihre Zeilen über: <die Kette aus Schritt 3>
**Folgerung:** <ein Satz>
```
Jede spitze Klammer wird durch das ersetzt, was die Schritte 1–3 ergeben haben. Bleibt eine stehen, ist die Aufgabe nicht fertig.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
git commit -m "Record what FxRosterBinding actually feeds
First section of the finding that part 2 of the JavaFX removal builds on.
No production code touched."
```
---
## Task 4: Die übrigen Verdachtsfälle am laufenden Programm prüfen
**Files:**
- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
**Interfaces:**
- Consumes: das Befunddokument aus Aufgabe 3.
- Produces: dasselbe Dokument mit den Abschnitten „registerThemedScene", „LayoutAutosave", „Systemmenüleiste" und „Kandidatenfenster".
**Keine Produktionsdatei wird in dieser Aufgabe geändert.** Diese Aufgabe erfordert, die Anwendung zu starten und zu bedienen; Lesen allein beantwortet sie nicht.
- [ ] **Step 1: Die Stellen auflisten**
Run: `grep -n "registerThemedScene\|LayoutAutosave\|layoutAutosave\|installSharedSystemMenuBar\|initMenuBar\|showTopPriorityCandidatesWindow" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Erwartet unter anderem: drei Aufrufe von `registerThemedScene`, `layoutAutosave` erzeugt um `:7420`, `installSharedSystemMenuBar(candidatesScene)` um `:4858`, `initMenuBar()` um `:5729`, `showTopPriorityCandidatesWindow` gerufen bei `:4706` (toter JavaFX-Knopf) und `:10707` (lebender Compose-Pfad).
- [ ] **Step 2: Die Anwendung starten und das Kandidatenfenster öffnen**
Run: `./gradlew :app-desktop:run`
Dann im Compose-Hauptfenster den Knopf **more** neben den Prioritätsknöpfen drücken.
Erwartet: ein Fenster mit der Kandidatenliste erscheint. Festhalten: Titelleiste, ob es eine eigene Menüleiste trägt, ob es modal ist, und ob ein Klick auf einen Eintrag im Hauptfenster eine Station auswählt.
Erscheint **kein** Fenster, ist der Pfad trotz `:10707` tot — das ist ein genauso wertvoller Befund und gehört so ins Dokument.
- [ ] **Step 3: Das Design umschalten und beobachten, ob die Compose-Fenster folgen**
Bei laufender Anwendung das Design über die Menüleiste des Hauptfensters umschalten (Tag/Abend).
Erwartet: Hauptfenster, Kartenfenster und Einstellungsfenster wechseln die Farben. Notieren, ob **alle** folgen. Folgt eines nicht, hängt es womöglich an `registerThemedScene` und gehört in den Topf „zu entflechten".
- [ ] **Step 4: Einen Profilwechsel durchführen**
Review Focus 4. Über die Menüleiste das Bedienerprofil wechseln.
Erwartet: die Fenster werden geschlossen und neu aufgebaut; die Anwendung bleibt bedienbar; in der Konsole erscheint keine Ausnahme. Notieren, was passiert.
- [ ] **Step 5: Mit einem zweiten Profil wiederholen**
Review Focus 3. Ein Profil mit anderen aktiven Bändern wählen (mindestens ein Band an- oder abgeschaltet gegenüber dem ersten) und die Schritte 2 bis 4 wiederholen.
Erwartet: dasselbe Verhalten. Weicht es ab, gehört die Abweichung mit der Einstellung ins Dokument, die sie auslöst.
- [ ] **Step 6: Fenstergrößen auf Persistenz prüfen**
Ein Fenster in der Größe ändern, die Anwendung beenden, neu starten.
Erwartet: die Größe ist erhalten. Ist sie es nicht, schreibt `LayoutAutosave` womöglich Werte, die niemand mehr liest — oder umgekehrt. Notieren.
- [ ] **Step 7: Die Abschnitte ins Befunddokument schreiben**
Für jeden der vier Blöcke — `registerThemedScene`, `LayoutAutosave`, Systemmenüleiste, Kandidatenfenster — denselben Aufbau wie in Aufgabe 3 Schritt 4: Topf, Belege, Folgerung.
Die Systemmenüleiste bekommt zusätzlich die Zeile:
```markdown
**Nicht geprüft:** macOS. Erhoben wurde auf Linux. Ob die Systemmenüleiste dort
sichtbar an der JavaFX-`MenuBar` hängt, ist offen und muss vor dem Entfernen auf
einem Mac nachgeholt werden.
```
- [ ] **Step 8: Commit**
```bash
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
git commit -m "Record what the themed scenes, autosave and menu bar actually do
Established by running the application and operating it, not by reading:
a listener that is never fired reads the same as one that does not exist.
Includes a profile switch and a second profile, and marks macOS as
unverified. No production code touched."
```
---
## Task 5: Den Befund abschließen und Teil 2 umreißen
**Files:**
- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
**Interfaces:**
- Consumes: alle Abschnitte aus Aufgabe 3 und 4.
- Produces: die Löschreihenfolge, aus der Teil 2 geschrieben wird.
- [ ] **Step 1: Den toten Aufbau in Scheiben zerlegen**
Run: `grep -n "private.*void init[A-Z][a-zA-Z]*(\|private.*Pane init[A-Z][a-zA-Z]*(\|private.*Node init[A-Z][a-zA-Z]*(" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Erwartet: die `init…`-Methoden, die zusammen das nie gezeigte Fenster bauen. Für jede notieren: Zeilenbereich, ungefähre Länge und ob der Befund aus Aufgabe 3 und 4 sie als „folgenlos löschbar" ausweist.
- [ ] **Step 2: Eine Reihenfolge festlegen, die den Build nach jeder Scheibe grün lässt**
Blätter zuerst, Wurzeln zuletzt: eine Methode, die niemand mehr ruft, lässt sich löschen; eine, die noch gerufen wird, nicht. Die Reihenfolge als nummerierte Liste ins Dokument schreiben, jede Scheibe mit den Methoden, die sie umfasst.
- [ ] **Step 3: Die Zusammenfassung schreiben**
Am Anfang des Dokuments, unter der Überschrift:
```markdown
## Ergebnis in einem Satz
<Von den N init-Methoden sind M folgenlos löschbar; K sind zu entflechten, nämlich …>
## Löschreihenfolge für Teil 2
1. <Scheibe> — <Methoden> — <geschätzte Zeilen>
2. …
```
- [ ] **Step 4: Prüfen, dass keine Lücke bleibt**
Run: `grep -c "zu entflechten\|folgenlos löschbar" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md; grep -n "<" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
Erwartet: jeder Block hat genau einen Topf, und der zweite Befehl findet **keine** spitze Klammer mehr. Findet er eine, ist eine Vorlage nicht ausgefüllt.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
git commit -m "Finish the live-code finding and derive the deletion order
Part 2 of the JavaFX removal is written from this, not from assumption."
```
---
## Was dieser Plan nicht tut
- Er löscht keine Produktionsdatei.
- Er fasst den `UiDispatcher`, den Lebenszyklus und die zehn Dialoge nicht an.
- Er entfernt `org.openjfx` nicht aus dem Build.
Das ist Teil 2, und der wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,286 @@
# Entwurf — JavaFX aus KST4Contest entfernen
Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6,
zweite Hälfte („Entfernung von JavaFX").
Die erste Hälfte — die Karte als Compose-Canvas — ist umgesetzt und abgenommen
(`docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md`). Dieser Entwurf
behandelt ausschließlich, was danach noch zwischen dem Projekt und einem Build ohne
`org.openjfx` steht.
## Ziel
Release 1.50 ohne jede JavaFX-Abhängigkeit. Erfolg ist nachprüfbar:
- `grep -rl "javafx\." core/src/main app-desktop/src/main` liefert nichts.
- Der `org.openjfx`-Block ist aus `app-desktop/build.gradle.kts` und
`gradle/libs.versions.toml` entfernt.
- `./gradlew clean build` bleibt grün (Stand heute: 766 Tests, 0 Fehler).
- Die Anwendung verhält sich unverändert: Ton, Dialoge, Farben, Fenstergrößen.
- Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert
(Abnahmekriterium der übergeordneten Spezifikation).
## Der Befund, der die Etappe umkrempelt
Die naheliegende Lesart — „11.655 Zeilen `Kst4ContestApplication` nach Compose
portieren" — ist falsch.
**Das JavaFX-Hauptfenster wird bei jedem Start vollständig aufgebaut und nie
angezeigt.** `primaryStage.setScene(...)` und `primaryStage.show()` sind beide
auskommentiert (`Kst4ContestApplication.java:9508`, `:9517`). Angezeigt wird
`MainWindowHost.show(...)` (`:10667`), also Compose. Die Szene
`scn_ChatwindowMainScene` wird gebaut (`:7548`), thematisiert und mit
Größen-Listenern versehen, aber an keine Bühne gehängt.
Der Aufbau läuft trotzdem: 93 `new TableView` / `new TableColumn`, Kontextmenüs,
Tastaturhandler. Das kostet Startzeit und hält Listener, ohne je sichtbar zu werden.
Ein zweiter Irrweg, den dieser Entwurf ausräumt: die **CW-Tonausgabe** sieht nach
einem harten Blocker aus, weil `Kst4ContestApplication` `javafx.scene.media.Media`
und `MediaPlayer` benutzt und 91 MP3-Dateien im Projekt liegen. Diese Kopie ist tot —
`playCWLauncher` bei `:6966` ruft niemand. Die **lebende** Ausgabe sitzt in
`core/src/main/java/kst4contest/utils/PlayAudioUtils.java:182`, wird aus
`MessageBusManagementThread.java:1105` gerufen und spielt über JLayer
(`javazoom.jl.player.Player`). Kein JavaFX, keine Ersatzentscheidung, keine neue
Abhängigkeit.
## Die lebende JavaFX-Fläche
| Was | Wo | Ersatz |
|---|---|---|
| Anwendungs-Lebenszyklus | `extends Application`, `launch(args)` (`:9806`), `start(Stage)` (`:7356`) | Compose-Anwendung aus einem einfachen `main` |
| UI-Thread-Zustellung | `JavaFxUiDispatcher` → `Platform.runLater` | AWT-Ereignisfaden |
| Dialoge | 10 × `new Alert(...)` | eigene Compose-Fenster über `ComposeWindowHost` |
| Farbableitung | **bereits erledigt** — siehe Korrektur unten | — |
| Fensterrahmen | `Screen` (Startgröße), Anwendungssymbol | Compose / AWT |
| Kandidatenfenster | eigene `Scene` + `stage.show()` (`:4855`–`:4860`), geöffnet aus dem **Compose**-Pfad (`:10707`) | Compose-Fenster |
| Systemmenüleiste | `installSharedSystemMenuBar(candidatesScene)` (`:4858`), `initMenuBar()` (`:5729`) | hängt am Kandidatenfenster, siehe Etappe 1 |
| Roster-Spiegel | `FxRosterBinding` nutzt `ObservableList`/`FXCollections` | **Status ungeklärt — siehe Etappe 1** |
Dateien mit JavaFX-Importen unter `src/main`, nach Größe:
```
11655 Kst4ContestApplication.java 49 Importe
1777 StationMapView.java 20
1161 PathProfileChart.java 4
480 StationMapBridge.java 5
376 TimelineView.java 11
353 JavaFxStylesheet.kt 1
326 MessageTextTableCell.java 11
280 TableLayoutManager.java 9
136 ComposeMenuActions.java 1
129 FxRosterBinding.java 2
123 GuiUtils.java 4
110 MessageVariableResolverTest.java 1 (Testdatei im main-Quellbaum)
109 TruncatedTextTableCell.java 4
106 ApplicationRuntimeLauncher.java 2
50 LayoutAutosave.java 3
31 JavaFxUiDispatcher.java 1
```
## Der Dreh- und Angelpunkt
`core` kennt JavaFX nicht. Es kennt die Schnittstelle `UiDispatcher`, und
`JavaFxUiDispatcher` ist die einzige Umsetzung, die sie an den JavaFX-Faden bindet.
**Diese 31 Zeilen sind der Hebel.** Wird die Zustellung auf den AWT-Ereignisfaden
umgestellt — dort läuft Compose Desktop ohnehin —, fällt die Thread-Kopplung zwischen
`core` und JavaFX in einem Stück. Lebenszyklus, Dialoge und Fensterrahmen hängen
daran, nicht umgekehrt.
Der Vertrag bleibt derselbe: `runOnUi` stellt zu und führt sofort aus, wenn es bereits
auf dem UI-Faden läuft; `isUiThread` beantwortet dieselbe Frage; eine geworfene
Ausnahme darf den Faden nicht reißen. Diese drei Punkte sind testbar, und genau das
macht den Austausch beherrschbar.
## Vorgehen: erst löschen, dann umhängen
Abgewogen wurden drei Wege.
**Erst umhängen, dann löschen** wurde verworfen: der Eingriff am Lebenszyklus fände
genau dann statt, wenn die Datei am unübersichtlichsten ist. Das Nebenwirkungsrisiko
wäre dort am höchsten, wo es am schlechtesten zu sehen ist.
**Zweiter Einstiegspunkt daneben**, umschaltbar, wurde verworfen: zwei Lebenszyklen in
einem Prozess mit globalem Zustand (`ApplicationRuntimeLauncher`, Profilwechsel), und
der Schalter wird selbst zur Sache, die gepflegt und getestet werden muss.
**Gewählt: erst löschen, dann umhängen.** Der tote Aufbau ist das Volumen und zugleich
das Risikoärmste. Danach steht eine kleine, lesbare Datei, und erst darin die
schwierige Operation.
## Etappen
### 1 — Feststellen, was lebt
Keine Löschung. Ergebnis ist eine belegte Liste.
Zu klären ist für jeden Teil des nie gezeigten Aufbaus, ob er Nebenwirkungen hat, auf
die der laufende Betrieb angewiesen ist. Drei Verdachtsfälle sind benannt:
- **`FxRosterBinding`** (`chatMemberListBinding`, erzeugt bei `:2513`, genullt bei
`:6877`). Es spiegelt den Roster in eine `ObservableList`. Speist es noch etwas, das
die Compose-Oberfläche liest, oder nur die tote Tabelle?
- **`registerThemedScene`** — drei Aufrufe. Hängt die Themenumschaltung der
Compose-Fenster daran?
- **`LayoutAutosave`** (`:7420`) — schreibt es Layoutwerte, die auch die
Compose-Fenster benutzen?
Dazu die veralteten Begründungen, die beim Lesen auffallen: der Kommentar bei
`:10446` rechtfertigt eigene Spalten-IDs damit, dass „beide Fenster auf dem Bildschirm
sind". Das stimmt seit dem Abklemmen nicht mehr.
**Abnahme:** eine Liste, die jeden Block des toten Aufbaus einem von zwei Töpfen
zuordnet — „folgenlos löschbar" oder „zu entflechten", letzteres mit der Stelle, die
davon abhängt.
### 2 — Toten Aufbau löschen
In Scheiben, jede für sich gebaut und geprüft. Eine Scheibe ist ein zusammenhängender
Block: die Stationstabelle, die Nachrichtentabellen, die Kontextmenüs, die
Filterleiste, die Szene samt Listenern.
Mit fällt die tote CW-Ausgabe (`:6966` ff., `musicList`, `mediaPlayer`) und damit der
Import von `javafx.scene.media`. Die 91 MP3-Dateien in `core/src/main/resources`
**bleiben** — sie gehören der lebenden Ausgabe in `PlayAudioUtils`.
**Abnahme:** `./gradlew clean build` grün nach jeder Scheibe; die Anwendung startet und
verhält sich unverändert; `Kst4ContestApplication` ist auf die lebende Fläche
zusammengeschrumpft.
### 3 — Den `derive`-Pin einfrieren
**Korrektur gegenüber der ersten Fassung dieses Entwurfs.** Dort stand, `derive` rufe
JavaFX über Reflexion und müsse ersetzt werden. Das ist falsch, und die übergeordnete
Spezifikation ist an dieser Stelle inzwischen veraltet: `JavaFxStylesheet.derive`
(`JavaFxStylesheet.kt:131` ff.) ist eine vollständige, JavaFX-freie Umsetzung. Das
einzige Vorkommen von „javafx" in der Datei steht in einem Kommentar, der noch die
alte Reflexionsfassung beschreibt.
Mehr noch: die Umsetzung ist bereits gegen echtes JavaFX gepinnt.
`JavaFxStylesheetTest.kt:165`–`:170` ruft `com.sun.javafx.util.Utils.deriveColor` per
Reflexion **im Test** und prüft, dass beide Fassungen übereinstimmen (`:48`–`:50`).
Die Reflexion sitzt also dort, wo sie hingehört.
Was bleibt, ist klein und trotzdem zeitkritisch: **dieser Vergleich hört auf zu
funktionieren, sobald JavaFX weg ist.** Der Test fiele dann stillschweigend auf die
Prüfung gegen sich selbst zurück. Die von JavaFX gelieferten Werte müssen vorher als
Daten eingecheckt werden, damit der Pin das Entfernen überlebt.
Der veraltete Kommentar bei `:116`–`:129` wird mitkorrigiert.
Zur Einordnung: die übergeordnete Spezifikation hält fest, dass eine freihändige
Nachbildung in Etappe 3b nachweislich falsch war: die Helligkeit, von der JavaFX
ableitet, ist nicht der HSB-Wert, sondern `0.3R + 0.59G + 0.11B`, und der positive
Zweig folgte keiner naheliegenden Formel.
Vorgehen: solange JavaFX im Prozess ist, die Paare, die der Test heute zur Laufzeit
von `deriveColor` holt, über den benutzten Wertebereich erzeugen und als Testdaten
einchecken. Der Test vergleicht danach gegen diese Daten.
**Abnahme:** die von JavaFX gelieferten Werte liegen als eingecheckte Daten vor; der
Test prüft `derive` gegen diese Daten und nicht mehr gegen einen Reflexionsaufruf; er
bleibt grün, wenn JavaFX nicht auf dem Klassenpfad ist.
### 4 — `UiDispatcher` umhängen
`JavaFxUiDispatcher` → eine Umsetzung auf dem AWT-Ereignisfaden.
**Abnahme:** Tests decken Zustellung von einem Fremdfaden, sofortige Ausführung auf
dem UI-Faden, korrekte Thread-Erkennung und ab, dass eine geworfene Ausnahme den Faden
nicht reißt. Die Anwendung läuft unverändert.
Dies ist die risikoreichste Etappe: `core` stellt über diese Schnittstelle **jede**
UI-sichtbare Änderung zu. Deshalb steht sie hinter dem Aufräumen und vor dem
Lebenszyklus.
### 5 — Lebenszyklus, Dialoge, Rahmen
- `Kst4ContestApplication extends Application` und `launch(args)` weichen einem
einfachen `main`, das die Compose-Anwendung startet.
- Die zehn `Alert`-Aufrufe werden eigene Compose-Fenster über `ComposeWindowHost`,
modal, wo sie es heute sind: die Bestätigungen liefern eine Antwort, die den
weiteren Ablauf steuert, und müssen weiter blockieren.
- `Screen` für die Startgröße und das Anwendungssymbol gehen an Compose beziehungsweise
AWT.
- Das **Kandidatenfenster** (`:4855`–`:4860`) wird ein Compose-Fenster. Es ist die
einzige JavaFX-Bühne, die tatsächlich erscheint, und sie wird aus dem Compose-Pfad
geöffnet (`:10707`): der „more"-Knopf neben den Prioritätsknöpfen. Solange es steht,
kann keine JavaFX-Abhängigkeit fallen.
**Abnahme:** die Anwendung startet ohne JavaFX-Bühne; jeder der zehn Dialoge erscheint
an derselben Stelle im Ablauf wie bisher und mit derselben Wirkung auf den Ablauf.
### 6 — Reste löschen und den Build befreien
- `StationMapView.java`, `StationMapBridge.java`, `MapHtmlResources.java` und die
Leaflet-Ressourcen unter `app-desktop/src/main/resources/web/leaflet/`.
- `PathProfileChart.java` und `TimelineView.java` — Compose-Gegenstücke existieren.
- `MessageTextTableCell.java`, `TruncatedTextTableCell.java` — kein Compose-Nutzer.
- `JavaFxUiDispatcher.java` — fällt in jedem Fall, sein Ersatz entsteht in Etappe 4.
- `FxRosterBinding.java`, `GuiUtils.java`, `LayoutAutosave.java`, soweit Etappe 1 sie
als entbehrlich ausweist.
- `MessageVariableResolverTest.java` liegt im **main**-Quellbaum und gehört nach
`src/test`.
- `app-desktop/build.gradle.kts` Zeilen 3, 9–11 und 19; `gradle/libs.versions.toml`
Zeilen 2–3 und 28.
**Eine Falle:** `TableLayoutManager.java` sieht genauso tot aus, wird aber von sechs
Compose-Dateien benutzt. Er muss seine JavaFX-Importe verlieren, nicht gelöscht werden.
`TileProxyServer` in `core` verliert mit `StationMapView` seinen einzigen Nutzer. Er
war eine Antwort auf TLS-Probleme der JavaFX-WebView in AppImage- und
Flatpak-Verpackung; der Compose-Canvas holt seine Kacheln selbst. Mit entfernen.
**Abnahme:** die drei Prüfungen aus dem Abschnitt „Ziel".
## Prüfung
Das Netz, auf das diese Etappe sich stützt, ist seit heute belastbar: `./gradlew clean
build` ist grün (766 Tests, 0 Fehler), erstmals seit dem 30. September. Jede
Rotfärbung während dieser Arbeit ist damit ein Signal und kein Rauschen.
Dazu kommen:
- die gepinnte `derive`-Tabelle (Etappe 3),
- echte Tests für den neuen Dispatcher (Etappe 4),
- die Compose-Layouttests aus der Kartenetappe, die die Fenster von 900×600 bis 1×1 px
abdecken,
- nach jeder Löschscheibe ein Start der Anwendung, weil kein Test beantwortet, ob ein
entfernter Listener gefehlt hat.
## Was unverändert bleibt
- Die Fachlogik in `core` und sämtliche Protokollformate.
- Die **Schnittstelle** `UiDispatcher`; nur ihre Umsetzung wird getauscht.
- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher und die Listen
sind Spiegel; kein Worker-Faden fasst UI-Sammlungen an; Zoom, Auswahl, Fokus,
Sortierung und vorbelegter Text ändern sich nicht als Nebeneffekt.
- Die 91 MP3-Dateien und die CW-Ausgabe in `PlayAudioUtils`.
- Verpackung und Vertrieb, soweit sie nicht am JavaFX-Modulpfad hängen.
## Risiken
**Etappe 1 kann das Vorgehen kippen.** Ergibt die Prüfung, dass der „tote" Aufbau
lebende Nebenwirkungen hat, wird aus Löschen ein Entflechten und die Etappe wird
länger. Das ist der Grund, warum die Prüfung eine eigene Etappe ist und keine Annahme
im Plan.
**Der Dispatcher-Tausch wirkt überall.** Ein Fehler darin zeigt sich nicht als
Übersetzungsfehler, sondern als sporadisch nicht aktualisierte Oberfläche. Deshalb die
Tests für den Vertrag und nicht nur für die Anwendung.
**Die Dialoge sind Ablaufsteuerung, keine Dekoration.** Wer eine Bestätigung
nicht-blockierend macht, ändert den Ablauf. Jeder der zehn Aufrufe ist einzeln zu
lesen.
## Offene Punkte
- Der Status von `FxRosterBinding` ist Gegenstand von Etappe 1 und wird hier bewusst
nicht vorweggenommen.
- Ob `ApplicationRuntimeLauncher` und `ComposeMenuActions` nach dem Wegfall der Bühne
noch eine JavaFX-Berührung behalten, entscheidet sich in Etappe 5.
- Die macOS-Systemmenüleiste ist eine JavaFX-`MenuBar` (`:172`, `:5729`) und wird auf
die Szene des Kandidatenfensters gesetzt (`:4858`). Ob sie darüber hinaus an etwas
hängt — und was von ihr auf macOS tatsächlich sichtbar ist, seit das Hauptfenster
Compose ist — gehört in Etappe 1.