A defect-fixing release. Ten dark-mode defects found by a Designer QA sweep
and fixed, each root-caused to a mechanism rather than patched by eye, and each
confirmed in a real Designer on 8.3.6. The theming passes gained failure
isolation, and the test harness gained two instruments it was missing:
component-level state diffing, and a check that everything this module reaches
by name still exists.
Added
-
A test for everything the module reaches by name
(#53).
This module works by reaching into Ignition, JIDE and JFreeChart internals as
strings — a class calledInlineTipLabel, a field calledCOLOR, a method
calledsetLineHighlightColor— none of which the compiler checks, and every
pass that uses one is deliberately guarded so a failure costs a surface rather
than the Designer. Together that means an IA rename stops a pass working
silently.ReflectiveSurfaceTestenumerates the whole surface (about 40
names) and asserts each still resolves, and CI now runs the harness at both
the 8.3.0 support floor and the current release. It cannot tell you a class
still behaves the same — that is what the QA checklist is for — but it turns
a silent regression into a red build. -
When
updateComponentTreeUIfails on a window, the module now says what
broke and how much of the tree went unrefreshed
(#12).
The failure was already survivable and already logged with its stack, but a
stack names the UI delegate that threw — not the component, and not the
subtree the aborted update never reached, which is the part that actually
matters. The report names components with no font at all and the path to each,
anyUIManagerfont key resolving to null, and every component left holding a
delegate from the wrong look and feel. Runs only on the failure path, so it is
not gated on the debug flag: that is precisely the moment nobody has verbose
logging on. -
The harness pins that no FlatLaf scaling listener is left registered
(#12).
With FlatLaf user scaling on,UIScaleregisters a listener on all three
defaults tables that is still there after the stock theme is back, reacting to
another look and feel's font changes. The module's only defence is one
ordering-sensitive line settingflatlaf.uiScale.enabled=falsebefore FlatLaf
loads, and nothing guarded it. Measured both ways: as shipped no such listener
appears anywhere; flip the property and all three tables carry one after a
restore. -
A headless look-and-feel harness (
./gradlew :designer:lafHarness,
#32).
It drives the real switch sequence against the real Synthetica, JIDE and
FlatLaf jars — no gateway, no Designer, no screenshots — and diffs every
resolvableUIManagerdefault across a light→dark→light cycle. The unit
tests only ever saw stub look and feels, so every bug this module has had
(#14, #17, #19, #22, #23) had to be found by deploying and looking. Four
invariants are now pinned instead: a full cycle restores every default, the
FlatLaf overrides are cleared while FlatLaf is still installed (the ordering
#23 got wrong), repeated cycles converge, and JIDE'sTheme.paintermap
comes back to its stock entries, the standard Swing colours actually go dark,
and noUIManagerkey naming a background stays light under dark mode —
which is #22
turned from a manual dump into an assertion over 174 keys. It runs in CI.Its blind spots are mapped and documented rather than assumed: state outside
UIManager(the IA colour tokens, the Synthetica singleton) is invisible to
it, the dark half is weakly covered because JIDE derives dark colours
correctly with no Designer present, and it cannot see pixels. A Designer
still settles "does this look right". -
A partly-applied theme now says so, in the Designer's status bar: which
passes failed, out of how many, and where to read the stack traces. Every
pass after the look-and-feel swap is isolated, so one that fails leaves a
Designer that works and is visibly wrong somewhere; the only record used to
be a log file nobody knows to look for. -
The Designer's status bar stays readable under dark mode.
StatusBar .setMessagere-assertsColor.blackon the message label on every call, so
its own messages were black on a dark bar. -
docs/QA-CHECKLIST.md — a sweep of Designer
surfaces with a pass/fail per surface, so coverage gaps are found before a
release rather than reported as bugs
(#5).
The surface list is drawn from the catalogue in the MIT-licensed
Exchange dark-mode script;
its 8.1 class names have shifted on 8.3, its UI locations have not. -
LookAndFeelDefaultsTableTest(./gradlew :designer:lafHarness) pins the
gap between the developer defaults table and the look-and-feel table: 109
colour keys resolve throughUIManagerbut not through
getLookAndFeelDefaults()under the stock look and feel, and the restore
leaves that set exactly as it found it. Ignition code reading a colour
that way gets null in a stock Designer and a real colour under dark mode,
which is the opposite of the intuitive direction and has already caused
one stack trace to be misread.
Changed
-
The headless harness runs against the current Ignition, not the support
floor (#53).
sdk_versionstays at 8.3.0 — it is what the module compiles against and what
module.xmlclaims as the minimum — but the harness now resolves
harness_sdk_version(8.3.8 by default,-Pharness.sdk=8.3.6to pin). The
module reaches Ignition and JIDE internals by name, and none of that is
compile-checked, so testing it against jars nobody runs was the weakest part
of the setup. The full suite passes against 8.3.8. -
Inline tip banners are readable under dark mode again
(#47).
InlineTipLabel.paintComponentfills with a literal#D6E4ED, so no
look-and-feel swap reaches it — while its text is IA'sBase900token, which
this module lightens. The result was light-on-pale: not merely wrong but
illegible, in eleven places including Help → Diagnostics, the permissions
configurator and the UDT multi-instance wizard. The fill now darkens with the
other class constants and restores with them. -
SQL and expression editors are themed
(#48).
"The script editor" is two components: the Python editors are
RSyntaxTextAreaand were already covered, while JIDE'sCodeEditor— the
Database Query Browser and every expression editor in the Designer, 46
classes' worth — was not. Under dark mode it kept a cream#FFFFD7band
across the current line, a black caret on dark chrome, and syntax tokens
at#000000,#000080,#650099. A newCodeEditorThemelifts each syntax
colour to a readable luminance while keeping its hue, so a keyword still reads
as a keyword, and restores every value on the way back to light. -
The Tag Browser's
Valueheader and the Perspective property editor's
filter now come back when dark mode is switched off
(#45). Two different
mechanisms, both invisible to the defaults diff — everyUIManagerkey was
already coming back correct.The header is painted by a cell renderer, which is not in the component
hierarchy.JTableHeader.updateUI()reaches its default renderer anyway, but
only while that renderer is aComponent: on the way into dark mode it is, so
Swing callsDefaultTableCellRenderer.updateUI()— literallysuper.updateUI(); setForeground(null); setBackground(null);— and destroys the colours
SimpleTreeTable$SimpleHeaderRenderersets in its constructor and never sets
again. On the way back the renderer is wrapped in ours, which is not a
Component, so Swing skips it and the header keeps null colours and a FlatLaf
delegate for the rest of the session. The colours are now snapshotted before
the switch can wipe them, and the delegate is put back explicitly.The filter is JIDE's
LabeledTextField, whoseupdateUI()ends in
setEnabled(), which doessetBackground(getTextField().getBackground()).
updateComponentTreeUIwalks parent first, so on the restore the wrapper
copies the inner field's still-dark#46494Bonto itself a moment before that
field goes light. A second pass now re-runsupdateUI()child-first on
anything left holding a darkUIResourcebackground.Measured on the real components: a light→dark→light cycle over a
SimpleTreeTableand aQuickFilterFieldrendered 15,462 pixels different
from stock before the fix and 0 after. -
The tree-update diagnostic now reports null backgrounds and foregrounds,
not only fonts. It was written against the description in
#12,
which named aFont.getFamily()NPE. The failure actually caught in the wild
wasColor.getAlpha()on a null background, so the instrument was looking at
the wrong property and would have reportedno font at all: 0while saying
nothing about the cause. ItsUIManagersweep now covers every key rather
than font-ish ones too. -
Opening a Vision window no longer floods the Designer with
minimumSizeerrors. Vision'sDockingInternalFrameUI.installDefaults
nulls the content pane's background when it is aUIResource, and a Vision
content pane is aBasicContainerwhosesetBackgroundcannot take null —
so it threw three instructions beforeframe.setLayout(...), leaving the
frame with no layout and every latergetMinimumSize()NPEing, once per
paint and per property-table read. The condition is ours to remove: the
background is aUIResourceonly because a previous walk of ours put one
there. The walk now swaps the same colour in as a plainColorbefore
updateUI()and restores aUIResourceafterwards, so IA's block skips
itself, the layout is installed, and the content pane still tracks the theme. -
One component throwing out of
updateUI()no longer strands the rest of
the Designer's tree. Swing'supdateComponentTreeUIis an unguarded
recursion, so the first throw abandons every component after it — and the
tree that aborts is usually the main frame's, leaving everything below the
throwing component on the outgoing look and feel's delegates. Isolating per
window
(#11)
was not enough. The walk is now per component, at every
call site — the switch's own pass, the component watcher's debounced rescan,
and the stale-delegate refresh: a failure costs that component, its siblings
and its own subtree are still walked, and the distinct failing classes are
logged once each. The rescan is aTimercallback, so a throw there did not
merely lose the tree — it reached the EDT's default handler and killed the
tick, including the theming passes that are the point of the rescan.Containment is the net, not the cure — where the throw leaves a component
half-configured, it has to be prevented instead, as the Vision entry above
does. What containment is right for is the case this module cannot reach at
all: theFont.getFamily()NPE in
#12,
whose cause is still open. The diagnostic added for that issue now also runs
on the per-component failure path, since containing the throw made the
window-level path it was written against nearly unreachable. -
Alarm pipeline and SFC blocks are legible under dark mode. A block paints
itself fromColorfields assigned literals inBasicBlockUI's constructor
—#B0D8EAconnected,#EEEEEEunconnected — which no look-and-feel swap
and noUIManageroverride can reach, while its title label is a plain
new JLabelthat inherits FlatLaf's lightLabel.foreground. Light text on
a pale fill. The fills are now darkened instead of the labels corrected,
since pale blocks on a dark canvas would be a different kind of wrong. Each
colour is judged on its own luminance rather than by which field holds it:
StartBlock$UIpushes IA's#F7901Eorange through the same public setters,
and the START block was the one thing on that canvas that already read
correctly. -
Opening a Vision window no longer crashes the Designer's event thread with a
StackOverflowErrorout ofCellRendererSanitizer. JIDE'sCheckBoxList
does not return the renderersetCellRendererreplaces — it hands back a
decorator that it re-points at the list's own renderer field on every call,
and SwingX'sJXListdoes the same thing with aDelegatingRenderer— so
wrapping whatgetCellRenderer()returned left the wrapper and the decorator
delegating to each other, and the first painted cell recursed until the stack
ran out. The wrapper now goes under such a decorator (both publish the real
renderer, and between them that covers every decorating list on the
Designer's classpath), a list that hides its real renderer is left unwrapped
rather than risked, and the wrapper breaks any remaining cycle instead of
overflowing. Those lists are dark-adapted as they always should have been. -
A failure to keep the Synthetica singleton alive is no longer silent
(#35).
Installing FlatLaf nulls Synthetica's privateactiveInstance, and the
Designer keeps callinggetInstance()the whole time dark mode is active, so
the module reflectively points it back. That repair swallowed its own
exceptions and logged a warning: it never reached the failed-phase count, so
the switch reported complete success while every such call NPE'd out of
Ignition's own code. It now runs as a reported pass like every other, first on
the dark switch since nothing may call into Synthetica before it. The field is
matched by name in a third-party jar, so the failure that matters is a
Synthetica upgrade renaming it — which would otherwise have broken dark mode
for everyone on one release, silently. -
The Tools → Dark Mode checkmark no longer disagrees with the theme in
effect (#15).
It tracked the request: ticked the moment you clicked, whether or not the
switch then worked, and a failed switch left it claiming a theme the Designer
was not in — permanently, and retried at every launch. The checkmark and the
saved preference are now both set from the look and feel actually installed
once the switch has finished. -
A switch no longer looks like a click that did not register. It runs one
event-queue turn late, behind an "Applying dark mode…" message painted
before the event dispatch thread blocks, with the menu item disabled so a
second click cannot queue an opposite toggle. -
The debug log has two levels.
DebugLog.log(theme switches, failures) always
writes; the per-pass counts, icon classes, popup contents and stale-delegate
traces moved toDebugLog.detail, which writes only under
-Ddesignerdarkmode.debug=true. The component watcher re-runs the theming
passes for the whole session, so those lines were unbounded — and every one
cost a file open, write and close on the event dispatch thread. Popup state
was logged on every popup menu creation, and a renderer-straggler walk
ran over every painted table cell; both are now behind the same flag. -
The log file is opened once and held for the session, flushed per line.
Fixed
-
Reporting's Report Overview is readable
(#59).
Its headings and report title were not dim but invisible:HeaderLabeland
AntialiasLabelcarry a literal#454545, which on the dark surface's
#3C3F41is a contrast ratio of about 1.1:1. An explicit (non-UIResource)
foreground is invisible to the look and feel by design, and the module lifted
such foregrounds only as a rider on a background swap — which a component
already sitting on a dark background never gets. Explicit dark text is now
lifted on its own merits, guarded so text that would land on a light
background is left alone. Fixing it exposed a second defect in the same place:
lifted originals were restored only for components whose background had been
swapped from white, so a lift made by any other branch was recorded and never
put back, stranding near-white text on the light theme. -
The Data tab's parameter and data-source headers are dark
(#59).
A JIDEGroupListpaints its group headers through a second renderer slot
thatsetCellRenderernever touches, so the rows came out correctly themed
under light-blue "Parameters" and "Data Sources" bars. The list itself probes
clean — a group header is not a component, the same way a table's cell
renderers are not. -
The palette's clear-search button is no longer a bright blob
(#60).
Two mechanisms had to give. The icon walk readsgetIcon()once, and JIDE
fills this button's icon slot only when there is text to clear, so it was
never processed at all; the slot is now watched. That alone was not enough:
the icon is not too dark but too light (a measured 226), so the
enabled/disabled pair swap installed the icon already installed, changed
nothing, and still reported the button handled — blocking the smart invert.
The pair is now declined when it is a no-op and the icon is above 200, which
is the point at which an icon drawn to be near-invisible on near-white chrome
becomes the loudest thing on a dark surface. The threshold clears every other
no-op pair on the Designer's classpath (the toolbar's vector icons at 174, the
Vision palette at 183, popup menus at 109), all of which keep their icons. -
The Tag Browser tree no longer throws on every repaint
(#58).
TreeIconRecolorerwraps a tree's cell renderer to recolour its icons, but
TagBrowserTreepublishes its renderer through a typed accessor that casts —
(TagRenderer) getCellRenderer()— and calls it from its ownpaint. The
wrapper therefore turned every repaint into aClassCastExceptionon the
EDT, swallowed by the paint loop, so the only symptom was a stack trace in
the Output Console. Trees that publish a typed renderer accessor are now
skipped, detected by shape rather than by class name. -
The Tag Editor's combo cells are dark and readable
(#57).
They rendered as white fields carrying our own lightened text — pale fill,
light text, barely readable. The cause was not the renderer but the plumbing:
the pass that replaces a table'sCellRendererPane(the only thing that
catches tables resolving a renderer per cell, as JIDE property grids do)
remembered which tables it had done and never revisited them. But
updateComponentTreeUIrebuilds a table's UI and installs a fresh pane, so
any table refreshed after being intercepted silently lost the interception.
The guard is now on the live pane rather than on the table. -
The pale band under the Tag Browser's
Tag | Valueheader is gone
(#21).
SimpleTreeTable$SimpleHeaderRenderergives every header cell a compound
border of 8pxColor.WHITEover 1px ofTable.gridColor, and the grid colour
is astatic finalcaptured at class-init, so it kept the light theme's
#C0C5CAfor the life of the Designer. A header cell is a rubber stamp,
configured and painted through aCellRendererPaneand never added to
anything — which is why thirty component inspections came back clean while the
band stayed on screen. -
One code editor that throws no longer costs the rest. JIDE raises
IndexOutOfBoundsException: Wrong line: -1out of
setBracketHighlightColoron an editor with no valid caret line. The first
cut of the editor pass treated any throw as systemic and gave up, which
silently left every later expression editor light for the rest of the
session — found in a debug log, not on screen. Failures are now isolated per
property and per editor, and only a genuinely systemic failure (a method that
has moved) stops the pass. -
The Output Console's log text is readable
(#52).
Two consoles in the Designer are coloured two different ways, and only one of
them was covered. The Script Console uses named document styles; the Output
Console dock registers appenders on the bifurcatedSystem.out/System.err
holdingColor.blackandColor.redand stamps that colour onto every
inserted run — and since the Designer's logging goes through stdout, that is
the entire console. Neither colour may be mutated (they are the JDK globals,
the same rule that protectsBase000), so the pass rewrites the runs already
in the document and repoints the appenders for lines still to come. The
restore maps back by colour rather than by offset, which is what makes it
survive the console being trimmed as it grows. -
The Diagnostics performance charts' axes are readable
(#50).
IA colours the plot background and gridlines from its own design tokens, which
this module already restyles — but never sets the axis paints, so those kept
JFreeChart'sColor.blackdefaults and the axis numbers and titles sat black
on dark. A new pass sets the label, tick-label, axis-line and tick-mark paints
and restores them. Targeted atDynamicTimeSeriesChartby name rather than at
any chart: Vision windows render user charts, and repainting those would
misrepresent what an operator sees. -
The Query Browser's result-table buttons no longer show pale boxes
(#51).
ResultTable$EditButtonpaints its own gradient behind the label from eight
literal colours, the first of which is plain white. The button component
itself is correctly dark, which is why inspecting it came back clean while the
screen showed the problem. The seven fills join the class constants
IaColorTokensdarkens; the two amber focus/hover accents are left alone.