Files
kst4contest/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
T
Rsclub2_2andClaude Opus 5.5 2a5c89b943 Next major release/version1.50 (#89)
* Make DBController work on one database file per instance

The controller was a static singleton: an eagerly created static instance
opened the root database during class initialization, and both the connection
and the path were static fields. A second operator profile in the same process
was therefore impossible, and ChatController's own "new DBController()" never
opened anything - it silently adopted the eagerly opened root connection.

- drop the eager static instance in favour of a lazily created default instance
- turn connection and path into instance state
- add a constructor taking a database file name relative to the application
  directory plus a flag whether a missing file is seeded from the bundled
  template
- create additional profile databases empty instead of seeding them: the
  bundled template carries 3452 foreign callsigns and user_version 0, which
  would show a new operator foreign data and trigger the full callsign
  normalization rebuild. The schema is created by the existing table setup.
- remember the shutdown hook so closeDBConnection can deregister it; otherwise
  every profile switch would leave another hook holding a dead connection

All SQL statements keep referencing the plain field and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Allow preferences and chat controller to be bound to a file set

ChatPreferences gains a constructor taking a preferences file name relative to
the application directory, so "profiles/OP2/preferences.xml" is as valid as the
historic flat "preferences.xml". A missing file is still seeded from the
bundled template, which gives an additional operator the same clean defaults a
first installation gets.

ChatController gains a constructor that passes both relative file names and the
seed flag through to ChatPreferences and DBController. The existing
constructors delegate to the historic file names, so nothing changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Introduce the operator profile model and its registry

Adds the descriptor, the path derivation and the registry persistence for
operator profiles. Nothing calls them yet, so behaviour is unchanged.

The descriptor stores only a shared/own flag, never a path. All file names are
derived in OperatorProfilePaths, so a stored path can never drift apart from
the flag that produced it. A profile identifier is a stable, file system safe
slug assigned once, so renaming a profile never moves a directory.

The registry is created lazily. An installation that only has the historic flat
layout gets no registry file and no profiles directory; the root profile is
synthesised in memory instead. That keeps a single operator installation
byte for byte the one it was before, and it keeps a downgrade to an older
release a no-op. A missing, unreadable or malformed registry is logged and
treated like an installation without additional profiles, never as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Select the operator profile at startup

Resolves the active operator profile before the chat controller is built and
passes its two file names on, so preferences, layout and worked data follow the
profile.

The resolution is deliberately quiet for existing installations. With no
registry or exactly one profile nothing is asked and nothing is written, so a
single operator start is unchanged. Only from two profiles on does a small
picker appear with the last used profile preselected, where Enter or a double
click starts immediately. A "--profile" argument, or the equivalent system
property, skips the picker; an unknown name warns and falls back to the normal
selection instead of refusing to start.

The startup decision itself lives in OperatorProfileBootstrap and contains no
user interface code, so it is covered by headless tests. The window title gains
the profile name only when a second profile exists.

Command line parsing happens in init() and is kept in a process wide holder,
because JavaFX only knows the parameters of the instance it launched itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Release the background resources a discarded runtime owns

Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Switch the operator profile while the application is running

Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Manage operator profiles from the settings window

Adds a "Profiles" tab, appended last so no established tab position shifts,
offering create, duplicate, rename, delete, a switch between shared and own
worked stations, and activation of another profile.

Duplicating copies the whole configuration except callsign and password. The
antenna, locator, layout and integration settings are exactly the work nobody
wants to enter twice, while the credentials belong to one operator only. The
root profile can neither be deleted nor moved off the common station database,
because its files are the installation itself.

The login callsign and its raw form now default to empty instead of a real
callsign. The XML reader treats an empty element as "not set" and falls back to
the field default, so without this change a profile created without credentials
would come up carrying the callsign compiled into the defaults - and an
operator could transmit under someone else's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Document operator profiles

Adds a manual section in both languages covering where profile files live, the
choice between shared and own worked stations, managing profiles, the startup
selection and the --profile argument, switching while running, and the fact
that passwords stay in clear text so profiles are not an access boundary.

Records the architecture in PROJECT_CONTEXT: lazy registry, derived paths, why
an additional profile database is created empty, why the login callsign default
is empty, and the constraints of rebuilding the runtime for a switch.

Adds a v1.50 changelog entry in both languages including the upgrade notes: no
file is moved, and going back to an older release stays possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Make the terrain profile cache multi operator safe

The cache stored a single owner identity in a meta table and dropped the whole
TerrainProfileCache table whenever the configured callsign or locator differed
from it. With several operator profiles that turns the cache into a permanent
miss: every switch between two operators with different locators would discard
every computed profile.

Entries are separated by owner identity through the primary key already, so the
wipe is replaced by an owner table that simply records which identities are in
use. A different owner now misses the cache instead of clearing everybody's.

The cache also moves out of the worked station database into its own global
terrainprofilecache.db. Terrain profiles are pure geometry derived from two
locators and a sample count; at a multi operator station both operators share
one location, so a per profile copy would only double the traffic against the
terrain service. No migration is needed, the new file refills itself, and the
old tables stay readable for older releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Fail cleanly when the registry path has no directory

SpotBugs flagged a null passed to Files.createTempFile: the null check guarded
only createDirectories, while the temporary file creation would still have
dereferenced it. The path is resolved absolute so this is practically
unreachable, but bailing out with a log line is cheaper than the latent NPE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm

* Code Version 1.50

* Add the in-app update concept for 1.50

Captures the agreed design so it can be handed over later: extended
update feed with per-artifact metadata, a build-time package-type
marker, a testable check service replacing the blocking call in the
ChatController constructor, a non-modal hint suppressed while connected
to ON4KST, verified downloads and platform-specific execution.

Concept only, nothing implemented yet. Line anchors were verified
against this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf

* Use one base font size and a per-profile startup design

JavaFX picks a 13px default font on macOS and Linux but 12px on
Windows, so labels in fixed-size controls were cut off outside
Windows. Both stylesheets now set 12px on the root.

Each operator profile now chooses light or dark mode for startup on
the GUI settings tab. The Windows menu entries remain a quick switch
for the running session. All windows, including the update window
and the top candidates list, follow the design, and the main window
no longer starts light while the station map starts dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show a drag-to-install layout in the macOS DMG

The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the macOS system menu bar in all windows

On macOS the main menus move into the system menu bar and are shared
with the secondary windows, so they stay available whichever window
has the focus. The connection state indicator becomes a read-only
LINK menu there instead of the status bar button. Other platforms
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List direct commits on main in the release notes

GitHub's generated release notes only cover merged pull requests, so
commits pushed straight to main were missing. The release job now
builds the notes itself: "What's Changed" holds "Commits on main"
(first-parent, without bot and squash-merge commits) and GitHub's PR
list as "Merged PRs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.

* Draw the whole interface in Compose

Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.

* 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.

* Draw the menu bar in Compose, and give the drawing layer a net

What the removal left open (4 commits).

Compose's MenuBar is a javax.swing.JMenuBar set on the window, so Swing draws
it and no theme reaches it; with no look-and-feel configured that meant the
default Metal menu above a themed window. The bar is drawn in the window now,
except on macOS, where the system bar is where that platform expects it. Two
renderers read one description -- mainMenuModel is a plain function of the
state, which also gives the menus their first tests. Closing the main window
quits the application again, and the window writes its size back to the
profile; both had quietly stopped working when the last JavaFX stage went.

Then Etappe 7. Four faults shipped in the settings-window branch alongside a
green suite of 382 tests, because not one of those tests drew anything: a
control that never redrew, a tab strip that clipped mid-word, a form taller
than its window with no way down, and colours that came from Material rather
than the stylesheet. One regression test per class, and the spec's acceptance
criterion is met rather than assumed -- each fault was put back deliberately
and the matching test watched to fail.

The CI had never run the suite at all; it does now, headless. What that took
was not Xvfb: skiko's native library links against libGL and only has to be
loadable, since the drawing tests render in software. Without the package the
whole Compose half of the suite dies with a message that reads like a missing
display. Found by running the job under act rather than on GitHub.

The six tests that open a real window skip headless by their own guard. The
summary step counts from the result XML and fails on failures there, because
this project's rule is never to infer a green suite from an exit code.

* Make the colours adjustable per operator profile

Six colour roles per design, per profile: surface, window surface, field interior,
text, accent and separator line. A change applies at once in every open window and
survives a restart once the settings are saved, like any other preference.

Three sources, later wins role by role: the shipped stylesheet from the classpath,
then the profile's own stylesheet file if there is one, then the overrides stored in
the profile's preferences. resolvePalette is a pure function and reports where every
role's colour came from, which the tab shows per field -- with two sources able to
decide one colour, that is the only thing keeping "why is this colour what it is"
answerable, and the spec calls it mandatory rather than decorative.

Four things this had to get right, each learned the hard way and each pinned:

A role is a selector and a property, not a property alone. Three of the six are
stated outside .root -- text on .label, the separator on .separator *.line -- and
-fx-background-color alone appears a dozen times in the evening sheet. Measured: the
shipped daylight sheet states no colour at all, the evening sheet states five and
implies the sixth.

Kst4ContestTheme reads the palette from the store's state and not from remember.
That read is what makes a change repaint every open window; remember(darkMode) held
the palette until somebody toggled day/evening, so a change reached nothing.

A derived colour follows its source's value, never its provenance. The root
profile's own stylesheet is the one copyResourceIfRequired writes at every startup --
the shipped sheet itself -- so layer 2 declares the accent on every existing
installation with nobody having chosen anything. Keying textAccent on "does this come
from a file" repainted the evening accent from green to blue on upgrade and made
"reset to shipped" disagree with "discard my changes".

The contrast guard measures where colours are actually drawn, read off Theme.kt's
Material mapping rather than off the role names: background and surface are both the
window surface, and primary is textAccent, not accent. It reports a pair only when it
is both under WCAG AA and worse than the shipped sheet -- the shipped sheets have
three pairs under 4.5 themselves, so an absolute floor would warn on an untouched
client and teach the operator to ignore warnings.

The way back out of an unreadable palette is deliberately overbuilt, because it is
the one failure that locks itself in. "Reset to the shipped colours" takes the
shipped palette as a required parameter and reads no composition local, so there is
no path by which it could be drawn in the colours it exists to undo; it looks out of
place under a customised palette, and that is what makes it findable. Ctrl+Z, Ctrl+D
and Ctrl+R reach the three ways back even with the caret in a colour field. A
mistyped colour is refused and the field snaps back.

The profile's stylesheet is the hand-editing route the copy under ~/.praktiKST has
been promising since it was first written and never read. It is read now. A button
writes the shipped template as a starting point and never replaces a file that is
already there. An unreadable, truncated or non-CSS file does not prevent startup --
the role falls back to the shipped design. A sheet stating -fx-base and no
-fx-background states the derived window surface too, the way JavaFX does, or the
most natural hand edit would leave every window the shipped light grey.

101 new tests. All of it runs headless.

* Document the adjustable colours, and record the design and the plan

Both manuals get the Colours tab in full: the six roles, that a change applies at
once but survives a restart only after Save settings, the three ways back with their
keyboard shortcuts, what each provenance label means, and the readability notice that
warns without refusing.

Two things an operator would otherwise have to discover. The reset button is always
drawn in the shipped colours and works when nothing else can be read -- the risk
section of the design says plainly that a keyboard path only helps whoever knows it,
so the manual is where this has to be written down. And the root profile already has
both stylesheet files, because KST4Contest has been writing them at startup for
years, so most of its roles report "from file" rather than "shipped" with identical
values.

PROJECT_CONTEXT.md records the decisions that outlive this stage: the three sources
and their precedence, that a role is a selector and a property, that the theme must
read the store's state rather than remember, where the contrast pairs come from, why
the root profile's file layer always exists, and why the lifeline refuses the active
palette. Also corrects its claim that CSS stays global -- a profile now owns its
stylesheets.

The design document and the implementation plan are kept as they were written,
including the plan's own corrections where measuring the code contradicted it.

* Speak the operator's language

The interface is German or English, chosen per operator profile on the GUI tab, in force at
once in every open window, and remembered across restarts. 376 texts in both languages.

The texts are ordinary .properties files that nobody has to compile: a contributor copies the
English one, translates the values, and opens a pull request. A third module -- build tooling
that never ships -- turns them into Kotlin before anything compiles, which is what makes a
mistyped key and a wrong argument count compile errors rather than things an operator
discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run
on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it
exists to prevent.

Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to
English, so a partial contributed language can be submitted at all; an orphan key, a
placeholder the translation drops, a duplicate key or an unreadable file fail the build with
the generator's own message naming the file. The argument count always comes from the English
base. German is held complete by a test because it ships; a contributed language is not.

What is deliberately not translated is the harder half. Protocol frames and chat commands
would break the connection or go unread; the beacon texts, auto answers, snippets and
shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273
println calls in core are diagnostics, and a German stack trace is worse for whoever reads the
bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three
tests guard the boundary by measuring behaviour rather than text: the protocol frames built
under three locales, every preference default compared by reflection, and no translation value
being a protocol command. The reflective one is the only one that covers what nobody thought
of.

One insight turned up three times. Anything built outside a composition freezes the language it
was built in: the window title, the settings tab strip and every column header are all
constructed once at startup -- the columns from Java -- so each now holds a function read when
it is drawn. That also fixed something older and unrelated to language: the main window's title
was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried
the live connection line. It carries it again.

A column's id is never translated. It is what the remembered widths and order are keyed on.

* Document the two languages, and how to contribute a third

Both manuals get the language picker in full: where it sits, that it applies per profile and
takes effect at once in every open window, and -- at more length -- what is deliberately left
alone and why. An operator who sees an English beacon text in a German client should be able
to read that it is meant that way.

The contribution route is written down in both, because plain text files are only useful if
somebody knows they are there: copy, translate, submit; a partial file is welcome; the build
reports mistakes by file name and writes a worksheet of what is still missing.

PROJECT_CONTEXT records the decisions that outlive this stage -- the four kinds of text and
the rule that the addressee decides, that core produces no display text, that incompleteness
is allowed and incorrectness is not, that anything built outside a composition needs its text
read inside one, and that a column id is never translated.

CLAUDE.md said two modules; there are three, and the third never ships. It also states the
German orthography now, which this session got wrong across a spec and two plans: ss after a
short vowel, so muss and dass.

* Remove the implementation plans now that every stage is executed

Thirteen plans, all of them step-by-step instructions for work that is committed. A plan left
behind reads to the next person like an open intention, and these describe a build that no
longer exists -- the first still explains how to introduce Gradle.

The specifications stay. They say why the architecture is what it is, which the code cannot,
and three of them are still argued from. The live-findings note stays too: CLAUDE.md sends
people to it, and its hardest lesson -- that a blocking wait on the AWT event thread deadlocks
the interface while the build stays green -- is written down nowhere else.

Its sweep script stays as well. The note argues explicitly that the script belongs beside the
finding so the next reader can measure rather than believe, and removing it would contradict
the reasoning it illustrates.

Two references in the note and one in the JavaFX-removal spec pointed at plans and now say
where the reasoning went instead.

* Point the JavaFX-removal spec at where its plan's reasoning went

* Remove the stray release-metadata patch from the working tree

A 416-line patch file that was applied long ago and has sat in the repository root since. It
predates this branch and stays in the shared history, where rewriting it would mean rewriting
commits that are already on main and on origin.

* Check the translation files in CI and say what they hold

classes already runs the generator, so an orphan key, a dropped placeholder
or a duplicate fails the compile job with the generator's own message naming
the file -- verified by breaking strings_de.properties on purpose.

A step summary counts each language against the base, so the author of a
translation PR sees what they covered without reading the build log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Refuse a translation key that would not form a Kotlin name

The generator escaped every translation value but emitted the key verbatim as
the member identifier, and identifierFor strips only . _ - -- so a key carrying
a space, a brace, a quote or a newline from a \uXXXX escape broke out of
val <name>: String get() = lookup(...) into an init block or property
initializer that runs when Strings is instantiated. Translation files are the
contribution surface that arrives by pull request, so this was a path from a
merged .properties change to code in every client.

The generated identifier is now held to letters-then-letters-and-digits before
any source is written, alongside the existing orphan and collision refusals.
Proven by replaying a file-writing payload against the built generator: it now
throws and writes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Port the rebased diagnostics and session logging onto the modules

origin/main landed replayable session logging (MessageHistoryRecorder), file
logging (ApplicationFileLogging) and a user-list refresh split on the old flat
layout. The rebase carried the new files, but three things needed the new
architecture:

- ChatController.fireUserListUpdate() used javafx Platform; core is toolkit-free
  now, so it dispatches through uiDispatcher like every other fire method.
- Two new tests were left in the old src/test/java; moved into core.
- ScoreServiceRefreshTest mocked javafx Platform statically; it now injects the
  DirectUiDispatcher the no-arg constructor installs.

The Leaflet map performance work was dropped with the HTML map it optimised.

classes testClasses and test are green: 1069 tests, 0 failures, 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the debug-to-file recording a home in the Compose settings

origin/main added a checkbox to the old JavaFX settings that recorded the whole
ON4KST session to file; the Compose redraw dropped the old settings UI, so the
toggle had no control left even though the preference and its wiring survived
the rebase.

It returns to the message-handling tab: the state writes the preference and asks
the application, through a new SettingsHost callback, to open or close the
error-log handler and the history recorder in the order the old checkbox kept.
The explanatory line and its German translation say that passwords are redacted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix Codex review findings on PR 89: station row key, map selection, sort after filter

- Station row key now includes the chat category, so the same callsign in two
  categories no longer collides.
- Map marker click and Reset view go through the central station selection.
- Sorting now operates on the filtered rows, so filters stay effective.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the map drawing test off the network and the user's tile cache

The test assumed no tile ever arrives, but it used the default TileFetcher,
which reads the real disk cache and the OSM tile server. On CI a tile now and
then landed before the frame was rendered and covered the background the
'unreachable tile source' test asserts on. An empty temporary cache and a
source that always fails make the assumption hold by construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 23:22:11 +02:00

40 KiB
Raw Blame History

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 Controls 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. Behoben in Etappe 4/5; der Plan ist nach seiner Ausführung entfernt worden, die Begründung steht in der Commit-Nachricht und hier.

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-AlertTypes 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. Behoben in Etappe 4/5; der Plan ist nach seiner Ausführung entfernt worden, die Begründung steht in der Commit-Nachricht und hier.

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-MenuItems 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:

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 nullmal 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.