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>
This commit is contained in:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-08 23:22:11 +02:00
1 parent 3e8457eede
commit 2a5c89b943
600 files changed
+46019 -23704

No files matched your search

@@ -0,0 +1,670 @@
# 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.
> 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-`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.
> 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-`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,490 @@
# Konzept: Ablösung von JavaFX durch Compose Multiplatform
> Status: Konzept, noch nicht umgesetzt. Zielrelease **1.50**. Alle Zeilenanker beziehen sich auf `nextMajorRelease/version1_50` und wurden gegen den Branch verifiziert (Stand 2026-09-25).
## Ziel
KST4Contest verlässt JavaFX und wird auf Compose Multiplatform umgestellt. Ergebnis ist eine wartbare, optisch aufgefrischte Desktop-Oberfläche bei **unverändertem Funktionsumfang**. Der fachliche Kern wird dabei von jeder UI-Technologie gelöst.
Drei Treiber, gleichrangig:
1. Die Oberfläche soll modern aussehen und sich modern anfühlen.
2. Der UI-Code soll wieder änderbar sein.
3. Die JavaFX-Abhängigkeit soll verschwinden.
## Nicht-Ziele
- **Kein UX-Neuentwurf.** 1.50 ist ein funktionsgleicher Port. Bedienabläufe, Fensteraufteilung und Funktionsumfang bleiben erhalten. Ein echter Neuentwurf der Bedienung ist Thema für 2.0.
- **Keine Mobile-App.** Die Modulgrenze wird so gezogen, dass ein späteres Mobile-Repo den Kern abgreifen könnte. Mehr nicht. Mobile ist in diesem Konzept Randbedingung, kein Lieferergebnis.
- **Keine Kotlin-Konvertierung des Kerns.** Siehe „Bestätigte Festlegungen".
- **Keine Protokolländerungen.** Kein Framing, kein Port, kein Transport, keine Normalisierung wird angefasst.
## Ausgangslage
Gemessen am Branchstand:
| Kennzahl | Wert |
|---|---|
| Java gesamt | 61.572 Zeilen, 196 Dateien |
| Kotlin | 0 Dateien |
| Dateien mit direktem JavaFX-Bezug | 33 |
| `Kst4ContestApplication.java` | 13.564 Zeilen, 387 Methoden |
| davon reines JavaFX-Gerüst | 82 `call()`-Cell-Factories, 64 `changed()`-Listener, 77 `handle()`-Eventhandler |
| `Platform.runLater` / `ObservableList` | 51 / 93 Vorkommen |
| Tests | 43 Dateien, 148 Testmethoden |
### Die JavaFX-Kopplung ist flacher als die Zahlen vermuten lassen
- `ChatMember`: **ein** gekoppeltes Feld, `StringProperty frequency` (`ChatMember.java:42`, Zugriff `:510`, `:516`, Neuanlage `:616`). Der Rest der Klasse ist reines Java.
- `ChatPreferences`: fünf Properties (`actualQTF` `:228`, `MYQRGFirstCat` `:266`, `MYQRGSecondCat` `:268`, `notify_optionalFrequencyPrefix` `:296`, `notify_DXCSrv_SpottersCallSign` `:298`) und vier `ObservableList<String>` für Listeneinstellungen (`:307`, `:308`, `:324`, `:325`).
- `ScoreService`: bereits sauber getrennt. Es existiert ein Snapshot-Modell, `topCandidatesFx` (`:45`, `:62`) ist explizit als FX-Projektion benannt, und der Klassenkommentar hält „No per-member Platform.runLater flooding" fest.
- `MessageBusManagementThread`: eine einzige Methodensignatur nimmt `ObservableList` entgegen (`:577`).
- `ChatController` ist der Brocken: sieben `ObservableList`-Felder (`:1164`, `:1742`, `:1759`, `:1768` mit der synchronisierten Hülle `:1775`, `:1776`, `:1777`, `:1817`) und eine `FilteredList`/`SortedList`-Pipeline (`:1745`, `:1749`, `:1751`, `:1756`, `:1772`, `:1773`).
Die Klasse dokumentiert ihr eigenes Muster bereits: der Kommentar ab `ChatController.java:1783` begründet, warum eine threadsichere Map die primäre Quelle ist und die `ObservableList` nur die gebündelte Projektion. Die hier vorgeschlagene Trennung setzt das fort, statt es zu erfinden.
### Die Karte hängt nur flach an Leaflet
`MapHtmlResources.java` nutzt aus der Leaflet-API lediglich `L.map`, `L.tileLayer`, `L.marker`, `L.divIcon`, `L.circleMarker`, `L.polyline`, `L.polygon`, `L.rectangle`, `L.layerGroup`, `L.latLng` und `L.latLngBounds`. Geometrie (`PathGeometryUtils`), Gitterplanung (`MaidenheadGridRenderPlanner`), Kachelbeschaffung (`TileProxyServer`), Terrain und Pfadanalyse liegen bereits vollständig in Java.
### Weitere betroffene Stellen
- `javafx.scene.media` wird ausschließlich in `Kst4ContestApplication.java` benutzt (`:33`, `:34`, `:6599`, `:6900`). MP3-Wiedergabe läuft ohnehin schon über `jlayer` in `PlayAudioUtils`.
- Lombok ist in `pom.xml` als Abhängigkeit deklariert, wird aber in keiner Quelldatei verwendet.
- `src/main/java/module-info.java` bindet das Projekt an JPMS; `pom.xml` enthält eine Enforcer-Regel, die die jpackage-Modulliste gegen Drift absichert.
## Bestätigte Festlegungen
| Punkt | Entscheidung |
|---|---|
| Zielrelease | 1.50. Die Compose-Migration findet auf `nextMajorRelease/version1_50` statt; 1.50 wird das Compose-Release |
| Funktionsumfang | Funktionsgleicher Port. UX-Neuentwurf frühestens 2.0 |
| Sprache Kern | `core` bleibt **Java 21**. Kotlin nur dort, wo Compose es erzwingt |
| Sprache UI | Kotlin, `app-desktop` |
| Build | Maven wird durch **Gradle** ersetzt. Compose Multiplatform wird offiziell nur mit Gradle unterstützt, Desktop-Packaging inbegriffen |
| Modulsystem | `module-info.java` entfällt. Compose Multiplatform liefert keine echten JPMS-Module |
| Packaging | Etappe 1 behält den direkten `jpackage`-Aufruf, nur aus Gradle statt aus Maven. Die Umstellung auf `compose.desktop.nativeDistributions` erfolgt in Etappe 3, wenn Kotlin und Compose ohnehin einziehen. DMG-Drag-Layout, Icon-Erzeugung, Flatpak und AUR werden dabei nachgezogen |
| Karte | Native Compose-Canvas-Umsetzung. Kein eingebettetes Chromium (KCEF/JCEF) |
| God-Klasse | `Kst4ContestApplication` wird in Phase 1 **nicht** zerschnitten. Compose löscht den Widget-Code ohnehin |
| Mobile | Nur als Modulgrenze berücksichtigt, nicht umgesetzt |
| Lombok | Entfällt (ungenutzt) |
| Entwickler | Marc (DO5AMF) und Philipp (DN9APW). Kein Termin |
## Zielarchitektur
```
kst4contest/
├── core/ Java 21, KEIN JavaFX, KEIN Compose
│ ├── model/ ChatMember, ChatMessage, ChatPreferences, Band …
│ ├── controller/ ChatController, MessageBus, Score, Reachability …
│ ├── net/ ON4KST, AirScout, Win-Test, UCXLog, PSTRotator, DXCluster
│ ├── map/ Geometrie, Terrain, Kachel-Proxy, Pfadanalyse
│ ├── audio/ Wiedergabe hinter einem Interface
│ └── observe/ Beobachtungs-Abstraktion
└── app-desktop/ Kotlin + Compose Multiplatform
├── ui/ Fenster, Panels, Tabellen, Dialoge
├── state/ UI-Zustand, Projektionen aus core
└── map/ Compose-Canvas-Karte
```
`core` hängt von keiner UI-Technologie ab. Das ist die Grenze, an der parallel gearbeitet werden kann, und dieselbe Grenze, die ein späteres Mobile-Repo abgreifen würde.
### Das Paket `core/observe`
Bewusst minimal. Kein Reaktiv-Framework, keine Bibliothek.
- **`ObservableValue<T>`** — `T get()`, `void addListener(Consumer<T>)`, `void removeListener(Consumer<T>)`.
- **`MutableValue<T> extends ObservableValue<T>`** — zusätzlich `void set(T)`.
- **`ObservableRoster<T>`** — **snapshotbasiert**: `List<T> snapshot()` liefert eine unveränderliche Liste; Listener erhalten die neue Liste. Es gibt **kein** inkrementelles Änderungsprotokoll. Das passt zum Neuzeichnungsmodell von Compose und ist ohne UI testbar.
- **`UiDispatcher`** — `void runOnUi(Runnable)` und `boolean isUiThread()`.
`UiDispatcher` ist der Schlüssel der gesamten Umstellung. In Phase 1 wird er von `Platform::runLater` und `Platform::isFxApplicationThread` implementiert, in Phase 2 vom Compose-Main-Dispatcher. Die Invariante aus `AGENTS.md` bleibt dabei wortgleich gültig: Worker-Threads mutieren keine UI-gebundenen Sammlungen, UI-sichtbare Änderungen laufen über den Dispatcher.
### Was aus der JavaFX-Pipeline wird
`FilteredList` und `SortedList` werden zu einer **reinen Funktion** in `core`: aus Mitgliederliste, Filterprädikaten und Komparator entsteht eine unveränderliche Ergebnisliste. Die Stationslisten-Logik wird damit erstmals ohne laufende UI testbar. Das ist der wesentliche fachliche Gewinn von Phase 1.
Die vorhandene Bündelung und Entprellung (`flushPendingChatMessagesToUi`, das Coalescing der Mitgliederliste) **bleibt erhalten**. Sie ist Leistungsschutz bei mehreren tausend ON4KST-Nutzern, kein JavaFX-Beiwerk.
## Etappen
Jede Etappe ist für sich abgeschlossen, lieferbar und bekommt einen eigenen Umsetzungsplan.
### Etappe 1 — Gradle, zwei Module, JPMS entfernen, Packaging
**Ziel:** Die unveränderte JavaFX-Anwendung baut, testet und paketiert unter Gradle.
**Umfang**
- Gradle-Wrapper ersetzt `mvnw`/`mvnw.cmd`.
- Aufteilung in die Module `core` und `app-desktop`; in dieser Etappe enthält `app-desktop` noch die JavaFX-Oberfläche.
- **`core` darf in dieser Etappe noch von JavaFX abhängen.** `ChatController`, `ChatMember`, `ChatPreferences` und `ScoreService` benutzen es; Etappe 2 entfernt die Abhängigkeit. Etappe 1 bleibt damit eine reine Build-Umstellung ohne fachliche Änderung.
- Paketnamen und `import`-Anweisungen ändern sich nicht. Gradle-Module erzwingen keine Paketumbenennung; die Aufteilung ist ein Verschieben von Verzeichnissen.
- `src/main/java/module-info.java` wird entfernt; die Enforcer-Regel zur jpackage-Modulliste in `pom.xml` entfällt mit ihr.
- SpotBugs- und PMD-Regelsätze wandern mit; `pmd-ruleset.xml` bleibt unverändert gültig.
- Lombok wird aus den Abhängigkeiten entfernt.
- Packaging für Windows, Linux und macOS ruft `jpackage` weiterhin direkt auf, künftig aus einer Gradle-Aufgabe. Das DMG-Drag-Layout, die Icon-Erzeugung, Flatpak und AUR bleiben inhaltlich unverändert; nur die Herkunft der Eingabedateien wechselt von `target/dist-libs` auf `app-desktop/build/dist-libs`.
- Die Projektversion wandert nach `gradle.properties`. Zehn Stellen in fünf Workflows und zwei PKGBUILDs lesen sie heute aus `pom.xml` und müssen umgestellt werden, bevor `pom.xml` entfällt.
- Die GitHub-Workflows unter `.github/` werden auf Gradle umgestellt.
**Abnahmekriterium:** Alle 148 vorhandenen Tests laufen unter Gradle. Auf allen drei Plattformen entsteht ein installierbares Paket, das sich wie das bisherige verhält. Stable-, Beta- und Nightly-Strecke funktionieren unverändert.
### Etappe 2 — `core` von JavaFX befreien
**Ziel:** `core` compiliert ohne JavaFX auf dem Klassenpfad. Die JavaFX-Oberfläche läuft unverändert und beweist, dass die Schnittstelle trägt.
**Umfang**
- `core/observe` entsteht wie oben beschrieben.
- `ChatMember.frequency` wird von `StringProperty` auf `MutableValue<String>` umgestellt (`ChatMember.java:42`, `:510`, `:516`, `:616`).
- Die fünf Properties und vier `ObservableList<String>` in `ChatPreferences` werden auf `MutableValue` beziehungsweise `ObservableRoster` umgestellt.
- Die sieben `ObservableList`-Felder in `ChatController` werden zu `ObservableRoster`; die `FilteredList`/`SortedList`-Pipeline wird durch die reine Filter-/Sortierfunktion ersetzt.
- `ScoreService` gibt seinen Snapshot über `ObservableRoster` heraus statt über `topCandidatesFx`; `Platform.runLater` wird durch `UiDispatcher` ersetzt.
- `MessageBusManagementThread.checkListForChatMemberIndexByCallSign` (`:577`) nimmt eine `List<ChatMember>` statt `ObservableList`.
- Audio wandert nach `core/audio` hinter ein Interface. `javafx.scene.media` (`Kst4ContestApplication.java:33`, `:34`, `:6599`, `:6900`) entfällt; MP3 über `jlayer`, WAV über `javax.sound.sampled`.
- `app-desktop` stellt die JavaFX-Implementierung von `UiDispatcher` bereit und adaptiert `ObservableRoster` auf `ObservableList` für die bestehenden `TableView`.
**Absicherung:** Jede herausgelöste Einheit bekommt **vorher** einen Test, der das heutige Verhalten festschreibt — insbesondere Filterung und Sortierung der Stationsliste, die heute in `FilteredList`/`SortedList` steckt und nicht testbar ist.
**Abnahmekriterium:** `core` hat keine JavaFX-Abhängigkeit mehr. Die Anwendung verhält sich unverändert: Stationsliste, Chat-Tabs, Prioritäten, Skeds, Cluster, Worked-Zustand, Profilwechsel.
### Etappe 3 — `DataTable` und Einstellungsfenster in Compose
**Ziel:** Das erste Compose-Fenster ist im Produkt.
**Umfang**
Der wesentliche Baustein dieser Etappe ist eine wiederverwendbare **`DataTable`**-Komponente. Compose kennt kein `TableView`, und die Anwendung hat fünf Tabellen: `TableView<ChatMember>`, `TableView<ChatMessage>`, `TableView<ClusterMessage>`, `TableView<String>` und die Top-Prioritätenliste. Alle brauchen dasselbe:
- Spaltenbreiten mit stabilen Tabellen- und Spalten-IDs, wie sie `preferences.xml` Version 7 bereits kennt;
- Sortierung nach Spalte;
- Zeilenauswahl und Kontextmenü;
- Zeilen- und Zell-Styling, heute `PrivateMessageRowStyleResolver`, `MessageTextTableCell`, `TruncatedTextTableCell`, `TruncatedTextTooltipSupport`.
Umgesetzt als `LazyColumn` mit eigener Kopfzeile. Wird diese Komponente gut, ist der Rest der Portierung Fleißarbeit; wird sie schlecht, zieht sich das durch die gesamte Anwendung.
Mit dieser Etappe ziehen Kotlin und das Compose-Gradle-Plugin ein. Das Packaging wird dabei von den direkten `jpackage`-Aufrufen auf `compose.desktop.nativeDistributions` umgestellt — der Zeitpunkt ist hier richtig, weil JavaFX dann nicht mehr über `jlink` gebündelt werden muss.
Danach das Einstellungsfenster mit seinen zwölf Reitern: Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Macros, Beacon, Messagehandling, Workedstn database, GUI, Profiles. Es ist in sich abgeschlossen, hat wenig Domänenkopplung und eignet sich deshalb als Lernstück.
**Abnahmekriterium:** Das Einstellungsfenster ist vollständig in Compose, alle zwölf Reiter sind funktionsgleich, und geänderte Einstellungen landen unverändert in `preferences.xml`.
> Nachzuschärfen nach Etappe 2: der genaue Zuschnitt der `DataTable`-Schnittstelle. Er hängt davon ab, wie sich `ObservableRoster` in der Praxis anfühlt.
### Etappe 4 — Update-Fenster und Monitor-Fenster
**Ziel:** Die zwei letzten Nebenfenster portiert; die Muster aus Etappe 3 sind an
Live-Daten bestätigt.
**Umfang zurechtgerückt nach Etappe 3b.** Die Spezifikation nannte hier ursprünglich
vier Dinge. Zwei sind bereits erledigt und entfallen:
| ursprünglich genannt | Stand |
|---|---|
| `OperatorProfilePickerDialog` | in Etappe 3a nach Compose portiert |
| `OperatorProfileSettingsPane` | gelöscht, ersetzt durch `ProfilesTab` (Etappe 3b) |
| Update-Fenster | offen, `Kst4ContestApplication.java:9458` |
| Monitor-Fenster für Cluster und QSOs | offen, `:9396` |
- Das **Update-Fenster** ist das einfachere: ein Textfeld, ein Fortschritt, zwei
Knöpfe. Es prüft vor allem, ob ein Compose-Fenster aus einem Hintergrundfaden
heraus sauber auf- und zugeht.
- Das **Monitor-Fenster** ist der eigentliche Prüfstein: es belastet die `DataTable`
erstmals mit **Live-Daten**. Genau dort saßen in Etappe 2 die Sortier- und
Fokusfehler, und zwar nicht in der Tabelle selbst, sondern in der Kopplung
zwischen Roster und Anzeige.
Die Layout-IDs für das Monitor-Fenster bleiben von denen des Hauptfensters getrennt,
wie in `PROJECT_CONTEXT.md` festgelegt.
**Abnahmekriterium:** Beide Fenster sind funktionsgleich. Fenstergrößen, Positionen
und Spaltenbreiten werden unverändert entprellt gespeichert und beim Herunterfahren
geschrieben. Das Monitor-Fenster hält eine Stunde Dauerlast aus dem Simulator aus,
ohne dass die Auswahl springt oder die Sortierung kippt.
> Nach Etappe 3b gelernt und hier anzuwenden: Ein Bedienelement, das an einem Wert
> hängt, der kein Compose-Zustand ist, zeichnet sich nicht neu. Bei Live-Daten fällt
> das nicht als „reagiert nicht" auf, sondern als veraltete Anzeige — schwerer zu
> bemerken und schlimmer in der Wirkung.
### Etappe 5 — Hauptfenster
**Ziel:** JavaFX wird nur noch für die Karte benötigt.
**Umfang**
- Stationsliste mit Filterleiste und Prioritätenspalten.
- Die drei Chat-Reiter „Public messages", „DXCluster messages", „QSO of the other".
- Der Info-Bereich zur ausgewählten Station (`generateFurtherInfoAbtSelectedCallsignBP`, `Kst4ContestApplication.java:707`) samt seinem vertikalen `SplitPane` und dessen gespeicherten Divider-Positionen.
- Die Top-Prioritätenliste (`initTopPriorityListPane`, `:4416`).
- Die Statusleiste mit ihren Indikatoren: Sked-Warnung (`:5868`), Verbindungszustand (`:5887`), Band-Upgrade (`:6045`).
- `TimelineView`.
- Menüleiste, einschließlich der macOS-Systemmenüleiste.
**Profilwechsel:** Der heutige Mechanismus reißt die Laufzeit ab und baut eine **neue** `Kst4ContestApplication`-Instanz auf, weil viele Bedienelemente inline initialisierte Instanzfelder sind. Dieser Grund entfällt in Compose — der Zustand hängt nicht mehr an Feldern einer Riesenklasse. Der Wechsel wird zum Austausch des Zustandsobjekts. `shutdownRuntime()` bleibt trotzdem verantwortlich für alles, was eine Verbindung überdauert: ON4KST-Supervisor, Sked-Erinnerung, Reachability-Executor, PSTRotator-Wiederholung, Kachel-Proxy, Zeitgeber. `ApplicationConstants.sessionRuntimeUniqueId` wird weiterhin nicht neu erzeugt.
**Abnahmekriterium:** Das Hauptfenster ist funktionsgleich. Der Profilwechsel im laufenden Betrieb funktioniert, ohne Listener doppelt zu registrieren und ohne Ressourcen zu halten.
> Nachzuschärfen nach Etappe 4: ob `Platform.setImplicitExit(false)` und `ApplicationRuntimeLauncher.exitApplication()` in der Compose-Fassung noch eine Entsprechung brauchen.
### Etappe 6 — Karte und Entfernung von JavaFX
**Ziel:** 1.50. Keine JavaFX-Abhängigkeit mehr im Projekt.
**Umfang**
Die Karte wird als Compose-Canvas neu umgesetzt:
- Web-Mercator-Projektion, Zoomstufen und Schwenken wie bisher;
- Kacheln über den bestehenden `TileProxyServer`;
- Marker, Linien, Polygone, Rechtecke und Kreismarker direkt gezeichnet, entsprechend der heutigen flachen Leaflet-Nutzung;
- Maidenhead-Gitter über den bestehenden `MaidenheadGridRenderPlanner`;
- Pfaddarstellung und Geländeprofil (`PathProfileChart`) als Compose-Zeichnung;
- bildschirmbasiertes Clustering als Vorschalt-Schritt vor dem Zeichnen. `GUIstationMapClusteringEnabled` bleibt eine Layout-Einstellung unterhalb `guiOptions` mit Vorgabewert `true`.
`MapHtmlResources`, `StationMapBridge` und die JavaScript-Brücke entfallen ersatzlos. `StationMapView` wird zur Compose-Komponente.
Zum Abschluss werden die JavaFX-Abhängigkeiten aus dem Build entfernt und `opens kst4contest.view.map to javafx.web` verliert seinen Gegenstand.
**Eine Abhängigkeit, die hier zerbricht.** `JavaFxStylesheet.derive` ruft heute
`com.sun.javafx.util.Utils.deriveColor` über Reflexion auf, weil eine 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 der
naheliegenden Formeln. Mit dem Entfernen von JavaFX greift die Rückfallnäherung — und
die ist messbar daneben. **Vor dem Entfernen** muss `derive` durch eine eigene,
gegen JavaFX gepinnte Umsetzung ersetzt werden, sonst verschieben sich alle
abgeleiteten Farben still: die Fensterfläche und die Innenfläche jedes Eingabefelds.
**Abnahmekriterium:** Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert. Im Projekt existiert keine JavaFX-Abhängigkeit mehr.
> Nachzuschärfen nach Etappe 5: Leistungsverhalten des Canvas bei mehreren tausend Markern. Falls das Zeichnen nicht trägt, ist eine Vorabaggregation der Marker im Kern der Ausweg.
### Etappe 7 — Compose-Oberflächentests
**Ziel:** Die Zeichenebene bekommt ein Netz. Die Fehlerklasse, die in Etappe 3b
ausschließlich durch Hinsehen gefunden wurde, fällt künftig im Bau auf.
**Warum am Ende**
Die Oberfläche ändert sich durch Etappe 4 bis 6 weiter. Tests, die heute gegen die
Fenster aus Etappe 3b geschrieben würden, wären dreimal neu zu schreiben. Die
Beweislage wird jetzt festgehalten, die Tests entstehen, wenn die Flächen stehen.
**Beweislage: was in Etappe 3b kein Test gesehen hat**
Sämtliche folgenden Fehler standen gleichzeitig mit einem grünen Bau aus 382
Testfällen im Zweig. Keiner davon zeichnet, deshalb sah keiner etwas.
| Klasse | Erscheinung in Etappe 3b |
|---|---|
| Ein Bedienelement hängt an einem nicht beobachtbaren Wert und zeichnet sich nicht neu | Jeder Tastendruck in den Einstellungen wurde durchgeschrieben und nicht angezeigt; die Auswahl im Profil-Reiter wanderte nicht mit; die abgewählte Listenzeile behielt ihren Akzentrahmen |
| Ein Layout schneidet ab, statt umzubrechen | Der Reiterstreifen schnitt „Workedstn database" mitten im Wort ab; die Knopfleiste quetschte „Disconnect & close Chat" zu einer 540px hohen Farbsäule |
| Inhalt ist höher als das Fenster und unerreichbar | Der Reiterinhalt endete mitten im Formular, ohne Hinweis, dass mehr folgt |
| Farben stammen nicht aus der Stilvorlage | Die Fensterfläche nahm `-fx-base` statt `derive(-fx-base, 26.4%)`; der Akzent war Material-Violett statt des Grüns aus `.text-field .text` |
**Umfang**
- `org.jetbrains.compose.ui:ui-test-desktop` als Testabhängigkeit, benutzt über
`runComposeUiTest { }`. **Keine** JUnit-4-Brücke: das Projekt bleibt auf Jupiter,
und `runComposeUiTest` ist aus einem gewöhnlichen `@Test` heraus aufrufbar. Die
Funktion ist als `@ExperimentalTestApi` gekennzeichnet; die Einwilligung wird an
einer Stelle gebündelt und begründet.
- Je ein Rückfalltest für die vier Klassen oben, geschrieben gegen die dann
aktuellen Fenster.
- Die beiden Abdeckungsnetze aus Etappe 3b bleiben bestehen. Sie prüfen
**Text** — dass ein Setzer irgendwo aufgerufen wird. Diese Tests prüfen
**Verhalten** — dass der Wert auf dem Schirm ankommt. Das eine ersetzt das andere
nicht.
**Abnahmekriterium**
Für jede der vier Klassen wird der ursprüngliche Fehler **absichtlich wieder
eingebaut**, und der zugehörige Test muss fehlschlagen. Eine Prüfsammlung, die den
Fehler nicht fängt, für den sie geschrieben wurde, ist wertlos; das ist die einzige
Abnahme, die hier etwas aussagt.
> Zu klären vor Beginn: ob die Tests ohne Bildschirm laufen. Skiko zeichnet in einer
> Softwarefassung, aber das ist für diesen Bau nicht erprobt. Falls sie einen
> Bildschirm brauchen, laufen sie in der CI unter Xvfb oder gar nicht — dann wären
> sie nur lokal etwas wert, und das gehört vorher entschieden, nicht nachher
> festgestellt.
### Etappe 8 — Farben anpassbar machen
**Ziel:** Der Operateur bestimmt die Farben seines Clients, ohne Dateien im
Installationsverzeichnis anzufassen.
**Ausgangslage, die dafür spricht**
Die halbe Voraussetzung ist bereits da, nur nicht verdrahtet:
- `ApplicationFileUtils.copyResourceIfRequired` legt beim Start Kopien von
`KST4ContestDefaultDay.css` und `KST4ContestDefaultEvening.css` im
Anwendungsverzeichnis ab.
- **Gelesen wird diese Kopie nie.** `applyThemeStylesheet` übergibt den blanken
Dateinamen an `Scene.getStylesheets()`, und JavaFX löst relative Namen gegen den
Klassenpfad auf. Wer seine Kopie bearbeitet, sieht keinerlei Wirkung.
- `JavaFxStylesheet` liest aus demselben Grund die Klassenpfad-Ressource: solange
JavaFX die Kopie ignoriert, würde ein Compose-Fenster, das sie liest, als einziges
anders aussehen.
**Umfang**
- Die Kopie im Profilverzeichnis wird zur maßgeblichen Quelle. Ab Etappe 6 gibt es
kein JavaFX mehr, das dagegenhält — vorher muss beides dieselbe Datei lesen.
- Die Palette wird **pro Profil** gehalten. Ein Profil ist heute schon der Träger für
Einstellungen und Layout; Farben gehören in dieselbe Schublade.
- Eine Oberfläche zum Anpassen im Einstellungsfenster: die Handvoll Rollen, die
`JavaFxPalette` bereits benennt — Fläche, Feldinneres, Text, Akzent, Trennlinie —
statt eines freien CSS-Editors. Zurücksetzen auf die Auslieferung gehört dazu.
- Ein Kontrastwächter. Eine frei gewählte Textfarbe auf einer frei gewählten Fläche
kann unlesbar werden; die Oberfläche muss das sagen, bevor sie es speichert.
**Abnahmekriterium:** Eine geänderte Palette überlebt den Neustart, gilt für alle
Fenster gleichzeitig und ist pro Profil verschieden. Das Zurücksetzen stellt exakt die
ausgelieferten Farben her.
> Setzt Etappe 6 voraus, nicht wegen der Karte, sondern wegen `derive`: solange die
> Ableitung an JavaFX hängt, ist die Palette nicht frei wählbar, ohne dass dieselbe
> Rechnung in Compose nachgebaut ist. Siehe den Hinweis in Etappe 6.
### Etappe 9 — Mehrsprachigkeit
**Ziel:** Die Oberfläche spricht die Sprache des Operateurs. Deutsch und Englisch zum
Start.
**Umfang**
Grob 232 sichtbare Zeichenketten in den Compose-Quellen und rund 492 im verbliebenen
Java-Teil — die Zahl schrumpft mit jeder Etappe, weshalb diese Arbeit ans Ende gehört.
**Die eigentliche Schwierigkeit ist nicht das Übersetzen, sondern die Abgrenzung.**
Drei Sorten Text liegen heute nebeneinander im Quelltext und sehen gleich aus:
| Sorte | Beispiele | übersetzen? |
|---|---|---|
| Oberflächentext | „Save settings", „Active bands", „Callsign" | **ja** |
| Protokolltext | `/AWAY`, `/BACK`, `/CQ `, `SETNAME`, `MYQRG` | **nein, niemals** — das geht an den ON4KST-Server |
| Funkerkürzel | „Pse Sked?", „qrv 144.300", „tnx om, 73" | **nein** — das geht an andere Funker und ist international |
Ein übersetzter Protokollbefehl bricht die Verbindung, ein übersetztes Funkerkürzel
macht den Operateur unverständlich. Beides fällt in keinem Test auf und erst im
Contest, also unter Zeitdruck.
- Vor dem Herausziehen wird jede Zeichenkette einer der drei Sorten zugeordnet, und
die Zuordnung wird festgehalten — nicht im Kopf des Übersetzenden.
- Die Vorgabewerte in `ChatPreferences` (Beacon-Vorlagen, Antworttexte, Kurztasten)
sind Funkertext und bleiben, wie sie sind. Sie stehen zudem in der gespeicherten
XML jedes Operateurs; sie zu übersetzen würde bestehende Profile ändern.
- Ressourcenbündel pro Sprache, Auswahl im GUI-Reiter, Vorgabe aus der Systemsprache
mit Rückfall auf Englisch.
**Abnahmekriterium:** Die Oberfläche lässt sich umschalten und bleibt nach Neustart
umgeschaltet. **Kein einziger an den Server oder an andere Funker gehender Text
ändert sich mit der Sprache** — das ist gegen die Protokoll-Zeichenketten und die
`ChatPreferences`-Vorgabewerte zu prüfen, nicht durch Hinsehen.
## Invarianten — was unverändert bleiben muss
Diese Punkte sind in `AGENTS.md` und `PROJECT_CONTEXT.md` festgelegt und werden von der Umstellung nicht berührt.
**Chat-Identität**
- Vollständiges sichtbares Rufzeichen plus Kategorie bildet die Identität eines Chat-Teilnehmers.
- Basisruf-Normalisierung nur für explizit basisrufweite Funktionen.
- Worked-Zustand wird über Suffixvarianten desselben Basisrufs geteilt.
- Suffixe werden nicht global als Band, Kategorie oder Frequenz gedeutet.
**Bänder und Verfügbarkeit**
- Kategorien 2 und 3 sind die Hauptkategorien; unerwartete Werte müssen sicher fehlschlagen.
- `NOT-QRV` hat Vorrang vor positiven Band-Hinweisen.
- Fehlende Frequenz-, QRB- oder QTF-Werte bleiben unbekannt. `null` bedeutet nicht verfügbar, nicht null.
- Kein stilles Zurückfallen auf feste 144 MHz.
**Externe Schnittstellen**
- CR/LF-Framing, XML-Framing, Ports und Transporte bleiben unverändert.
- Die lokale DX-Cluster-Ausgabe behält ihre feste 75-Zeichen-Zeile, das DX-Rufzeichen ab Spalte 27, den 30-Zeichen-Kommentar ab Spalte 40 und die UTC-Zeit ab Spalte 71, gefolgt von zwei BEL und CRLF.
- Unerwartete Eingaben beenden keinen Verarbeitungs- oder UI-Thread.
**Persistenz**
- `preferences.xml` bleibt in Version 7, einschließlich der verwalteten Spaltenbreiten unterhalb `guiOptions` und der Regel, dass Elternspalten ihre Breite aus den Blattspalten ableiten.
- Selektives Schreiben aktualisiert die Datei auf der Platte, bewahrt unbekannte XML-Knoten und schreibt keine unbestätigten funktionalen Einstellungen. **Save Settings** bleibt der vollständige Schreiber.
- Vollständige und selektive Schreibvorgänge sind synchronisiert und ersetzen `preferences.xml` atomar.
- Operator-Profile behalten ihr Verhalten: Wurzelprofil flach, weitere Profile unter `profiles/<profileId>/`, `profiles.xml` wird verzögert angelegt, die Registrierung speichert niemals einen Pfad, ein neues Profil bekommt eine **leere** Datenbank.
- Das SQLite-Schema und die Worked-Semantik samt Dreitagesablauf bleiben unverändert.
- Simplelogfile-Verhalten bleibt unverändert, einschließlich des minütlichen Lesens und der Tatsache, dass daraus abgeleiteter Worked-Zustand nicht in SQLite landet.
**Quelltext-Konventionen**
- Kommentare und Javadoc ausschließlich auf Englisch.
- Log-, Protokoll- und API-Literale in ihrer kanonischen Form.
- Kommunikation mit Marc auf Deutsch; Commit-Nachrichten knapp auf Englisch.
## Risiken
| Risiko | Bewertung | Umgang |
|---|---|---|
| `DataTable` wird der Engpass | hoch | Etappe 3 baut sie zuerst und belastet sie in Etappe 4 mit Live-Daten, bevor das Hauptfenster davon abhängt |
| Packaging auf drei Plattformen neu | hoch | Etappe 1 macht das zuerst, mit unveränderter JavaFX-App als Vergleichsmaßstab |
| Canvas-Leistung bei vielen Markern | mittel | Ausweg ist Vorabaggregation im Kern; wird in Etappe 6 gemessen, nicht angenommen |
| Compose-Erfahrung fehlt bei beiden Entwicklern | mittel | Das Einstellungsfenster als erstes Lernstück ist bewusst ungefährlich gewählt |
| Funktionsgleichheit ist schwer nachweisbar | mittel | Vergleich Fenster für Fenster gegen die noch laufende JavaFX-Fassung; Protokoll und Persistenz sind durch `core`-Tests gedeckt |
| 1.50 verschiebt sich erheblich | angenommen | Bewusste Entscheidung. Kein Termin gesetzt |
| **JavaFX und Compose können auf macOS nicht koexistieren** | **eingetreten** | Am 28.09.2026 auf einem Mac nachgewiesen. Siehe den Abschnitt darunter; noch nicht entschieden, wie damit umgegangen wird |
## Eingetretenes Risiko: macOS trägt die Koexistenz nicht
Am 28.09.2026 auf einem Mac (macOS 27, arm64, Temurin 21) festgestellt: Übersetzen und
die vollständige Testreihe laufen dort sauber — **660 Testfälle, keine Fehler**, genau wie
auf Linux. Sobald aber ein Compose-Fenster geöffnet wird, hängt die Anwendung.
Thread-Abzug eines hängenden Starts:
```
main → wartet in Application.launch (JavaFX besitzt den Haupt-Runloop)
JavaFX Application Thread → wartet in OperatorProfilePickerWindow.showAndSelect
operator-profile-picker → hängt in sun.lwawt.macosx.LWCToolkit.initAppkit
```
Kein AWT-, AppKit- oder Skiko-Thread existiert. Auf macOS muss AppKit auf Thread 0
laufen; JavaFX nimmt diesen Runloop mit `Application.launch` in Besitz und gibt ihn nie
her, also wartet `initAppkit` auf einen Hauptthread, der nie wieder zuhört. X11 und
Wayland kennen diese Einschränkung nicht — deshalb ist es vier Etappen lang nicht
aufgefallen.
Der Profilwähler ist nur der Auslöser, weil er zuerst dran ist. Einstellungsfenster,
Monitor, Update-Fenster und Hauptfenster benutzen denselben `ComposeWindowHost`.
**Das trifft das Verfahren dieser Spezifikation im Kern:** der Vergleich Fenster für
Fenster gegen die laufende JavaFX-Fassung — der Umgang mit dem Risiko
„Funktionsgleichheit ist schwer nachweisbar" — setzt Koexistenz voraus, und die gibt es
auf macOS nicht.
Drei Wege:
1. **Startreihenfolge umdrehen.** AWT/Compose besitzt den Hauptthread, JavaFX wird
eingebettet gestartet (`Platform.startup` statt `Application.launch`, ggf. mit
`-Djavafx.embed.singleThread=true`). Wirkt sofort und für den Rest der Migration,
fasst aber den Anwendungsstart an und betrifft damit auch Linux. Als eigene Etappe
nach 5c zu führen, nicht hinein: sonst sind zwei riskante Umbauten gleichzeitig offen.
2. **macOS bis zum Ende zurückstellen.** Das Problem verschwindet mit JavaFX. Kostet
nichts, aber bis dahin ist auf macOS nichts zu testen — und Etappe 1 hat Packaging für
drei Plattformen ausdrücklich zuerst gemacht, um genau das zu vermeiden.
3. **Compose-Fenster auf macOS abschalten.** Arbeit, die später wieder herausfliegt.
## Offene Punkte
Diese Fragen werden bewusst erst beantwortet, wenn die vorangehende Etappe Erfahrung geliefert hat. Sie blockieren Etappe 1 und 2 nicht.
1. Genauer Zuschnitt der `DataTable`-Schnittstelle — nach Etappe 2.
2. Ob `Platform.setImplicitExit(false)` in Compose eine Entsprechung braucht — nach Etappe 4.
3. Aggregationsstrategie für Kartenmarker, falls das Canvas-Zeichnen nicht trägt — nach Etappe 5.
4. Ob das Geländeprofil als eigene Compose-Zeichnung oder über eine Diagrammbibliothek entsteht — nach Etappe 5.
5. Ob die Compose-Oberflächentests ohne Bildschirm laufen — vor Etappe 7.
6. Welcher der drei Wege beim macOS-Befund gegangen wird — zu entscheiden, bevor Etappe 6
JavaFX entfernt, denn Weg 1 wäre danach umsonst gebaut.
6. Ob die Farbpalette pro Profil oder pro Installation gilt — vor Etappe 8.
@@ -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
(Plan entfernt nach der Ausführung). 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.
@@ -0,0 +1,240 @@
# Entwurf — die Farben pro Operateurprofil anpassbar machen
Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 8.
Dieser Entwurf ersetzt den dortigen Abschnitt. Er ist im September geschrieben worden, und
zwei seiner Annahmen stimmen nicht mehr.
## Ziel
Der Operateur bestimmt die Farben seines Clients, ohne Dateien im Installationsverzeichnis
anfassen zu müssen. Erfolg ist nachprüfbar:
- Eine geänderte Palette überlebt den Neustart.
- Sie gilt in **allen offenen Fenstern gleichzeitig**, nicht erst nach einem Fensterneubau.
- Sie ist **pro Profil** verschieden.
- Das Zurücksetzen stellt **exakt** die ausgelieferten Farben her — nicht etwas, das ihnen
gleicht.
## Was sich gegenüber dem Ursprungsabschnitt geändert hat
**Die Voraussetzung ist entfallen.** Er schreibt: „Ab Etappe 6 gibt es kein JavaFX mehr, das
dagegenhält — vorher muss beides dieselbe Datei lesen." Es gibt kein JavaFX mehr. Und der
`derive`-Pin, auf den er ausdrücklich wartet, ist seit Etappe 3 eine JavaFX-freie Umsetzung,
gegen eingecheckte Referenzwerte geprüft. Diese Etappe ist frei.
**Ein Befund kommt hinzu, den er nicht hatte.** `Kst4ContestTheme` liest die Palette als
`remember(darkMode)` (`Theme.kt:32`). Eine Palettenänderung erreicht die Fenster damit **gar
nicht**, bis jemand Tag/Abend umschaltet. Das Abnahmekriterium „gilt für alle Fenster
gleichzeitig" hängt also nicht an der Speicherung, sondern hier. Der Weg dafür existiert
bereits: `applyTheme` (`Kst4ContestApplication`) ruft `applyDarkMode` auf sechs Fenster-Wirten,
jeder hält einen `mutableStateOf` — nur trägt der heute bloß ein Boolean.
**Der Defektbefund bleibt richtig.** `copyResourceIfRequired` legt beim Start Kopien von
`KST4ContestDefaultDay.css` und `KST4ContestDefaultEvening.css` nach
`~/.kst4contest/` (`Kst4ContestApplication:2138`–`:2139`). Geprüft: **nichts liest sie.** Wer
seine Kopie bearbeitet, sieht keinerlei Wirkung.
## Die Quelle der Farben: drei Schichten
| | Schicht | Woher | Vorrang |
|---|---|---|---|
1 | **Auslieferung** | die Vorlage aus dem Klassenpfad | Boden, immer vorhanden |
2 | **Datei** | die CSS-Datei im Profilverzeichnis, falls vorhanden | überschreibt 1 |
3 | **Geändert** | die sechs Rollen aus den Einstellungen des Profils | überschreibt 1 und 2 |
**Die spätere Schicht gewinnt, Rolle für Rolle.** Eine Datei, die nur `-fx-base` nennt, ändert
nur diese Rolle; die übrigen kommen weiter aus der Auslieferung.
### Warum drei und nicht eine
Abgewogen wurden zwei Wege.
**Nur die CSS-Datei maßgeblich**, wie der Ursprungsabschnitt es wörtlich sagt, wurde
verworfen: eine Farbänderung müsste `-fx-base` in die 6,4-KB-Vorlage zurückschreiben, und das
braucht einen Serialisierer, der jede übrige Regel unverändert durchträgt. Ein Rundlauf, der
eine Regel verliert, zerstört das Aussehen still. `JavaFxStylesheet` ist ein Leser; ein
Schreiber daneben wäre die risikoreichste Stelle dieser Etappe, und die Anforderung — „ohne
Dateien anfassen zu müssen" — verlangt ihn nicht.
**Nur die Einstellungen** wäre das Einfachste und wurde bewusst nicht gewählt: es nimmt dem
Operateur den Handweg, den er heute zu haben *glaubt* (die Kopie liegt ja da), und der
Defektbefund würde gelöscht statt behoben.
**Gewählt: beide, mit ausdrücklichem Vorrang.** Der Preis ist benannt und wird im nächsten
Abschnitt bezahlt: bei zwei Quellen ist später nicht mehr ersichtlich, warum eine Farbe so
ist, wie sie ist.
### Was diesen Preis bezahlt
**Jede der sechs Rollen zeigt in der Oberfläche, woher ihr geltender Wert kommt** —
*Auslieferung*, *Datei* oder *geändert*. Die Auflösung weiß das ohnehin, es kostet nichts, und
ohne das ist die Frage „warum ist diese Farbe so?" unbeantwortbar. Das ist keine Zierde,
sondern die Bedingung, unter der die Dreischicht-Lösung vertretbar ist.
## Die sechs Rollen
Die, die `JavaFxPalette` benennt. Der Ursprungsabschnitt zählt fünf auf; es sind sechs,
weil `base` und `windowBackground` getrennt geführt werden — siehe unter der Tabelle.
| Rolle | im Palettenobjekt |
|---|---|
Fläche | `base` |
Fensterfläche | `windowBackground` |
Feldinneres | `controlInnerBackground` |
Text | `labelTextFill` |
Akzent | `accent` |
Trennlinie | `separatorLine` |
`base` und `windowBackground` sind getrennt, weil die Vorlagen sie getrennt führen und die
Fensterfläche aus der Fläche abgeleitet wird (`derive(base, 26.4%)`) — wer nur eine davon
setzen könnte, bekäme die andere ungefragt mitgezogen.
**Nicht anpassbar** bleiben `textAccent`, `buttonHoverGradient` und `buttonPressedBorder`. Sie
sind abgeleitete Werte, keine Rollen, die jemand einzeln wählen will; sie folgen dem Akzent.
`fontSizePx` ist eine bestehende Einstellung und wird hier nicht angefasst.
**Tag und Abend sind zwei Paletten.** Jede Rolle existiert zweimal, und eine Änderung am
Abendentwurf lässt den Tagentwurf unberührt.
## Die Wirkung ist sofort, und es gibt drei Wege zurück
**Eine Änderung gilt unmittelbar in allen offenen Fenstern.** Kein Musterfeld, kein
„noch nicht gespeichert"-Zustand.
Der Grund gegen ein Musterfeld ist die Geschichte dieses Projekts: die Fehlerklassen, die hier
wiederholt mit grünem Bau auslieferten, waren „sah einzeln richtig aus und war an seinem Platz
falsch". Ein zweihundert Pixel breites Muster ist genau diese Isolation. Es sagt nichts
darüber, wie die Stationsliste mit 22 Spalten in einer Palette liest. Das Einstellungsfenster
selbst ist die bessere Vorschau: es färbt sich mit und enthält Beschriftungen, Felder, Knöpfe
und Trennlinien — alles, was die sechs Rollen betrifft.
Weil es keinen Zwischenzustand gibt, sind die Rückwege keine Bequemlichkeit, sondern der
Ersatz dafür:
- **„Zurück zum Stand von vorher"** — beim Öffnen des Reiters wird die geltende Palette
gemerkt; dieser Knopf stellt sie wieder her. Das ist der Rückweg für den Normalfall: eine
Farbe im echten Fenster ausprobiert, gefällt nicht, weg damit.
- **„Meine Änderungen verwerfen"** — leert Schicht 3. Es gilt wieder Datei oder Auslieferung,
je nachdem, was da ist.
- **„Auf Auslieferung zurücksetzen"** — setzt alle Rollen auf die ausgelieferten Werte, als
ausdrückliche Änderung der Schicht 3. Damit spielt eine vorhandene Datei keine Rolle mehr.
**Das ist die Aktion, die das Abnahmekriterium erfüllt**, und sie erfüllt es auch dann, wenn
eine Datei im Spiel ist.
Mit einer Datei zwischen Auslieferung und Änderung kann **ein** Knopf nicht alle drei Fälle
abdecken: löscht er nur die Änderungen, gilt wieder die Datei und nicht die Auslieferung.
### „Auf Auslieferung zurücksetzen" wird immer in den ausgelieferten Farben gezeichnet
**Dieser eine Knopf nimmt die aktive Palette nicht an.** Er wird in den ausgelieferten Farben
gezeichnet, ganz gleich, was der Operateur eingestellt hat — Fläche, Text und Rahmen aus
Schicht 1, nicht aus der Auflösung.
Der Grund ist die Fehlerart, die sich sonst selbst einschließt: wer Text und Fläche auf
dasselbe Schwarz stellt, sieht die Knöpfe nicht mehr, mit denen er es zurücknehmen könnte. Ein
Tastaturweg allein hilft dann nur, wer ihn kennt. Ein Knopf, der die kaputte Palette nicht
annimmt, hilft immer.
Dass er dadurch bei einer stark angepassten Palette **fremd aussieht, ist beabsichtigt.** Er ist
eine Rettungsleine, keine Zierde; dass er sich nicht einfügt, macht ihn auffindbar.
Die anderen zwei Rückwege nehmen die aktive Palette an. Sie sind Bequemlichkeit für den
Normalfall; die Rettungsleine ist diese eine, und genau eine ist leichter zu merken als drei.
Ein Tastaturweg kommt zusätzlich, für alle drei. Er ist jetzt die zweite Absicherung, nicht
mehr die einzige.
## Die Datei im Profilverzeichnis
**Für das Wurzelprofil existiert sie schon:** `~/.kst4contest/KST4ContestDefaultDay.css` und
`…Evening.css`, von `copyResourceIfRequired` geschrieben. Sie wird jetzt **gelesen**. Das
behebt den dokumentierten Defekt, statt seine Spuren zu beseitigen, und gibt dem Operateur den
Handweg, den die Datei ihm die ganze Zeit versprochen hat.
**Für andere Profile wird nicht ungefragt kopiert.** Der Pfad folgt dem Muster, das
`OperatorProfilePaths` für Einstellungen und Datenbank schon benutzt: Wurzelprofil flach, sonst
`profiles/<id>/<datei>`. Eine Datei, die ungefragt entsteht und niemand braucht, ist dieselbe
Falle wie die bisherige ungelesene Kopie. Stattdessen schreibt ein Knopf im Reiter die Vorlage
ins Profilverzeichnis, wenn der Operateur von Hand bearbeiten will.
**Eine unlesbare oder fehlerhafte Datei darf den Start nicht verhindern.** Sie wird
übersprungen, die Rolle fällt auf die Auslieferung zurück, und der Reiter sagt es. Ein Client,
der wegen einer kaputten Farbdatei nicht startet, ist schlimmer als einer in den falschen
Farben.
## Dass es alle Fenster erreicht
Ein **`PaletteStore`** hält die Palette beider Entwürfe für das aktive Profil als
Compose-Zustand. `Kst4ContestTheme` liest daraus statt aus `remember(darkMode)`. Damit zeichnet
eine Änderung jedes offene Fenster neu — über denselben Weg, den der Tag/Abend-Wechsel schon
benutzt, und ohne dass `applyTheme` oder die sechs Fenster-Wirte etwas Neues lernen müssen.
Der Store wird beim Profilwechsel neu gebildet, wie jeder andere profilgebundene Zustand.
## Der Kontrastwächter
Eine frei gewählte Textfarbe auf einer frei gewählten Fläche kann unlesbar werden. Die
Oberfläche **sagt es, speichert aber trotzdem**: ein Funker, der im Contest bewusst eine harte
Kombination will, bekommt sie. Das Maß ist das WCAG-Kontrastverhältnis aus relativer
Leuchtdichte — eine reine Funktion, und damit der Teil, der falsch sein kann, ohne dass es
auffällt.
Geprüft werden die Paare, die tatsächlich übereinanderliegen: Text auf Fläche, Text auf
Feldinnerem, und Akzent auf Fläche.
## Prüfung
Das Wertvolle hier sind die Regeln, nicht die Oberfläche. Alle ohne Bildschirm prüfbar:
- **Die Dreischicht-Auflösung** samt Vorrang und Herkunftsangabe, einschließlich: Datei fehlt;
Datei nennt nur eine Rolle; Datei ist unlesbar; Änderung und Datei widersprechen sich.
- **Die drei Rückwege** — besonders, dass „auf Auslieferung" auch bei vorhandener Datei exakt
die ausgelieferten Werte ergibt, und dass „zum Stand von vorher" die Palette herstellt, die
beim Öffnen des Reiters galt, einschließlich einer damals schon vorhandenen Änderung.
- **Dass der Auslieferungs-Knopf die ausgelieferten Farben benutzt und nicht die aufgelöste
Palette** — geprüft mit einer Palette, in der jede Rolle auf etwas anderes gesetzt ist. Das
ist der Test, der die Rettungsleine davor bewahrt, still an der Palette zu hängen, die sie
retten soll.
- **Das Kontrastverhältnis**, gegen von Hand nachgerechnete Werte.
- **Dass Tag und Abend getrennt bleiben.**
- Dazu die Rückfalltests aus Etappe 7: Klasse 4 prüft, dass die Farben aus der Stilvorlage
kommen, und muss mit dieser Etappe weiter gelten.
## Was unverändert bleibt
- Die ausgelieferten Vorlagen selbst.
- `JavaFxStylesheet` als **Leser**. Kein CSS-Schreiber, nirgends.
- Der `derive`-Pin samt eingecheckter Referenztabelle und `JavaFxStylesheetTest`.
- Die Speicherung pro Profil über `ChatPreferences` — dieselbe wie jede andere Einstellung.
- `fontSizePx` und die bestehende Schriftgrößeneinstellung.
- Die Invarianten aus `AGENTS.md`.
## Risiken
**Die zweite Quelle ist der Preis dieser Etappe.** Zwei Schichten, die dasselbe bestimmen
können, sind eine Stelle, an der später niemand weiß, warum eine Farbe so ist. Die
Herkunftsangabe pro Rolle ist die einzige Gegenmaßnahme, und sie ist deshalb nicht optional.
**Eine Änderung, die alle Fenster erreicht, erreicht sie auch alle falsch.** Der `PaletteStore`
ist eine Stelle, an der ein Fehler sich überall gleichzeitig zeigt. Dafür zeigt er sich
wenigstens sofort und nicht erst nach einem Fensterneubau.
**Der Kontrastwächter warnt nur.** Ein Operateur kann sich einen unlesbaren Client bauen. Das
ist die Entscheidung, die hier bewusst bei ihm liegt, und der Ausweg ist der Knopf, der die
kaputte Palette nicht annimmt.
**Die Warnung des Wächters hat dieselbe Angreifbarkeit wie die Knöpfe** und ist nicht
ausgenommen: in einer unlesbaren Palette ist auch sie unlesbar. Sie auszunehmen wäre
folgerichtig, würde aber den Reiter mit zwei fremd aussehenden Flächen besetzen statt mit
einer. Ich lasse es bei der einen Rettungsleine und halte den Punkt hier fest, statt ihn
stillschweigend auszuweiten.
## Zwei Festlegungen, die sonst im Bau beliebig würden
**Die Wirkung ist sofort, der Rückweg gemerkt.** Kein Musterfeld — die Begründung steht unter
„Die Wirkung ist sofort". Beurteilt wird im echten Fenster, zurück geht es über eine beim
Öffnen des Reiters gemerkte Palette.
**Alle drei Rückwege bleiben.** Ohne Zwischenzustand sind sie der einzige Weg zurück, und alle
drei müssen ohne Farbwahrnehmung erreichbar sein — also auch über die Tastatur.
@@ -0,0 +1,301 @@
# Entwurf — Mehrsprachigkeit
Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 9.
Dieser Entwurf ersetzt den dortigen Abschnitt. Er ist im September geschrieben worden, und
seine Zahlen und eine seiner Annahmen stimmen nicht mehr.
## Ziel
Die Oberfläche spricht die Sprache des Operateurs, Deutsch und Englisch zum Start, und
weitere Sprachen können von außen beigetragen werden, ohne dass der Beitragende Kotlin anfasst.
Erfolg ist nachprüfbar:
- Die Sprache lässt sich umschalten, wirkt **sofort in allen offenen Fenstern** und bleibt nach
dem Neustart umgeschaltet.
- Sie gilt **pro Operateurprofil**.
- **Kein einziger Text, der an den ON4KST-Server oder an andere Funker geht, ändert sich mit
der Sprache.** Geprüft, nicht angesehen.
- Eine fehlende Übersetzung fällt auf Englisch zurück, statt zu fehlen.
- Eine fehlerhafte Übersetzung bricht den Bau ab, bevor sie ausgeliefert wird.
## Umfang dieser Etappe: die Mechanik, nicht die Texte
Gemessen am heutigen Stand, nach den Etappen 4 bis 8:
| | Zeichenketten |
|---|---|
`app-desktop` Kotlin | 852
`app-desktop` Java | 417
davon nach Oberflächentext aussehend, verschieden | **~394**
`core` | 2.968, davon 273 `println`
Die Zahlen des Ursprungsabschnitts (232 Compose, 492 Java) sind durch den JavaFX-Ausbau
überholt.
**Diese Etappe baut die Mechanik und zieht ein Dutzend Texte als Beweis heraus.** Die übrigen
rund 380 sind mechanische Folgearbeit, die häppchenweise gehen kann und keine
Architekturentscheidung mehr enthält. Der Grund für diesen Schnitt: die Mechanik trägt alle
380, und sie jetzt falsch zu bauen kostet 380 Mal nach.
## Die vier Textsorten
Der Ursprungsabschnitt nennt drei. Gemessen sind es vier, und die vierte ist die größte.
**Die Regel, die entscheidet, ist der Adressat des Textes** — nicht, wie er aussieht.
| Adressat | Sorte | Wo sie heute lebt | übersetzen |
|---|---|---|---|
Der Operateur am Bildschirm | Oberflächentext | `app-desktop/src/main` | **ja** |
Der ON4KST-Server | Protokolltext | `On4KstProtocol` (`LOGINC\|`, `SDONE\|`, `ACHAT\|`, `CK`, `DXQ`), dazu die `/`-Chatbefehle und `SETNAME`/`MYQRG` | nein |
Andere Funker | Funkertext | `ChatPreferences`-Vorgabewerte, Schnipsel, Kurztasten | nein |
Der Entwickler beim Fehlersuchen | Diagnosetext | 273 `println` in `core` | nein |
Die beiden mittleren sind dasselbe Risiko aus zwei Richtungen: ein übersetzter Protokollbefehl
bricht die Verbindung, ein übersetztes Funkerkürzel macht den Operateur unverständlich.
Der Protokolltext hat dabei zwei Ebenen, die nicht zu verwechseln sind: die **Rahmen** auf dem
Draht (`LOGINC|`, `SDONE|`, `ACHAT|` in `On4KstProtocol`) brechen bei einer Übersetzung die
Verbindung, die **`/`-Chatbefehle** (`/CQ`, `/AWAY`) gehen als Nachricht durch und werden vom
Server nicht verstanden. Beide dürfen sich nicht mit der Sprache ändern.
Keiner dieser Fehler fällt in einem Test auf, der nur die Oberfläche ansieht — und im Betrieb
fällt er erst im Contest auf, also unter Zeitdruck.
Der Diagnosetext ist neu benannt und gehört ausdrücklich dazu: ein deutscher Fehlerbericht ist
für den Wartenden schlechter als ein englischer, und die 273 `println`-Aufrufe sehen im
Quelltext genauso aus wie Oberflächentext.
### Die Regel für `core`
**`core` erzeugt keinen Anzeigetext. Es liefert einen Schlüssel oder einen Aufzählungswert,
und `app-desktop` macht daraus Text.**
Das ist die Fortsetzung des bestehenden Schnitts „`core` bleibt toolkit-frei" in die Sprache
hinein: `core` hat nicht zu wissen, welche Sprache der Mensch liest. Der zweite, praktische
Grund: weil `core` keine Nachschlagefunktion bekommt, **kann kein deutscher Text in eine
Protokoll- oder Diagnosezeile geraten.** Die Regel erzwingt die Abgrenzung, statt sie zu
verlangen.
Abgewogen und verworfen wurde, `core` ein Bündel mitzugeben. Ein `ResourceBundle` ist reines
JDK und hätte den Toolkit-Schnitt nicht verletzt — aber es hätte die Nachschlagefunktion neben
die 273 `println`-Aufrufe gestellt, also genau dorthin, wo Deutsch nicht hingehört.
**Zwei heutige Verstöße** werden benannt und bleiben diese Etappe englisch:
- `core/src/main/java/kst4contest/view/map/PathPropagationAssessment.java` — die
Ausbreitungsbeschreibungen („Aircraft scatter, tropo ducting/enhancement, …").
- `core/src/main/java/kst4contest/logic/PriorityCalculator.java` — die Prioritätsgründe
(„Active now").
Sie zu verlegen ist Folgearbeit. Der Nutzen der Regel jetzt: die ~380 Folgetexte haben eine
Regel statt einer Diskussion pro Fall.
## Die Übersetzungen sind Dateien, die niemand kompilieren muss
`app-desktop/src/main/i18n/strings_en.properties` als Grundlage, `strings_de.properties`
daneben. Flach, ein Schlüssel, ein Text:
```properties
settings.save=Save settings
alert.armedFor=Armed for {0}
```
Wer eine Sprache beitragen will, kopiert die englische Datei, übersetzt die Werte rechts vom
Gleichheitszeichen und öffnet eine PR. Kein Kotlin, kein Gradle, keine Escapes für Umlaute.
**Warum `.properties` und nicht JSON.** Gemessen: `java.util.Properties` ist JDK und liest
seit Java 9 UTF-8, funktioniert also im Erzeuger ohne jede Abhängigkeit. JSON hätte entweder
eine neue Bauwerkzeug-Abhängigkeit gekostet oder einen selbstgeschriebenen Leser, dessen
Escape- und Unicode-Fehler dann hausgemacht wären — `groovy.json.JsonSlurper` steht nur im
Gradle-Kontext zur Verfügung und nicht in einem Modul, dessen Tests im normalen Lauf
mitzählen. Für Beitragende ist das Format zudem robuster: keine Klammern und Kommas, die man
kaputtmachen kann.
**Eine Datei pro Sprache**, nicht eine Datei mit allen Sprachen nebeneinander: so bleiben
Sprach-PRs voneinander getrennt. Den Vorteil der Nebeneinander-Form — der Übersetzer sieht das
Original neben seinem Feld — bildet ein Arbeitsblatt nach (siehe unten).
Englisch ist die Quellsprache, weil der Quelltext es so hält und `AGENTS.md` es für
Oberflächentext vorschreibt.
## Der Erzeuger: Unvollständigkeit ist erlaubt, Unrichtigkeit nicht
Ein Gradle-Task liest die Textdateien und erzeugt daraus Kotlin. **Die Fehler entstehen
dort**, nicht in einem Prüfer, der danebensteht.
| Befund | Erzeuger |
|---|---|
Schlüssel fehlt in einer Übersetzung | **Warnung**, Rückfall auf Englisch zur Laufzeit
Schlüssel steht in der Übersetzung, aber nicht in der englischen Grundlage | **Bau bricht ab** — Tippfehler des Beitragenden
Platzhalter weichen ab (`{0} {1}` → `{0}`) | **Bau bricht ab** — verschluckt Daten lautlos, und `MessageFormat` sagt dazu nichts
Datei unlesbar oder doppelter Schlüssel | **Bau bricht ab**
Schlüssel lässt sich nicht eindeutig in einen Kotlin-Namen übersetzen | **Bau bricht ab**
Diese Trennung ist der Kern: eine zu 80 % fertige beigetragene Sprache **muss** einreichbar
sein, sonst ist die ganze Auslagerung zwecklos. Eine falsche Übersetzung darf dagegen nicht
ausgeliefert werden.
Erzeugt wird nach `build/generated/`, nicht in den Quellbaum — damit kann das Erzeugnis nicht
von den Textdateien abdriften.
**Der Erzeuger ist ein eigenes Modul, kein Bauskript.** Die Spezifikation verlangt, ihn zu
testen; `buildSrc` leistet das nicht — gemessen: `buildSrc`-Tests laufen bei
`./gradlew build` nicht mit, was genau den Fehler ergäbe, den der Erzeuger verhindern soll. Er
wird deshalb ein drittes, **reines Bauwerkzeug-Modul** `i18n-generator` im Hauptbau, von
`app-desktop` über eine `JavaExec`-Aufgabe vor `compileKotlin` aufgerufen. Seine Tests sind
gewöhnliche Projekttests: sie laufen bei `./gradlew build`, zählen in der Test-XML mit und
werden von der bestehenden CI gefahren.
Das Modul wird **nicht** ausgeliefert: es steht nicht im Laufzeit-Klassenpfad von
`app-desktop`, nur in einer eigenen Konfiguration für den Aufruf. `CLAUDE.md` und
`docs/PROJECT_CONTEXT.md` nennen heute zwei Module und sind entsprechend nachzuziehen.
**Kein Parser zur Laufzeit.** Die Texte stehen als Kotlin im Jar: kein `ResourceBundle`, keine
Datei-E/A, und **keine neue Abhängigkeit, weder im Jar noch im Bau**.
### Die erzeugte Form macht auch die Stelligkeit prüfbar
```kotlin
class Strings(private val lookup: (String) -> String) {
val settingsSave: String get() = lookup("settings.save")
fun alertArmedFor(arg0: Any): String = MessageFormat.format(lookup("alert.armedFor"), arg0)
}
```
Also `strings.settingsSave` statt `strings.get("settings.save")`. Ein Tippfehler ist ein
Übersetzungsfehler, und `alertArmedFor()` ohne Argument übersetzt nicht.
Das ist der Teil, der die ~380 Folgetexte trägt: bei stringbasierten Schlüsseln wäre jeder
davon eine neue Gelegenheit für einen Laufzeitfehler, und die Fehlerart wäre „Text fehlt im
Fenster" — dieselbe stille Sorte, die diesen Zweig schon mehrfach erwischt hat.
Form und Stelligkeit kommen **immer aus der englischen Grundlage.** Deshalb bleibt ein falscher
Platzhalter ein Bauabbruch, während ein fehlender Schlüssel nur ein Rückfall ist.
### Das Arbeitsblatt für Beitragende
Ein Task erzeugt `strings_<lang>.todo.properties`: die fehlenden Schlüssel mit dem
**englischen Text als Wert**. Ohne das müsste ein Beitragender zwei Dateien diffen, um zu finden, was fehlt.
### Deckung
Der Erzeuger gibt beim Bau eine Zeile aus — `de: 394/394, fr: 120/394` —, damit einer PR
ansehbar ist, wie weit sie ist.
**Deutsch wird durch einen Test auf Vollständigkeit gehalten, weitere Sprachen nicht.** Deutsch
ist eine ausgelieferte Sprache; dort ist eine Lücke ein Fehler. Bei einer beigetragenen Sprache
ist eine Lücke der Normalfall.
## Dass es alle Fenster erreicht
Dieselbe Bauform wie bei den Farben in Etappe 8, damit es nur eine zu verstehen gibt.
Ein **`LanguageStore`** hält die Sprache als Compose-Zustand. Zwei Aufrufwege, eine Wahrheit:
- **Compose:** `LocalStrings.current.settingsSave`, bereitgestellt in
`ComposeWindowHost.show` neben `LocalPaletteStore`. Der Zustandszugriff ist wieder genau das,
was die Neuzeichnung auslöst — in Etappe 8 war `remember(darkMode)` der Grund, warum eine
Änderung gar kein Fenster erreichte.
- **Java:** `Strings.current()` gegen ein `@Volatile`-Feld, das der Store beim Wechsel setzt.
Die verbliebenen AWT-Aufrufstellen holen damit beim nächsten Aufruf die neue Sprache, ohne
eigene Mechanik.
Der Store wird beim Profilwechsel neu gebildet, wie jeder andere profilgebundene Zustand.
## Die Einstellung
Ein Feld `guiOptions_language` in `ChatPreferences` — pro Profil, wie jede andere
GUI-Einstellung. Bei einer Mehrmann-Station ist das auch das Richtige: zwei Operateure, zwei
Sprachen. Drei Werte: leer (Systemsprache), `en`, `de`. Ein `Form.choice` im GUI-Reiter.
Die Vorgabe ist eine **reine Funktion** `localeFor(stored, systemDefault)`, damit sie ohne
Systemwechsel prüfbar ist: leerer Wert heißt Systemsprache, und ist die nicht Deutsch, dann
Englisch.
## Die zwölf Beweistexte
Bewußt **über mehrere Fenster verteilt**, nicht die zwölf nächstliegenden aus einer Datei: die
Save- und Close-Knöpfe des Einstellungsfensters, die Abschnittstitel des GUI-Reiters, die
Beschriftung der Sprachwahl selbst, ein Menütitel im Hauptfenster, die Verbindungsanzeige in
der Statuszeile und ein `ComposeAlert`-Knopf.
Der Grund ist das Abnahmekriterium: beim Umschalten im Einstellungsfenster muss sich **das
Hauptfenster dahinter sichtbar mitändern.** Zwölf Texte aus einer Datei würden das nicht
zeigen, und genau diese Sorte Scheinbeweis hat diesen Zweig mehrfach erwischt — eine
Einstellung, die durchgeschrieben und nicht gezeichnet wurde.
## Prüfung
Das Wertvolle sind die Regeln, nicht die Oberfläche. Alles ohne Bildschirm prüfbar:
- **Die drei Abgrenzungstests.** (1) `On4KstProtocol` baut seine Rahmen unter
`Locale.GERMAN` und `Locale.ENGLISH` zeichengleich — `login`, `settingsDone`, `addChat`,
`clientLivenessProbe`, `serverLivenessProbeResponse`. Geprüft wird die Ausgabe, nicht das
Bündel. Die Klasse ist paketprivat, der Test gehört also nach
`core/src/test/java/kst4contest/controller/`. (2) Die `ChatPreferences`-Vorgabewerte ändern sich mit keiner Sprache, **über
Reflexion über alle `String`-Felder**, nicht über eine gepflegte Liste: das deckt auch ab,
was nach dieser Etappe dazukommt. (3) Kein Wert in einem Bündel ist ein
Protokoll-Opcode, ein `/`-Chatbefehl, `SETNAME`, `MYQRG` oder ein
`ChatPreferences`-Vorgabewert.
- **Der Erzeuger selbst**, je ein Test für die fünf Befunde seiner Tabelle, gegen kleine
Textschnipsel. Das ist der Teil, der falsch sein kann, ohne dass es auffällt — ein
Bauabbruch, der nie abbricht, sieht aus wie ein sauberes Projekt.
- **`localeFor`**: leer und deutsches System → Deutsch; leer und französisches System →
Englisch; `"de"` gespeichert und englisches System → Deutsch; Müll gespeichert → Englisch.
- **Der Rückfall**: ein Schlüssel, den nur Englisch hat, kommt unter Deutsch englisch zurück.
- **Deutsch ist vollständig**, über die erzeugten Schlüssel.
- **Dass ein Sprachwechsel die Komposition erreicht** — dieselbe Bauform wie
`ThemeReadsPaletteStoreTest`, das bei den Farben genau diesen Fehler gefunden hat.
**Die PR-Prüfung fängt das alles schon.** Der `compile`-Auftrag in `pr-compile-check.yml` läuft
`./gradlew -S classes testClasses`; der Erzeuger hängt an `classes`, also schlägt eine kaputte
Übersetzung dort mit seiner eigenen Meldung fehl. Der `test`-Auftrag fährt die
Abgrenzungstests. **Kein neuer Workflow nötig** — das ist eine Folge davon, den Erzeuger in den
Bau zu hängen statt in ein eigenes Prüfskript.
## Was unverändert bleibt
- Die vier `ChatPreferences`-Vorgabewerte und alles, was in der gespeicherten XML eines
Operateurs steht: `bcn_beaconTextMainCat`, `bcn_beaconTextSecondCat`,
`messageHandling_autoAnswerTextMainCat`, `messageHandling_autoAnswerTextSecondCat`, sowie
Schnipsel und Kurztasten.
- `core` bekommt keine Nachschlagefunktion.
- Die Reiterreihenfolge im Einstellungsfenster; die Sprachwahl kommt in den bestehenden
GUI-Reiter.
- Keine neue Abhängigkeit, weder im Jar noch im Bau.
- `app-desktop`s übrige ~380 Texte bleiben englisch, mit der Regel aus „Die vier Textsorten"
für die Folgearbeit.
- Quelltextkommentare und Javadoc bleiben ausschließlich Englisch. Mehrsprachigkeit betrifft
die Oberfläche, nicht den Quelltext.
## Risiken
**Die Abgrenzung ist der Preis dieser Etappe, nicht die Mechanik.** Ein übersetzter
Protokollbefehl oder ein übersetztes Funkerkürzel fällt beim Bauen nicht auf und beim Hinsehen
auch nicht. Die drei Abgrenzungstests sind die Gegenmaßnahme, und der reflexionsbasierte unter
ihnen ist der einzige, der auch das abdeckt, woran beim Schreiben niemand gedacht hat.
**Ein Sprachwechsel erreicht alle Fenster — also auch alle falsch.** Der `LanguageStore` ist
wie der `PaletteStore` eine Stelle, an der ein Fehler sich überall gleichzeitig zeigt. Dafür
zeigt er sich sofort.
**Eine beigetragene Sprache kann halb englisch sein.** Das ist gewollt und der Preis dafür,
Beiträge überhaupt annehmen zu können. Die Deckungszeile macht es sichtbar; ob die Sprachwahl
dem Operateur den Deckungsgrad anzeigen soll, ist hier **nicht** entschieden und wäre eine
eigene kleine Erweiterung.
**Der Erzeuger ist neuer Bau-Code und damit eine neue Stelle, an der der Bau brechen kann.**
Dagegen hilft nur, ihn zu testen wie Produktionscode — deshalb steht er in der Prüfliste.
## Festlegungen, die sonst im Bau beliebig würden
**Die Regel ist der Adressat, nicht das Aussehen.** Wer beim Herausziehen unsicher ist, fragt
nicht „sieht das nach Oberfläche aus", sondern „wer liest das".
**Unvollständigkeit ist erlaubt, Unrichtigkeit nicht.** Das entscheidet jeden Zweifelsfall im
Erzeuger.
**Die Stelligkeit kommt aus der englischen Grundlage.** Eine Übersetzung kann einen Schlüssel
weglassen, aber nicht seine Platzhalter umdeuten.