Light and Dark Themes
This page is the Design Document for Light and Dark Themes. To be referred by AI for coding as well as humans. It is a plan; sections marked as decisions are locked, defaults are changeable until first implementation.
The feature: one application-wide theme setting with exactly three values — 1: System
(default), 2: Light, 3: Dark — living in the Application Tab’s Settings view (tab 0, see
website/content/software/tabs.md). There are no customizable themes: no user palettes, no
accent pickers, no per-document themes. The only two extra knobs are background-color
overrides for the Scene3D sky and the Page2D drawing area. The theme changes the whole shell
— top ribbon, tab bands, panes, widgets — and the drawing-area backgrounds, with the classic
CAD black↔white entity remap so 2D content stays visible on a dark canvas. All colour codes and
per-theme palettes are defined in code-core/colors.h.
1. Current state (anchor points in code)
colors.h: today holds onlykUIDisabledTextGray, the Scene3D sky gradient pair (kSceneSkyTop*/kSceneSkyHorizon*) and the Page2D background (kCad2DBackground*). This file becomes the single home of all theme palettes.struct UIColors(UserInterface.h~line 319): the ribbon palette (tab band, action groups, hover, disabled icon) with light-theme defaults. Its own comment already anticipates this document: “The default values specified here are for theme Light. These can be overridden by other themes (e.g. Dark)”. GlobalsuiLightDefaultColors, uiActiveColors(UserInterface.cpp~line 122); every band/pane draw already readsuiActiveColors.*.- Hardcoded colour literals bypassing
UIColorsare scattered throughUserInterface.cpp(inventory in §5). One of them even carries the marker// hover tint (TODO: theme-aware)(PushInteractiveRect, ~line 729). - Scene3D sky:
ClearSceneSkyGradient(RenderScene3D-DirectX12.cpp~line 105) pushes the two gradient stops as root constants every frame — runtime re-colouring needs no pipeline or resource change at all. - Page2D background has four coupled consumers, all reading the same constexpr:
- Compositor per-frame RTT clear (
RenderCompositor-DirectX12.cpp~line 209). - The baked optimized clear value at RTT creation,
शंकर::InitD3DPerWindow(RenderCompositor-DirectX12.cpp~line 576). - The same baked value in the resize path,
शंकर::ResizeD3DWindow(~line 734). - Page2D’s own clear (
RenderPage2D-DirectX12.cpp~line 651). The comment incolors.hrecords the constraint: clearing to any colour other than the baked value forfeits the fast clear and tripsCLEARRENDERTARGETVIEW_MISMATCHINGCLEARVALUE. A theme switch therefore has to recreate the RTT textures, not just swap a constant.
- Compositor per-frame RTT clear (
- Page2D entity colours: every 2D creation path defaults to pure black
0xFF000000u(RenderPage2D.cpplines 129–350). Line/curve colours live in GPU records (Cad2DLineGPURecord.colorABGR) and are resolved in the vertex shader (Shader2D_LineVertex.hlslline ~103 already remaps to the selection blue when the SELECTED flag is set). Text colours are baked per vertex (Cad2DTextVertex.colorABGR) but pass throughShader2D_TextVertex.hlsl. All three vertex shaders bind the samecbuffer Cad2DViewConstants : register(b0), which has an unusedfloat padding0(RenderPage2D.h~line 276,static_assert32 bytes). This is the perfect slot for a theme flag — no ABI growth, no re-tessellation. PrinterController.cpp(~line 132) fills the sameCad2DViewConstantsfor printing. Paper is always light; the print path must never see the dark remap.RestartRenderThreads(RenderCompositor-DirectX12.cpp~line 1049): the proven join-threads → drain-fences → rebuild → relaunch pattern used for monitor topology changes and per-monitor icon atlas rebuilds. Its “rare event, brief pause is acceptable” reasoning applies verbatim to a theme switch.- Icon atlases are theme-safe as-is: monochrome icons are tinted by vertex colour at draw time,
multi-colour icons are drawn with a white tint (shader multiplies). No atlas rebuild is
needed for a theme change (the ride through
RestartRenderThreadsrebuilds them anyway, which is harmless). - Data tree text colour already flips by content (
useDarkDataTreeText,UserInterface.cpp~line 975) — dark text over the light Page2D/Scene3D canvases, white otherwise. The condition is keyed on container type, which becomes wrong the moment canvases can be dark (§6). - Settings persistence: none exists yet.
%LOCALAPPDATA%\Mission Vishwakarma\is the established per-installation data folder (AccountManager.cpp~line 26,ImprovementData.cpp~line 449). This feature introduces the first settings file. - System theme detection: none exists. No
dwmapiusage anywhere yet either, so OS title bars are always light today. - Pre-existing dead constants
COLOR_UI_TAB_ACTIVE/COLOR_UI_TAB_INACTIVE(UserInterface.hlines 184–185) are defined but never used — noted here, not touched.
2. Locked design decisions
- Exactly two compile-time palettes.
colors.hdefineskUIColorsLightandkUIColorsDarkasconstexpr UIColorsinstances plus per-theme background constants. Users pick a mode; they never edit palette entries. The only user-supplied colours in the whole system are the two background overrides. - Three-state mode, two-state result.
enum class ThemeMode : uint8_t { System = 1, Light = 2, Dark = 3 };(numbering per the requirement) is what is stored;ResolveActiveTheme()collapses it to Light or Dark. Everything downstream sees only the resolved two-state value — no code outside the resolver ever asks “is mode System?”. colors.his the single home of colour definitions.struct UIColorsmoves fromUserInterface.hintocolors.h(whichUserInterface.hthen includes), joined by both palette instances, the per-theme sky/page background constants, the shared theme-independent colours (§4), and the luminance helper. Every hardcoded UI colour literal in §5 migrates into aUIColorsfield or a named shared constant. After Phase 1, a colour literal outsidecolors.his a code-review defect.- Theme is global. One theme for the whole process — every window, every monitor, every tab. No per-window or per-document theming.
- Palette globals mutate only while render threads are joined.
uiActiveColorsand the newg_activeBackgrounds(§6) are written exclusively inside theRestartRenderThreadsdrain window, on the UI thread. Render threads keep reading them lock-free with zero risk of a torn frame. This is the single most important correctness rule of this feature. - A theme switch rides
RestartRenderThreads. Because the Page2D background is a baked RTT clear value, applying a theme means recreating every window’s RTT textures. Rather than inventing a second drain path,ApplyThemeChange()records the pending palette and callsRestartRenderThreads(), which gains one step: after the join+drain, if a theme apply is pending, swap the palette globals and recreate each published window’s RTTs (same descriptor slots, new baked clear value). Theme switches are rare user events; the brief pause is the same one users already accept for monitor changes. The fast clear stays intact and the debug layer stays silent. - Page2D entity visibility is solved in the shader, not the data. Stored entity colours
are never rewritten. The three 2D vertex shaders remap pure black ↔ pure white
(
0xFF000000↔0xFFFFFFFF, both directions) when the view constants say the background is dark. Any other colour passes through untouched. This is the AutoCAD-familiar behaviour for “foreground” entities, costs one compare per primitive, needs no re-tessellation, and takes effect the same frame. The selection highlight0xFFA6260Dis not remapped. - The remap and all “over the canvas” colours key on effective background luminance, not on
theme name. Overrides can make a Light-theme canvas dark (or vice versa); black entities,
the data-tree text and the canvas cursor crosshair must follow the actual background. Rule:
IsDarkBackground(r,g,b)⇔ Rec.709 luma0.2126R + 0.7152G + 0.0722B < 0.5(normalized), as a constexpr helper incolors.h, evaluated once per apply (not per frame). - Overrides are plain RGB values, one per view type, theme-independent.
scene3d.backgroundandpage2d.backgroundeach hold either “absent = use active theme default” or one#RRGGBB. A Scene3D override replaces both gradient stops (flat sky — simplest thing that works; auto-derived gradient is a changeable default, §11). No per-theme override pairs — that is customization creep this feature explicitly rejects. - Printing is always light.
PrinterControllersets the new view-constants theme field to 0 (light) unconditionally and never applies the remap or dark background. Paper wins. - The splash screen is brand, not theme.
kSplashBackgroundColor/kSplashTextColorand the tricolour logo stay identical in both themes. - Settings live in a new plain-text file
%LOCALAPPDATA%\Mission Vishwakarma\Settings.txt(§8) — the file that tabs.md’s Launcher open question (“likely next to the AccountManager identity data”) anticipated. Read once at startup on the UI thread before render threads launch; rewritten in full on every change. The UI thread is the only reader and writer.
3. What is themed, what is not
| Surface | Light → Dark behaviour |
|---|---|
| Tab band, sub-tab band, ribbon action groups | Full palette swap (uiActiveColors) |
| Properties pane, right icon bar, data tree panel, dropdowns, scrollbars, toasts, text fields | Palette swap via migrated fields (§5) |
| Scene3D sky gradient | Per-theme gradient pair, or flat override colour |
| Page2D drawing background | Per-theme colour, or override; RTT baked clear follows |
| Page2D entities | Pure black ↔ pure white shader remap on dark canvas; all other colours untouched |
| Canvas cursor crosshair + caret glyphs drawn over canvas | Black/white by canvas luminance |
| OS window title bar | DWMWA_USE_IMMERSIVE_DARK_MODE per window (until the frameless window of tabs.md removes it) |
| Splash screen | Not themed (brand) |
| Icon colours inside buttons | Unchanged (per-icon colours / dev placeholder StableRandomUIColour) |
| Printed output | Always light |
| Website / documentation | Out of scope (Hugo has its own theme) |
4. colors.h layout after Phase 1
enum class ThemeMode : uint8_t { System = 1, Light = 2, Dark = 3 }; // Stored setting.
enum class ActiveTheme : uint8_t { Light, Dark }; // Resolved, two-state.
struct UIColors { /* moved from UserInterface.h, grown by the §5 fields */ };
constexpr UIColors kUIColorsLight { /* today's values */ };
constexpr UIColors kUIColorsDark { /* table below */ };
// Scene3D sky, per theme (existing kSceneSky* become the Light set):
constexpr float kSceneSkyLightTop[3], kSceneSkyLightHorizon[3];
constexpr float kSceneSkyDarkTop[3], kSceneSkyDarkHorizon[3];
// Page2D background, per theme (existing kCad2DBackground* become the Light value):
constexpr float kCad2DBackgroundLight[3];
constexpr float kCad2DBackgroundDark[3];
// Theme-independent (shared) colours:
// kUIDisabledTextGray (existing), selection blue 0xFF3399FF, hover saffron 0xFFFF9933,
// Page2D selection highlight 0xFFA6260D, splash colours.
constexpr bool IsDarkBackground(float r, float g, float b); // Decision 8.
Proposed dark palette — changeable defaults until first implementation. Values keep the
green family of the light theme and the saffron brand hover. Reminder from the splash-colour
comment (UserInterface.cpp ~line 913): these are ABGR (0xAABBGGRR) — get the channel
order right or greens come out purple.
UIColors field |
Light (existing) | Dark proposed | Dark ABGR |
|---|---|---|---|
tabBackground |
rgb(221,229,212) | rgb(25,29,23) #191D17 |
0xFF171D19 |
tabBackgroundText |
rgb(84,98,78) | rgb(156,178,164) | 0xFFA4B29C |
tabActive |
rgb(248,251,241) | rgb(36,41,38) | 0xFF262924 |
tabActiveText |
rgb(23,29,25) | rgb(233,237,235) | 0xFFEBEDE9 |
tabBackgroundHover |
rgb(196,213,205)* | rgb(45,52,47) | 0xFF2F342D |
actionGroupBackground |
rgb(255,255,255) | rgb(30,30,32) | 0xFF201E1E |
actionGroupSeperator |
rgb(209,209,209) | rgb(72,72,76) | 0xFF4C4848 |
actionGroupHoverBackground |
saffron 0xFFFF9933 |
same (brand) | 0xFFFF9933 |
actionText |
rgb(36,36,36) | rgb(228,228,228) | 0xFFE4E4E4 |
actionIconDisabled |
rgb(136,136,136) | rgb(105,105,105) | 0xFF696969 |
* light tabBackgroundHover shown decoded from 0xFFC4D5CD.
Backgrounds — changeable defaults:
- Scene3D dark sky: top
#0D1117(13,17,23), horizon#202731(32,39,49) — a deep night-blue gradient, dark enough that white/light geometry pops, not pure black. - Page2D dark background:
#212830(33,40,48) — the near-black blue-gray CAD users know from AutoCAD’s model space, deliberately not#000000so pure-black→white remapped entities and the true-black selection rectangle remain distinguishable.
5. Hardcoded-literal migration inventory (Phase 1)
Every literal below moves into a (new) UIColors field with light+dark values, or is declared a
named shared constant in colors.h. Line numbers are approximate anchors in UserInterface.cpp
unless noted.
| Anchor | Literal today | Disposition |
|---|---|---|
PushInteractiveRect ~729–731 |
hover 0xFF555555, pressed 0xFF333333, disabled 0xFF1E1E1E |
fields interactiveHover/Pressed/DisabledBase (the existing TODO: theme-aware) |
| Widget field ~886–887, ~1933 | field bg 0xFFF7F7F7, text 0xFF000000 |
fields widgetFieldBackground/Text |
| Dropdown ~899–904 | border 0xFFCCCCCC, row hover 0xFFC4D5CD, selected 0xFFE8F2ED, row 0xFFFFFFFF, label 0xFF666666 |
fields widgetBorder, dropdownRow*, widgetLabelText |
| Data tree ~979–981 | text black/white flip, active 0xFF3399FF, hover 0xFFFF9933 |
text: luminance rule (§6); active/hover: shared constants |
| Sub-tab band ~1360–1367, ~1478 | 0xFF1E1E1E, 0xFF2D2D30, 0xFF444444 |
fields for the sub-tab close/hover chrome |
| Scrollbar ~1619–1622 | track 0x66333333, thumb 0xFF3399FF / 0xFF5CB4FF / 0xCC8A8A8A |
fields scrollbar* |
| Toast ~2189–2191 | bg 0xFF333333, text 0xFFFFFFFF |
fields toast* |
| Right pane dropdown panel ~1996 | 0xFF1E1E1E |
field (shared with sub-tab chrome) |
| Canvas cursor icons ~2041, 2155, 2173 | 0xFF000000 |
luminance rule — black cursor on light canvas, white on dark (§6) |
| Text caret ~1952, 2108 | 0xFF000000 |
follows widgetFieldText |
| Tab separators / accents ~1252, 1293 | 0xFF3399FF, 0xFF555555 |
accent: shared constant; separator: field |
RenderPage2D.cpp entity defaults |
0xFF000000 |
unchanged — stored data stays black; §7 handles display |
Behaviour-preserving check for the whole phase: with theme forced Light, the UI must be pixel-identical to today.
6. Background pipeline and the apply sequence
New global (written under Decision 5’s joined-threads rule only):
struct ActiveBackgroundColors {
float scene3DTop[3]; float scene3DHorizon[3]; // theme default or flat override
float page2D[3]; // theme default or override
bool scene3DIsDark; bool page2DIsDark; // IsDarkBackground(), precomputed
};
inline ActiveBackgroundColors g_activeBackgrounds; // starts as Light defaults
Call-site conversion (all four Page2D-background consumers of §1 plus the sky constants read
g_activeBackgrounds instead of the constexprs):
ClearSceneSkyGradient: root constants fromg_activeBackgrounds.scene3D*.- Compositor clear ~209, Page2D clear ~651:
g_activeBackgrounds.page2D. InitD3DPerWindow~576 andResizeD3DWindow~734 baked clear value:g_activeBackgrounds.page2D. Both always run while the affected window has no in-flight frames, so baked value and per-frame clear can never disagree.- Data-tree text over canvas (~975): replace the container-type condition with the
luminance flag of whatever the active sub-tab shows (
page2DIsDark/scene3DIsDark); canvas cursor icons and canvas carets use the same flag.
ApplyThemeChange() (UI thread; no-op if the resolved palette + backgrounds are unchanged):
- Resolve
ThemeMode→ActiveTheme(System consultsQuerySystemPrefersDark(), §8). - Compose the pending
UIColors+ActiveBackgroundColors(overrides applied, luminance flags computed). Store into a pending slot; setg_themeApplyPending = true. - Call
RestartRenderThreads(). Inside, after the existing join + per-monitor fence drain: ifg_themeApplyPending, copy pending →uiActiveColors/g_activeBackgrounds, then for every published window release and recreate itsrenderTextures[FRAMES_PER_RENDERTARGETS]with the new baked clear value, re-writing the same RTV/SRV descriptor slots (factor the RTT block ofResizeD3DWindow~731 into a smallRecreateWindowRTTs(dx)shared by both callers). Fences are already drained, so immediate release is safe — the same justification the icon-atlas drain uses. - Set
DWMWA_USE_IMMERSIVE_DARK_MODEon every top-level window (§8). - Threads relaunch as usual; the first new frame is fully in the new theme.
7. Page2D entity remap (shader ABI change)
Cad2DViewConstants.padding0 is renamed to float backgroundIsDark (0.0 or 1.0) in
RenderPage2D.h and all three vertex-shader cbuffers — size stays 32 bytes, the
static_assert is the tripwire. RenderPage2D-DirectX12.cpp (~670) fills it from
g_activeBackgrounds.page2DIsDark; PrinterController.cpp leaves it 0 with a comment citing
Decision 10.
Remap applied identically in Shader2D_LineVertex.hlsl, Shader2D_CurveVertex.hlsl (on the
record colour, before the selection override) and Shader2D_TextVertex.hlsl (on the baked
vertex colour):
uint c = rec.colorABGR;
if (backgroundIsDark != 0.0) {
if (c == 0xFF000000u) c = 0xFFFFFFFFu; // black -> white on dark canvas
else if (c == 0xFFFFFFFFu) c = 0xFF000000u; // white -> black (symmetric)
}
DXF round-trips benefit for free: imported colour-7 (“black/white”) entities behave exactly as they did in the source CAD package. Snapping markers, grid/axis helpers and the selection rectangle are audited in Phase 2 — anything drawn through the same record path inherits the remap; anything hardcoded joins the §5 inventory.
8. System theme detection and OS chrome (Windows)
QuerySystemPrefersDark(): readHKCU\Software\Microsoft\Windows\CurrentVersion\Themes\ PersonalizevalueAppsUseLightTheme(RegGetValueW, DWORD; 0 ⇒ dark, missing ⇒ light).- Live change:
WndProchandlesWM_SETTINGCHANGEwherelParampoints to the string"ImmersiveColorSet"; if the stored mode is System and the resolved theme actually changed, callApplyThemeChange(). Guard withgpu.isGPUEngineInitializedexactly like the adjacentWM_DISPLAYCHANGEhandler (Main.cpp~line 1390) — same deadlock reasoning. - Title bars:
DwmSetWindowAttribute(hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE /*20*/, &darkBOOL, sizeof(BOOL))at every window creation and insideApplyThemeChange. Adds adwmapi.liblink (first use in the project). This is interim polish only — the Chrome-style frameless window (tabs.md future phase) deletes the OS title bar entirely. - Linux/Mac:
QuerySystemPrefersDark()is the single platform seam; other platforms supply their own implementation later. Nothing else in this design is platform-specific.
9. Settings persistence
First settings file of the application: %LOCALAPPDATA%\Mission Vishwakarma\Settings.txt,
UTF-8, one key = value per line, no comments, unknown keys preserved on rewrite (the file is
read into an ordered key→value map, mutated, written back whole — future features like the
Launcher’s recent files add keys without a format change).
theme = system (system | light | dark; missing/unparseable => system)
scene3d.background = #0D1117 (line absent => theme default)
page2d.background = #212830 (line absent => theme default)
New portable file pair Settings.h / Settings.cpp hosts the map load/save, the typed
accessors for these three keys, QuerySystemPrefersDark() (the one #ifdef _WIN32 corner) and
ResolveActiveTheme(). Loaded in wWinMain before render threads launch, so the very first
frame is already themed — no light flash on startup.
10. Settings view UI (Application Tab)
Lives in tab 0’s Settings view and follows tabs.md’s rendering model (immediate-mode overlay,
UI-thread state ownership). Until tabs.md Phase 3 ships, Phases 1–3 of this document are
exercised by editing Settings.txt and restarting — the UI is deliberately the last phase.
- Theme row: label +
BuildUIDropdown(the existing reusable dropdown) with the three items System / Light / Dark. - Two override rows: label + text field (
UITextEditStatemachinery, accepts#RRGGBB) + a small colour swatch rect + a “Default” button that clears the override. Invalid input is rejected on commit (field border flips to the existing error red, value untouched). - Commit path: control emits
kThemeSettingUIAction = 0xE0000040u(p1 = which setting, p2 = packed value) →ProcessPendingUIActionson the UI thread updates the settings map, rewritesSettings.txt, callsApplyThemeChange(). Tab 0 actions are UI-thread-handled by design (tabs.md Decision 2), so this needs no engineering-thread involvement.
11. Phases
Phase 1 — Palette consolidation (zero visible change)
UIColors moves to colors.h; both palettes defined; §5 literal inventory migrated; shared
constants named; ThemeMode/ActiveTheme/IsDarkBackground added. App still hardwired Light.
Verify: warning-free build; screenshot comparison of ribbon, properties pane, data tree,
dropdown, toast and splash is pixel-identical to before; grep '0xFF[0-9A-Fa-f]\{6\}' over
UserInterface.cpp finds no colour literals left (excluding the splash block).
Phase 2 — Dark rendering end to end (forced via settings file)
Settings.txt load; g_activeBackgrounds + call-site conversion; RTT rebuild step inside
RestartRenderThreads; Cad2DViewConstants.backgroundIsDark + three shader remaps; printer
pinned light; DWM title bar; overrides honoured.
Verify: theme = dark renders dark ribbon, dark sky, dark Page2D with black entities shown
white and selection highlight still legible; debug layer stays silent (fast clear intact — no
CLEARRENDERTARGETVIEW_MISMATCHINGCLEARVALUE); print output unchanged vs light theme;
page2d.background = #FFFFFF under dark theme flips entities back to black (luminance rule,
not theme rule); window resize in dark theme keeps the dark baked clear; cursor crosshair
visible on both canvas polarities.
Phase 3 — System mode + live switching
QuerySystemPrefersDark(), WM_SETTINGCHANGE handler, ApplyThemeChange wired.
Verify: with theme = system, toggling Windows Settings light↔dark restyles the running app
(all windows, all monitors) after one brief render pause; toggling with mode Light/Dark does
nothing; rapid toggling doesn’t leak (GPU memory flat across 20 switches — the §6 rebuild
releases old RTTs); no torn half-themed frame is ever visible.
Phase 4 — Settings view UI (depends on tabs.md Phase 3)
Dropdown + override fields + persistence round-trip as §10.
Verify: every control round-trips through Settings.txt and survives restart; live apply on
commit; invalid hex rejected; “Default” removes the line from the file.
12. File touch list
| File | Change |
|---|---|
code-core/colors.h |
Major. §4 layout: enums, both palettes, per-theme backgrounds, shared constants, luminance helper. |
code-core/Settings.h / Settings.cpp |
New. Settings file map, typed accessors, QuerySystemPrefersDark, ResolveActiveTheme. |
code-core/UserInterface.h |
UIColors moves out; includes colors.h. |
code-core/UserInterface.cpp |
§5 literal migration; luminance-keyed data-tree text + canvas cursor/caret colours. |
code-core/RenderCompositor-DirectX12.cpp |
Clear + baked-clear call sites → g_activeBackgrounds; RecreateWindowRTTs factored; theme-apply step in RestartRenderThreads. |
code-core/RenderScene3D-DirectX12.cpp |
Sky root constants from g_activeBackgrounds. |
code-core/RenderPage2D-DirectX12.cpp |
Clear colour + backgroundIsDark fill. |
code-core/RenderPage2D.h |
padding0 → backgroundIsDark. |
code-core/Shader2D_LineVertex.hlsl, Shader2D_CurveVertex.hlsl, Shader2D_TextVertex.hlsl |
cbuffer field rename + §7 remap. |
code-core/PrinterController.cpp |
Explicit backgroundIsDark = 0 + Decision-10 comment. |
code-core/Main.cpp |
Settings load in wWinMain; WM_SETTINGCHANGE; DWM attribute at window creation; dwmapi.lib. |
code-core/ApplicationTab.cpp (once it exists) |
§10 Settings view rows + kThemeSettingUIAction handling. |
code-core/Vishwakarma.vcxproj |
Add Settings.h/.cpp. |
13. Open questions and changeable defaults
- Exact dark palette values (§4 tables) — tune on a real monitor during Phase 1; the field list is the contract, the numbers are not.
- Page2D dark background:
#212830(AutoCAD-familiar) vs neutral#1E1E1E— pick once during Phase 2. - Scene3D override = flat sky: acceptable, or derive a subtle auto-gradient (e.g. horizon = override lightened 8%)? Flat is the MVP default.
- Remap breadth: exact
0xFF000000/0xFFFFFFFFswap only (current decision) vs a near-black/near-white luminance band. Exact-match is deterministic and explainable; widen only if imported DXF files show e.g.#0A0A0Aentities vanishing in practice. - Saffron hover on dark:
actionGroupHoverBackgroundstays brand-saffron in both themes; if it reads too loud on dark, a muted saffron becomes a dark-palette-only tweak. - Telemetry: record theme mode in
ImprovementData(one enum per session) to learn actual dark-mode adoption — cheap, but only if wanted. Settings.txtname:Settings.txtvsSettings.ini— content format is identical either way; decide at first implementation.