Files
kst4contest/docs/superpowers/specs/2026-09-25-compose-migration-design.md
Rsclub2_2andClaude Opus 5.5 2a5c89b943 Next major release/version1.50 (#89)
* Make DBController work on one database file per instance

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

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

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

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

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

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

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

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

* Introduce the operator profile model and its registry

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

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

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

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

* Select the operator profile at startup

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

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

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

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

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

* Release the background resources a discarded runtime owns

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

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

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

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

* Switch the operator profile while the application is running

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

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

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

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

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

* Manage operator profiles from the settings window

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

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

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

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

* Document operator profiles

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

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

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

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

* Make the terrain profile cache multi operator safe

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

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

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

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

* Fail cleanly when the registry path has no directory

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

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

* Code Version 1.50

* Add the in-app update concept for 1.50

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

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

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

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

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

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

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

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

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

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

* Use the macOS system menu bar in all windows

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

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

* List direct commits on main in the release notes

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

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

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

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

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

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

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

* Draw the whole interface in Compose

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

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

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

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

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

* Remove JavaFX

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

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

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

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

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

The binding docs and the manual no longer describe a JavaFX client.

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

What the removal left open (4 commits).

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

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

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

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

* Make the colours adjustable per operator profile

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

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

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

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

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

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

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

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

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

101 new tests. All of it runs headless.

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

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

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

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

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

* Speak the operator's language

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

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

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

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

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

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

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

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

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

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

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

* Remove the implementation plans now that every stage is executed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Port the rebased diagnostics and session logging onto the modules

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

32 KiB

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.
  7. Ob die Farbpalette pro Profil oder pro Installation gilt — vor Etappe 8.