# PanoPainter Modernization Roadmap

Status: live
Last updated: 2026-06-17

This roadmap is now architecture-first.
The active execution queue lives in `docs/modernization/tasks.md`.
Completed and superseded task history moved to
`docs/modernization/tasks-done.md`.
The debt log remains `docs/modernization/debt.md`.

## Objective

Turn PanoPainter into a thin composition-root application over separable C++23
components while preserving current behavior.

The target end state is not "more planners around the same legacy shell".
The next phase is measured by ownership transfer in the live app, not by
planner count, CLI breadth, or test count.
The target end state is:

- real component ownership
- real platform boundaries
- real renderer boundaries
- a thin `panopainter_app`
- legacy containment targets either deleted or reduced to trivial,
  debt-tracked adapters

## What This Roadmap Covers

- app architecture
- component boundaries
- platform boundaries
- renderer/app ownership boundaries
- the order of work needed to finish the split

It does not try to be the full build, test, or automation reference.
Those details live in the other modernization docs when needed.

## What Does Not Count As Top-Priority Progress

These can still be useful, but they are not first-order modernization work
while the app shell still mostly looks like the old codebase:

- planner-only extraction that leaves the same live owner in place
- new CLI surface without corresponding live app ownership reduction
- test-only or automation-only expansion that does not unblock code movement
- backend lab scaffolds
- debt-log churn without a target or ownership change

## Reality Check

The codebase is meaningfully farther along than the old flat app, but it is not
close to the final architecture yet.
Historical percentage claims such as the earlier 67% score should not be used
as a proxy for architectural completion. The live app still mostly runs through
the same large shell and hotspot files.

What is already real:

- `pp_foundation`
- `pp_assets`
- `pp_paint`
- `pp_document`
- `pp_renderer_api`
- `pp_renderer_gl`
- `pp_paint_renderer`
- `pp_ui_core`
- `pp_platform_api`
- `pp_app_core`

Latest slice:
- `src/platform_android/android_platform_services.*` now owns the concrete
  Android `PlatformServices` implementation and `create_platform_services()`
  instead of leaving the live Android execution surface inside
  `platform_legacy`.
- `android/src/cpp/main.cpp` now binds that owned Android `PlatformServices`
  instance into `App` directly at the Android entrypoint.
- The touched Android clipboard, keyboard visibility, JNI thread
  attach/detach, async render-context, and file-picker execution now route
  through `src/platform_android/android_platform_services.*` instead of the
  cross-platform fallback adapter.
- `src/platform_legacy/legacy_platform_services.*` no longer carries the
  Android bridge/configuration surface or the touched Android method branches,
  so the retained fallback adapter is narrower again and now focused on the
  Linux/Web path plus generic fallback policy.
- `src/platform_apple/apple_platform_services.*` now owns the concrete Apple
  `PlatformServices` implementation plus the `create_apple_platform_services()`
  factory instead of leaving the live Apple execution surface inside
  `platform_legacy`.
- `PanoPainter-OSX/main.cpp` and `PanoPainter/GameViewController.m` now bind
  owned Apple `PlatformServices` instances into `App` directly at the Apple
  entrypoints.
- The touched Apple clipboard, keyboard visibility, render-context,
  document-picker, display/share, save-ui-state, app-close, SonarPen,
  recording cleanup, exported-image publish, and prepared-file execution now
  route through `src/platform_apple/apple_platform_services.*` instead of the
  cross-platform fallback adapter.
- `src/platform_legacy/legacy_platform_services.*` no longer carries the Apple
  document-service provider/configuration surface or the touched Apple method
  branches, so the retained fallback adapter is narrower and now focused on the
  Android/Linux/Web path.
- `src/platform_legacy/legacy_platform_services.*` now takes an explicit
  `WebPlatformServices*` dependency through `create_platform_services(...)`
  instead of routing WebGL publish/flush/default-canvas/save-prepared-file
  behavior through retained `try_*legacy_web*` fallback helpers.
- `webgl/src/main.cpp` now creates the owned Web service first and threads it
  directly into its owned legacy `PlatformServices` instance.
- `src/platform_legacy/legacy_platform_state.*` is down to the retained Web
  service factory only; the old `active_legacy_web_platform_services()` and
  `try_*legacy_web*` dispatch layer are gone.
- `src/platform_legacy/legacy_platform_services.*` no longer exposes the dead
  `pp::platform::legacy::platform_services()` singleton accessor.
- Linux, WebGL, and Android were already on owned
  `create_platform_services(...)` instances, so removing that legacy singleton
  surface does not change the live entrypoint ownership path.
- `src/platform_windows/windows_runtime_state.*` now owns the Win32 bound-app,
  bound-runtime shutdown, and bound-tablet bindings beside the retained owned
  `App`, `AppRuntime*`, and `WacomTablet` objects instead of leaving that
  binding surface in `windows_runtime_shell.cpp`.
- `src/platform_windows/windows_runtime_state.*` now also owns the Win32
  owned-app creation plus app/runtime binding handoff, so
  `windows_runtime_shell.cpp` is down to a thinner startup dispatcher over the
  retained runtime-state helper.
- `src/platform_windows/windows_runtime_state.*` now also owns the bound
  Win32 app/runtime shutdown order, so `windows_lifecycle_shell.cpp` is down
  to close-message dispatch instead of manually stopping threads and
  terminating the bound app itself.
- `src/platform_windows/windows_lifecycle_state.*` now also owns the Win32
  lifecycle close/VR control handoff, so `windows_lifecycle_shell.cpp` is
  down to a thinner message adapter over the retained lifecycle state helper.
- `src/platform_windows/windows_lifecycle_state.*` now also owns the Win32
  close-message shutdown/quit handoff, so `windows_lifecycle_shell.cpp` is
  down to a one-line adapter over the retained lifecycle state helper.
- `src/platform_windows/windows_runtime_shell.h` is now a thinner runtime
  entrypoint header that picks up the retained binding surface from
  `windows_runtime_state.h` instead of declaring a second shell-owned binding
  API.
- `src/platform_windows/windows_lifecycle_shell.cpp` now formats the FPS title
  with a stack buffer at the callsite instead of routing through a retained
  lifecycle-global scratch array.
- `src/platform_windows/windows_lifecycle_state.*` is down to the retained
  Win32 running flag only; the redundant global FPS-title buffer is gone.
- `src/platform_windows/windows_runtime_session.*` now owns the bound-session
  Win32 runtime loop/startup/shutdown body that had still been sitting inside
  `windows_runtime_flow.cpp`.
- `src/platform_windows/windows_runtime_flow.cpp` is now a thinner handoff
  layer over that Windows session helper.
- `src/platform_windows/windows_runtime_shell.h` no longer exposes the removed
  runtime-session entrypoint or drags bootstrap/splash declarations through the
  broader shell header surface.
- `src/platform_windows/windows_runtime_shell.cpp` now hands runtime execution
  directly to `run_bound_main_window_runtime(...)` instead of keeping a second
  wrapper around that seam.
- `src/main.cpp` now includes `windows_bootstrap_helpers.h` directly for
  `run_winmain_entry(...)` instead of relying on a transitive declaration
  through the Windows runtime shell header.
- `src/platform_windows/windows_runtime_flow.*` now owns the Win32 bound-app
  startup/message-loop/shutdown orchestration that used to live in
  `windows_runtime_shell.cpp`.
- `src/platform_windows/windows_runtime_shell.cpp` is thinner again and now
  delegates the main runtime flow through that Windows-owned helper.
- `src/platform_windows/windows_runtime_shell.cpp` and
  `src/platform_windows/windows_lifecycle_shell.cpp` now drive thread
  start/stop through the explicit Windows runtime binding instead of routing
  that control through `App::runtime()`.
- `src/platform_windows/windows_platform_services.cpp` no longer brokers the
  Win32 main-thread queue through `bound_app()->runtime()`; it now uses an
  explicit Windows runtime binding.
- `src/platform_windows/windows_runtime_state.*` now carries the active
  `AppRuntime*` binding beside the retained Windows-owned runtime objects.
- `src/platform_windows/windows_runtime_state.*` now owns the retained Win32
  runtime object lifetime (`App`, `WacomTablet`) instead of leaving that
  storage inside `windows_runtime_shell.cpp`.
- `src/platform_windows/windows_runtime_shell.cpp` now orchestrates startup and
  shutdown over the Windows runtime-state helper instead of directly owning the
  retained runtime pocket.
- `src/platform_windows/windows_platform_services.cpp` now owns the retained
  Win32 VR shell state directly behind `platform_vr_state()`.
- `src/platform_windows/windows_window_shell.cpp` no longer exposes or owns a
  separate `retained_vr_shell_state()` pocket.
- `src/platform_windows/windows_lifecycle_state.*` now owns the retained
  Win32 lifecycle running flag and FPS-title scratch buffer instead of leaving
  them inside `windows_lifecycle_shell.cpp`.
- `src/platform_windows/windows_lifecycle_shell.cpp` now keeps lifecycle/VR
  control behavior without also owning the retained lifecycle state pocket.
- `src/platform_windows/windows_async_render_context.*` now owns the retained
  Win32 async OpenGL context lock/swap state instead of leaving it inside
  `windows_platform_services.cpp`.
- `src/platform_windows/windows_platform_services.cpp` now keeps the VR/runtime
  adapter surface without also owning the async GL context pocket.
- `src/platform_windows/windows_main_window_session.*` now owns the retained
  Win32 main-window session fields (`HWND`, title buffer, sandbox flag)
  instead of leaving them inside `windows_runtime_shell.cpp`.
- `src/platform_windows/windows_runtime_shell.cpp` now keeps Windows runtime
  ownership focused on `App` and tablet lifetime rather than also owning
  main-window session metadata.
- `src/platform_legacy/legacy_platform_services.*` now owns Android storage
  paths through explicit `create_platform_services(...)` configuration instead
  of reading them from a shared legacy singleton.
- `android/src/cpp/main.cpp` now seeds Android storage paths into its owned
  legacy `PlatformServices` instance.
- `src/platform_legacy/legacy_platform_state.*` no longer carries the Android
  storage-path singleton API.
- `src/platform_legacy/legacy_platform_state.*` no longer keeps its own
  retained Web platform-service binding; the legacy Web helper path now uses
  the shared `platform_api` injection surface instead.
- `src/platform_legacy/legacy_platform_state.*` no longer carries any retained
  GLFW window state; the leftover `set_legacy_glfw_window*` surface is gone.
- `linux/src/main.cpp` and `webgl/src/main.cpp` no longer seed legacy GLFW
  retained state at startup.
- `src/platform_windows/windows_window_shell.cpp` no longer keeps any retained
  mouse-position pocket for button events; it now reads client coordinates
  directly from the Win32 messages that already carry them.
- `src/platform_legacy/legacy_platform_services.*` now accepts an injected
  `LegacyGlfwPlatformShell` for Linux/WebGL `acquire_render_context`,
  `present_render_context`, and app-close callbacks.
- `linux/src/main.cpp` and `webgl/src/main.cpp` now provide that GLFW shell
  directly from the entrypoint-owned window state instead of relying on legacy
  free helpers for those operations.
- `src/platform_legacy/legacy_platform_state.*` no longer exports the old GLFW
  `acquire/present/request_close` helper trio.
- `src/platform_windows/windows_platform_services.cpp` now owns the Win32
  virtual-key map and `initialize_retained_input_state()` path, so
  `windows_window_shell.cpp` no longer carries that retained input setup.
- `src/platform_windows/windows_window_shell.*` no longer carries the dead
  retained raw key-state array or its accessor; Win32 key synchronization now
  relies only on the virtual-key map plus `App` key state.
- `linux/src/main.cpp` now binds and clears the FPS-title callback directly in
  the Linux entrypoint-owned shell; `App::set_platform_services()` no longer
  installs Linux-specific GLFW title behavior.
- `src/platform_windows/windows_window_shell.*` now keeps the Win32 virtual-key
  map outside the retained input-state struct, shrinking that shared pocket.
- `src/platform_windows/windows_runtime_shell.cpp` no longer keeps a second
  retained `App*` copy alongside `App::I`; the runtime shell now reads the
  bound app directly from the singleton composition edge.
- `src/platform_windows/windows_window_shell.*` and
  `src/platform_windows/windows_platform_services.cpp` now keep Win32 VR state
  outside the generic retained input-state bundle.
- `src/platform_legacy/legacy_platform_state.*` no longer carries the dead
  generic storage-path singleton for the current runtime matrix.
- `src/platform_legacy/legacy_platform_services.cpp` now drops the dead generic
  storage-path fallback read; only the platform-specific storage-path branches
  remain live.
- `src/legacy_app_startup_services.cpp` no longer seeds the dead generic
  storage-path fallback state on non-Android startup.
- `src/platform_legacy/legacy_platform_services.cpp` now reads Android storage
  paths from Android-owned retained state instead of the shared legacy
  storage-path singleton.
- `src/legacy_app_startup_services.cpp` and `android/src/cpp/main.cpp` now
  update Android-owned retained storage paths instead of the shared legacy
  storage-path singleton.
- `src/app_events.cpp`, `linux/src/main.cpp`, and
  `src/platform_legacy/legacy_platform_state.*` now route the Linux FPS-title
  callback through a narrow legacy GLFW title helper instead of reaching into
  retained GLFW window state from `App::set_platform_services(...)`.
- `src/platform_legacy/legacy_platform_state.h` no longer exports the raw
  retained GLFW window-state accessor; callers now go through narrow GLFW
  helpers only.
- `src/platform_apple/apple_platform_state.cpp` no longer reads Apple storage
  paths from `pp::platform::legacy::active_legacy_storage_paths()`;
  Apple-owned retained state now carries that path bundle.
- `PanoPainter-OSX/main.cpp` and `PanoPainter/GameViewController.m` now seed
  Apple-owned storage paths directly instead of writing them through the shared
  legacy storage-path singleton.
- `src/platform_legacy/legacy_platform_state.*` now exposes an ownable Web
  platform-services entrypoint plus an explicit binding hook instead of only a
  retained fallback object.
- `webgl/src/main.cpp` now owns the Web platform-services implementation and
  seeds that owned instance into legacy platform state during startup.
- `src/platform_windows/windows_runtime_shell.*` no longer keeps `HINSTANCE`
  in the retained main-window session state; the startup/shutdown path now
  threads the module handle explicitly.
- `webgl/src/main.cpp` now owns a TU-local legacy `PlatformServices`
  instance and binds that owned service into `App` during `StartApp()`.
- `android/src/cpp/main.cpp` now owns a function-lifetime legacy
  `PlatformServices` instance in `android_main()` and binds that owned service
  into `App` instead of binding the process-global fallback directly.
- `src/platform_legacy/legacy_platform_services.*` now exposes an ownable
  `create_platform_services()` entrypoint for explicit per-entrypoint
  ownership.
- `linux/src/main.cpp` now owns a local legacy `PlatformServices` instance and
  binds it into `App` explicitly instead of binding the process-global legacy
  accessor directly.
- `src/app_events.cpp` no longer silently falls back to
  `pp::platform::legacy::platform_services()` when `App` has no bound platform
  services; the live app path now expects explicit platform-service ownership.
- `linux/src/main.cpp`, `webgl/src/main.cpp`, and `android/src/cpp/main.cpp`
  now bind owned `create_platform_services(...)` instances at app creation
  instead of relying on a hidden fallback in `App` event/platform dispatch.
- `src/platform_windows/windows_runtime_shell.cpp` now owns the Windows tablet
  object directly; the composition edge no longer binds `&WacomTablet::I` into
  the Windows runtime path.
- `src/platform_legacy/legacy_platform_state.*` now exposes narrow Web helper
  functions for the touched publish/flush/default-canvas/prepared-file paths,
  so less of the Web fallback behavior lives inline in the legacy platform
  singleton implementation.
- `src/platform_windows/windows_runtime_shell.cpp` no longer keeps a separate
  retained `AppRuntime*` binding; the touched Windows shell and platform
  helpers now derive runtime ownership directly from the owned `App`.
- `src/platform_windows/windows_runtime_shell.cpp` now explicitly owns the
  Windows `App` lifetime through a retained `std::unique_ptr<App>` instead of
  raw `new`/`delete` plus shutdown-side manual cleanup in the lifecycle shell.
- `src/platform_windows/windows_lifecycle_shell.cpp` now releases the bound
  Windows app through `release_bound_app()` after runtime shutdown instead of
  deleting it directly through the global shutdown path.
- `src/platform_windows/windows_window_shell.cpp` now routes the touched
  key-map and VR-state reads through narrow helpers instead of keeping the
  broader retained-state bundle live across the main window-proc body.
- `src/platform_legacy/legacy_platform_state.*` no longer exposes the mutable
  retained GLFW hook bundle; Linux/Web fallback render-context/present/close
  calls now go through narrow GLFW helper functions instead of an exported hook
  struct.
- `scripts/automation/quiet-validate.ps1` is now the bundled checkpoint path
  for Windows build/test plus optional platform and Apple remote validation,
  with one compact JSON summary under `out/logs/quiet-validation`.
- `scripts/automation/platform-build.ps1` quiet mode now writes per-preset
  configure/build logs and compact JSON output so Android/headless sweeps can
  plug into the bundled quiet wrapper without flooding the console.
- `scripts/automation/apple-remote-build.ps1` quiet mode now writes the local
  SSH session log, reports the remote `platform-build.sh` log path, and emits
  compact JSON output so the bundled quiet wrapper can include the Apple gate
  in the same checkpoint run.

What is still carrying too much live ownership:

- `pp_panopainter_ui`: 34 files, about 9102 lines
- `panopainter_app`: 29 files, about 8817 lines
- `pp_legacy_paint_document`: 7 files, about 5709 lines
- `pp_legacy_app`: 20 files, about 4368 lines
- `pp_legacy_ui_core`: 20 files, about 3770 lines

Current hotspot files:

- `src/canvas.cpp`: 17 lines
- `src/app_layout.cpp`: 109 lines
- `src/canvas_modes.cpp`: 1 line
- `src/node.cpp`: 12 lines
- `src/main.cpp`: 10 lines
- `src/node_panel_brush.cpp`: 2 lines
- `src/node_stroke_preview.cpp`: 76 lines
- `src/node_canvas.cpp`: 69 lines
- `src/app.cpp`: 94 lines
- `src/app_dialogs.cpp`: 95 lines

Latest slice:

- The remaining Windows entry/exit singleton write no longer lives at the
  `run_main_application(...)` and `handle_window_close_message(...)` callsites;
  `src/platform_windows/windows_runtime_shell.cpp` now centralizes that legacy
  `App::I` side effect inside `bind_app(...)`, leaving the touched runtime and
  lifecycle shell as explicit binder users instead of direct singleton writers.
- The touched `src/platform_windows/windows_platform_services.cpp` fan-out no
  longer reaches the broader retained window bundle directly for main-window,
  sandbox, and VR/session reads; the touched window/VR queries now route
  through narrow runtime-shell accessors instead.
- `src/platform_windows/windows_bootstrap_helpers.cpp` no longer uses
  `Canvas::I` for crash-recovery saves; the BugTrap pre-error handler now uses
  the app-owned `NodeCanvas` document (`app.canvas->m_canvas`) and the new
  runtime-shell window/sandbox accessors instead of direct singleton or
  retained-state reads in the touched recovery path.
- The retained Apple document bridge/state pocket no longer lives in
  `src/platform_legacy/legacy_platform_state.*`; it now lives in the Apple-owned
  `src/platform_apple/apple_platform_state.cpp` plus
  `src/platform_apple/apple_platform_services.*`, and the macOS/iOS entrypoints
  now seed that state through `pp::platform::apple::set_legacy_apple_state(...)`
  instead of the legacy namespace.
- `src/platform_legacy/legacy_platform_services.cpp` now consumes the
  Apple-owned retained provider through `pp::platform::apple::...` call-throughs
  instead of constructing or caching Apple retained bridge state inside the
  legacy platform layer.
- The Win32 stylus and pointer-input path no longer reaches
  `WacomTablet::I` directly inside
  `src/platform_windows/windows_window_shell.cpp` or
  `src/platform_windows/windows_stylus_input.cpp`; the live Windows runtime
  shell now binds the active tablet explicitly through
  `src/platform_windows/windows_runtime_shell.*`, leaving
  `WacomTablet::I` only at the entry composition edge where the runtime shell
  binds the active tablet instance.
- The remaining dense Windows bootstrap singleton pocket moved off
  `App::I`: `setup_exception_handler(...)`, `initialize_main_window_startup_state(...)`,
  and `_pre_call_callback(...)` now use explicit app/bound-runtime state
  instead of reading the app singleton directly in
  `src/platform_windows/windows_bootstrap_helpers.*`, and the app-side
  platform dispatch helpers in `src/app_events.cpp` now also use the
  instance they are invoked on instead of a global `App::I` fallback.
- The retained Web fallback service object and the Apple storage-path
  preparation helper now also live in
  `src/platform_legacy/legacy_platform_state.*` instead of being built inline
  inside `src/platform_legacy/legacy_platform_services.cpp`, which trims
  another process-global fallback/service pocket out of the legacy platform
  shell.
- The Win32 window procedure, stylus state updates, lifecycle shutdown path,
  and VR shell callback wiring no longer reach `App::I` directly; the live
  Windows shell now binds the active `App*` explicitly through
  `src/platform_windows/windows_runtime_shell.*`, leaving `App::I` only at
  the entry/shutdown composition edge in the touched Win32 path.
- The full retained Apple document bridge construction no longer lives inline
  in `src/platform_legacy/legacy_platform_services.cpp`; it now lives behind
  `active_legacy_apple_document_platform_services()` in
  `src/platform_legacy/legacy_platform_state.*`, and that retained service now
  resets when seeded Apple handles change so first-use bridge capture stays in
  sync with the active entrypoint state.
- Win32 main-thread task dispatch no longer reaches `AppRuntime` through
  `App::I` inside `src/platform_windows/windows_platform_services.cpp`;
  `src/platform_windows/windows_runtime_shell.*` now binds the active runtime
  explicitly and clears that binding on shutdown, leaving the Windows queue
  helper as a thinner runtime forwarder.
- `App` no longer owns `and_app` or `and_engine`; the retained Android
  entrypoint now seeds only the explicit legacy platform storage snapshot
  needed by touched platform services instead of storing Android-native
  handles on the app singleton.
- `active_legacy_storage_paths()` no longer snapshots `App::I` lazily inside
  `src/platform_legacy/legacy_platform_state.*`; storage roots are now seeded
  explicitly from app startup plus the iOS, macOS, and Android entrypoints
  through `set_legacy_storage_paths(...)`.
- `pp_platform_api` no longer compiles
  `src/platform_linux/linux_platform_services.*`; Linux concrete platform code
  now lives in `pp_platform_linux`, which `pp_legacy_app` and
  `pp_platform_api_tests` link where needed.
- Win32 main-thread queued task ownership now lives in `AppRuntime` instead of
  `src/platform_windows/windows_platform_services.cpp`, which removes another
  runtime queue from retained platform-local static state and leaves the
  Windows shell as a thin forwarder.
- The `platform_legacy`-mirrored Apple and GLFW handle cluster no longer lives
  on `App`; retained Apple/GLFW platform state is now seeded explicitly from
  the iOS, macOS, Linux, and WebGL entrypoints through
  `src/platform_legacy/legacy_platform_state.*`.
- `pp_platform_api` no longer compiles
  `src/platform_apple/apple_platform_services.*`; Apple concrete platform
  code now lives in the new `pp_platform_apple` target, and
  `panopainter_app` plus `pp_platform_api_tests` link that concrete target
  where needed.
- Retained GLFW window hooks/state and retained Apple UI/app handle snapshots
  now live in `src/platform_legacy/legacy_platform_state.*` instead of staying
  inline in `src/platform_legacy/legacy_platform_services.cpp`, which trims
  another process-global platform-state pocket out of the legacy platform
  shell and removes more direct `App::I` reads from touched platform paths.
- Windows VR session snapshot ownership now lives in
  `src/platform_windows/windows_vr_shell.h` and
  `src/platform_windows/windows_platform_services.*` instead of on `App`,
  with app-side reads now routed through `App::vr_session_snapshot()`.
- The live Windows entry shell now routes through
  `run_main_application(...)` in
  `src/platform_windows/windows_runtime_shell.*`, leaving `src/main.cpp` as a
  minimal entry wrapper around `main(...)` and `WinMain(...)`.
- Retained legacy storage-path state now lives in
  `src/platform_legacy/legacy_platform_state.*` instead of staying inline in
  `src/platform_legacy/legacy_platform_services.cpp`, which trims another
  process-global platform-state pocket out of the legacy platform shell.
- The remaining `NodeStrokePreview` clone-init, stroke-frame planning,
  mix-pass adapter wiring, sample-pass adapter wiring, and immediate-draw
  request construction now route through
  `src/legacy_node_stroke_preview_runtime_services.*`,
  `src/legacy_node_stroke_preview_draw_services.*`, and
  `src/legacy_node_stroke_preview_sample_services.*`, leaving
  `src/node_stroke_preview.cpp` as a thinner live adapter.

- `NodeCanvas::handle_event()` now routes through
  `handle_legacy_node_canvas_event(...)` in
  `src/legacy_canvas_tool_services.*`, leaving `src/node_canvas.cpp` as a much
  thinner controller shell.
- `App::open_document()` now routes through
  `src/legacy_document_open_services.cpp`, which moved the document route
  classification and unsaved-project gating out of `src/app.cpp` and into the
  retained document-open helper.
- `App::dialog_layer_rename()` now routes through
  `open_legacy_document_layer_rename_dialog(...)` in
  `src/legacy_document_layer_services.*`, which moved the remaining
  overlay-open/wire/close workflow out of `src/app_dialogs.cpp`.
- The remaining low-level `NodeStrokePreview` viewport/query and texture-slot
  plumbing now lives in
  `src/legacy_node_stroke_preview_runtime_services.*` instead of staying
  inline in `src/node_stroke_preview.cpp`.
- `NodePanelBrushPreset` global panel registration now lives in
  `src/legacy_brush_preset_list_services.*` instead of staying on the live
  node type as a static registry field.
- The remaining generic `Node` geometry/state pocket for `SetSize(...)`,
  `SetMinSize(...)`, `SetMaxSize(...)`, and `SetPosition(const glm::vec2)` now
  lives in `src/legacy_ui_node_style.*` instead of staying inline in
  `src/node.cpp`.
- `Node::app_redraw()` and `Node::watch(...)` now live in
  `src/legacy_ui_node_execution.cpp` instead of staying inline in `src/node.cpp`.
- `NodeStrokePreview::draw_stroke_immediate()` now routes through
  `execute_legacy_node_stroke_preview_immediate_draw(...)` in
  `src/legacy_node_stroke_preview_runtime_services.*`, which moves render-target
  validation, viewport/clear-color save-restore, and immediate-runtime request
  assembly out of the live node file.
- The remaining `NodePanelBrush` and `NodePanelBrushPreset` member bodies now
  live in the existing retained helper layers
  (`src/legacy_brush_panel_item_ui.*`,
  `src/legacy_brush_panel_ui.*`,
  `src/legacy_brush_panel_services.*`,
  `src/legacy_brush_preset_panel_ui.*`,
  `src/legacy_brush_preset_list_services.*`, and
  `src/legacy_brush_preset_services.*`), leaving
  `src/node_panel_brush.cpp` as a thin translation unit.
- `Node::load_internal(...)` now routes through
  `load_legacy_ui_node(...)` in `src/legacy_ui_node_loader.*`, which moves the
  init/attribute-parse/create/child-load/loaded shell out of `src/node.cpp`.
- The remaining Win32 shell wrappers for close, async lock/swap, stylus/FPS
  updates, VR start/stop, window-state save, and the window-handle accessor now
  live in `src/platform_windows/windows_platform_services.cpp` instead of
  `src/main.cpp`, leaving `main.cpp` as a thinner entry/runtime dispatcher.
- The entire `CanvasModeGrid` implementation plus `ActionModeGrid` undo/redo
  glue now live in `src/legacy_canvas_mode_helpers.cpp` instead of
  `src/canvas_modes.cpp`, leaving `src/canvas_modes.cpp` as a minimal shell.
- `App::request_close()`, the RenderDoc frame wrappers, and the render/UI
  thread entrypoint wrappers now route through
  `src/legacy_app_runtime_shell_services.*` instead of staying inline in
  `src/app.cpp`, leaving `app.cpp` as a thinner retained app shell.
- `App::show_progress()`, `App::message_box()`, and `App::input_box()` now
  route through `src/legacy_app_dialog_services.*` instead of building dialog
  plans and factories inline in `src/app_dialogs.cpp`.
- The remaining generic `Node` event/capture/resize shell plus the
  width/height/padding/margin/flex/visibility/geometry wrappers now live in
  `src/legacy_ui_node_execution.cpp` and `src/legacy_ui_node_style.*` instead
  of staying inline in `src/node.cpp`, leaving `node.cpp` as a near-trivial
  attribute/load shell.

Current architecture mismatches that must be treated as real blockers:

- `pp_platform_api` no longer compiles Apple implementation files, but it
  still owns too much concrete platform implementation instead of only
  platform-neutral policy and interface code.
- `src/platform_apple/apple_platform_services.cpp` no longer reaches `App::I`
  directly, and Linux FPS title reporting now uses an injected callback, but
  retained Apple bridging in `platform_legacy` and other platform/app coupling
  remain, even though iOS keyboard visibility and prepared-file save handoff
  now also route through explicit Apple bridge callbacks and Apple render-
  context hooks plus iOS main-render-target binding now route through the same
  bridge style, as do Apple crash-test, app-close, and iOS SonarPen hooks,
  while Linux/Web GLFW render-context acquire/present and Linux app-close now
  route through retained local GLFW callback hooks, and retained Apple ObjC
  handles plus storage paths now sit behind one local `platform_legacy`
  helper instead of being re-read through `App::I` in each touched path, with
  the retained GLFW window hooks, Apple handle snapshots, and fallback
  storage-path return now also using local retained-state helpers instead of
  direct method-body reads, while Windows VR session snapshot state now also
  lives behind platform-owned helpers instead of on `App`, the
  `platform_legacy`-mirrored Apple/GLFW handle cluster is now seeded
  explicitly from platform entrypoints instead of being copied out of `App`,
  and retained storage roots are now also seeded explicitly instead of being
  lazily copied from `App::I` inside `active_legacy_storage_paths()`, while the
  retained Apple document bridge now also lives in `legacy_platform_state.*`
  instead of being built inline in `legacy_platform_services.cpp`.
- `src/platform_legacy/legacy_platform_services.*` is still part of the live
  app shell.
- `pp_panopainter_ui` still depends on `pp_legacy_app`.
- `Canvas`, `NodeCanvas`, and `NodeStrokePreview` still own too much live
  OpenGL execution around the renderer boundary, even though `NodeCanvas`
  display resolve, cache-to-screen composite, post-draw mask/grid/current-mode
  sequencing, per-layer/per-plane retained draw execution, and shared
  checkerboard background setup now route through retained draw-merge helpers,
  with the cache-to-screen checkerboard-plane callback setup also reduced and
  the merged-path checkerboard background-plane callback plus per-plane
  merged-texture draw callback plus non-`draw_merged` per-frame layer draw
  callback plus the smoothing-mask face shader/draw pass plus heightmap,
  current-mode, and grid-mode callback setup now routed through the same
  retained helper family, while post-draw callback assembly and the remaining
  per-layer render-path orchestration now also route through retained
  draw-merge helpers even though the broader node draw loop is still inline,
  with the non-`draw_merged` outer layer/plane traversal now also routing
  through `execute_legacy_canvas_draw_layer_traversal(...)`, while the heavier
  per-layer GL setup now also routes through
  `make_legacy_canvas_draw_merge_layer_path_gl_execution(...)` even though the
  remaining draw lambdas and broader node draw loop still live in
  `src/node_canvas.cpp`, where the post-draw/display-resolve tail now also
  routes through `execute_node_canvas_draw_merge_tail(...)`, while the
  unmerged-path onion-range planning, plane filtering, per-layer visit
  handling, and per-visit layer-path execution now also route through
  `execute_legacy_canvas_draw_unmerged_node_canvas_shell(...)`, while the
  broader unmerged cache/viewport/background/composite pass setup now also
  routes through
  `execute_legacy_canvas_draw_unmerged_node_canvas_pass(...)`, while
  `NodeCanvas::draw()` setup, merged-pass shell, and unmerged-pass shell now
  also route through `prepare_legacy_node_canvas_draw_setup(...)`,
  `execute_legacy_canvas_draw_node_canvas_shell(...)`,
  `execute_legacy_canvas_draw_merged_pass(...)`, and
  `execute_legacy_canvas_draw_node_canvas_unmerged_pass(...)`, which
  materially shortens the live `NodeCanvas::draw()` body even though the file
  itself is still large.
- `app_layout.cpp` and `app_dialogs.cpp` are still mixed shell/controller files
  rather than thin composition/binding surfaces, even though tools-menu binding
  plus nested panels/options submenu wiring now live in
  `src/app_layout_tools_menu.cpp` and `App::init_menu_tools()` is now a thin
  call-through, while file-menu binding plus the export submenu wiring now also
  live in `src/app_layout_file_menu.cpp` and `App::init_menu_file()` is now a
  thin call-through, while about-menu and layer-menu wiring now also live in
  `src/app_layout_about_layer_menu.cpp` and `App::init_menu_about()` plus
  `App::init_menu_layer()` are now thin call-throughs, while sidebar panel
  binding and popup wiring now also live in `src/app_layout_sidebar.cpp` and
  `App::init_sidebar()` is now a thin call-through, while main-toolbar binding
  now also lives in `src/app_layout_main_toolbar.cpp` and
  `App::init_toolbar_main()` is now a thin call-through, while edit-menu
  binding now also lives in `src/app_layout_edit_menu.cpp` and
  `App::init_menu_edit()` is now a thin call-through, while UI-direction and
  persisted floating/docked panel-state ownership now also live in
  `src/app_layout_ui_state.cpp`, while draw-toolbar binding now also lives in
  `src/app_layout_draw_toolbar.cpp`, while brush-refresh now also lives in
  `src/app_layout_brush.cpp`, while layout bootstrap plus reload/load
  continuation wiring now also lives in `src/app_layout_bootstrap.cpp`, and
  `src/app_layout.cpp` is now mostly thin call-through entrypoints, while the
  informational overlay opener family now also lives in
  `src/app_dialogs_info_openers.cpp` and the corresponding `App::dialog_*`
  entrypoints are thinner, while the export/video/PPBR dialog family now also
  lives in
  `src/app_dialogs_export.cpp` and those `App::dialog_*` entrypoints are
  thinner too, while new/open/save/browse/resize workflow entrypoints now also
  live in `src/app_dialogs_workflow.cpp`, while the layer-rename dialog open /
  wire / close pocket now lives in `src/legacy_document_layer_services.*`, and
  `src/app_dialogs.cpp` is now a thinner dialog dispatch surface.
- `App`, `Canvas`, `Node`, retained workers, and platform entrypoints still use
  global singleton reach, raw observer pointers, retained static worker
  ownership in several app families, and ad hoc mutex/condition-variable
  ownership, even though most previously detached or raw app-facing worker
  launches now use owned `std::jthread` or service-owned worker queues and
  `AppRuntime` now owns render/UI workers with explicit `std::jthread`
  shutdown semantics while the Windows splash-dialog and HMD renderer workers
  also use owned `std::jthread` lifecycle, `LogRemote` now uses the same
  ownership model, the Windows VR device now has explicit `std::unique_ptr`
  ownership instead of raw global lifetime, and the Windows main-loop/VR
  coordination flags now use `std::atomic` instead of unsynchronized globals,
  while the main Win32 entrypoint now groups window/GL/task/VR state behind a
  retained local state object instead of separate process-wide globals, the
  Win32 async GL/context lock state now lives under
  `src/platform_windows/windows_platform_services.cpp` instead of `main.cpp`
  retained state, the main-thread queued task state now lives under
  `src/platform_windows/windows_platform_services.cpp` instead of staying in
  `src/main.cpp`, the canvas async worker now sits behind a named retained
  local worker-state helper instead of a bare static accessor, the
  prepared-file worker and the canvas async import/export/save/open worker now
  live under `AppRuntime` instead of retained static app-events/canvas
  workers, and the splash-screen dialog loop, HWND ownership, and bitmap setup
  now live in `src/platform_windows/windows_splash.cpp` instead of
  `src/main.cpp`, while Win32 pointer API loading, stylus/ink timer decay,
  Wintab packet reset, and `WM_POINTERUPDATE` pen/touch handling now also live
  in `src/platform_windows/windows_stylus_input.cpp` instead of `src/main.cpp`,
  while the retained Win32 VR/HMD shell now also routes through
  `src/platform_windows/windows_vr_shell.h` instead of staying inline in
  `src/main.cpp`, while RenderDoc startup/frame capture, SHCore DPI bootstrap,
  Win32 error-string conversion, the GL debug pre/post callbacks, and the WMI
  startup probe now also live in
  `src/platform_windows/windows_bootstrap_helpers.cpp` instead of
  `src/main.cpp`, while Win32 lifecycle running-state, close/shutdown
  sequencing, FPS title update/wakeup posting, stylus frame update, window
  preference save, and VR lifecycle wrappers now also live in
  `src/platform_windows/windows_lifecycle_shell.cpp` instead of `src/main.cpp`,
  while the Win32 startup/window/bootstrap path now also lives under
  `src/platform_windows/windows_bootstrap_helpers.*` for runtime-data
  discovery, startup-state initialization, window creation, pixel-format
  setup, GL loader init, runtime-info logging, and core-context upgrade
  sequencing, while BugTrap/SEH recovery setup now also lives in
  `src/platform_windows/windows_bootstrap_helpers.cpp` instead of
  `src/main.cpp`, while the Win32 window procedure and retained message-handling
  shell now also live in `src/platform_windows/windows_window_shell.*`
  instead of `src/main.cpp`, while the `WinMain` argv conversion bridge now
  also lives in `src/platform_windows/windows_bootstrap_helpers.*` instead of
  staying inline in `src/main.cpp`, while retained input-state zeroing and reverse
  key-map initialization now also live in
  `src/platform_windows/windows_window_shell.*` instead of `src/main.cpp`,
  while the remaining interactive Win32 runtime
  pocket for touch registration, render/UI thread startup, GL debug callback
  hookup, Wintab initialization/skip, icon setup, placement restore, optional
  VR start, splash dismissal, message loop, and shutdown cleanup now also
  lives in `src/platform_windows/windows_runtime_shell.*` instead of
  `src/main.cpp`, which materially thins `src/main.cpp` even though broader
  entrypoint/runtime composition still remains there,
  while `App::rec_loop()` now delegates worker-iteration orchestration into
  the retained recording bridge, `App::update_rec_frames()` now delegates
  recording label refresh through that same retained recording path, and the
  UI observer math, repeated UI child traversal, and canvas toolbar refresh
  now live in `src/legacy_app_frame_services.cpp` instead of staying inline in
  `src/app.cpp`, while the larger document/export/save/open/thumbnail
  document-IO cluster now lives in `src/legacy_canvas_document_io_services.cpp`
  and `src/app.cpp` is materially thinner, while `App::clear()`,
  `App::check_license()`, `App::async_start()`, `App::async_redraw()`,
  `App::async_end()`, and `App::async_swap()` now also live in
  `src/legacy_app_runtime_shell_services.cpp` instead of staying inline in
  `src/app.cpp`,
  while the canvas state-management cluster for picking, clear/clear-all, layer
  add/remove/order/lookups, animation frame control, resize, and snapshot
  save/restore now lives in `src/legacy_canvas_state_services.cpp` instead of
  `src/canvas.cpp`, while the larger import/export/save/open/thumbnail
  document-IO cluster now lives in `src/legacy_canvas_document_io_services.cpp`
  and `NodeStrokePreview` render-target setup plus immediate-pass sequencing
  now route through retained preview execution helpers, even though the bridge
  still owns worker-side readback flow and encoder-state label reads, while the
  main live-pass request assembly and framebuffer-copy setup now also route
  through `src/legacy_node_stroke_preview_execution_services.h`, even though
  broader preview-pass orchestration still lives in
  `src/node_stroke_preview.cpp`, while the immediate preview pass-sequencing
  family inside `draw_stroke_immediate()` now also routes through
  `NodeStrokePreview::execute_stroke_draw_immediate_pass_sequence(...)`, while
  the remaining immediate preview pass shell now also routes through
  `execute_legacy_node_stroke_preview_draw_immediate_shell(...)`, which
  materially reduces the live preview-pass body even though broader
  worker/readback flow still remains inline, while the immediate preview
  runtime/orchestration block for stroke setup, prepared-stroke construction,
  pass planning, shader setup, and live render request assembly now also
  routes through `src/legacy_node_stroke_preview_runtime_services.*` instead
  of staying inline in `src/node_stroke_preview.cpp`, while
  the low-level preview GL dispatch and texture-slot binding pocket now also
  routes through `src/legacy_node_stroke_preview_runtime_services.*` instead
  of staying inline in `src/node_stroke_preview.cpp`, while
  `NodeStrokePreview` remaining mix-pass planning and execution now also route
  through `src/legacy_node_stroke_preview_draw_services.*`, which trims the
  last dedicated mix-orchestration pocket from `src/node_stroke_preview.cpp`,
  while
  `NodeCanvas::draw()` unmerged-pass blend-gate, layer-orientation, and
  callback-assembly setup now also route through
  `execute_node_canvas_draw_unmerged_pass(...)`, which trims another coherent
  unmerged draw shell from the live node even though the file itself remains
  large, while `NodeCanvas::draw()` merged-pass callback wiring and pass setup
  now also route through `execute_node_canvas_draw_merged_pass(...)`, which
  trims another coherent merged draw shell from the live node even though the
  broader draw loop still remains inline,
  while `Canvas::draw_objects_direct(...)` and `Canvas::draw_objects(...)` now
  route through `src/legacy_canvas_object_draw_services.*` instead of staying
  inline in `src/canvas.cpp`, which trims another coherent object-draw and
  viewport-state execution family from the live canvas shell, while the
  static canvas plane geometry/orientation data now also lives in
  `src/legacy_canvas_plane_data.cpp` instead of staying inline in
  `src/canvas.cpp`, which trims another retained data-ownership pocket from
  the live canvas shell, while
  `Canvas::stroke_draw_samples(...)`, `Canvas::stroke_commit()`, and the
  larger stroke commit/sample execution family now also route through
  `src/legacy_canvas_stroke_commit_services.*` instead of staying inline in
  `src/canvas.cpp`, which trims another large retained stroke-render and
  viewport-state execution family from the live canvas shell, while the live
  `Canvas::stroke_draw()` orchestration now also routes through
  `src/legacy_canvas_stroke_live_services.cpp` instead of staying inline in
  `src/canvas.cpp`, which materially thins another large retained live
  stroke-render pocket, while `Canvas::layer_merge(...)`,
  `Canvas::flood_fill(...)`, and `Canvas::FloodData::apply()` now also route
  through `src/legacy_canvas_layer_services.cpp` instead of staying inline in
  `src/canvas.cpp`, which trims another coherent retained layer/fill workflow
  pocket, while `Canvas::stroke_end(...)`, `Canvas::stroke_cancel(...)`,
  `Canvas::stroke_draw_mix(...)`, `Canvas::stroke_draw_project(...)`,
  `Canvas::stroke_update(...)`, and `Canvas::stroke_start(...)` now also route
  through `src/legacy_canvas_stroke_runtime_services.*` instead of staying
  inline in `src/canvas.cpp`, which trims another large retained stroke/runtime
  pocket, while the final camera/timelapse member definitions now also live in
  `src/legacy_canvas_camera_services.cpp` instead of staying inline in
  `src/canvas.cpp`, which trims another retained canvas shell pocket, while the
  `CanvasModeTransform` interaction family now also routes through
  `src/legacy_canvas_mode_transform.cpp` instead of staying inline in
  `src/canvas_modes.cpp`, which materially thins another retained canvas-view
  and transform-mode execution pocket, while the `CanvasModePen` and
  `CanvasModeLine` interaction families now also route through
  `src/legacy_canvas_mode_pen_line.cpp` instead of staying inline in
  `src/canvas_modes.cpp`,
  while the `CanvasModeFill` and `CanvasModeFloodFill` interaction families now
  also route through `src/legacy_canvas_mode_fill.cpp` instead of staying
  inline in `src/canvas_modes.cpp`, which materially thins another retained
  fill-mode execution pocket from the broader canvas/render hotspot family,
  while `NodePanelBrush` save/restore/scan/reload/find/get-path ownership now
  routes through `src/legacy_brush_panel_services.*` instead of staying inline
  in `src/node_panel_brush.cpp`, which trims another retained brush-workflow
  pocket from the live UI node even though the broader panel still remains
  large, while `NodePanelBrushPreset` save/restore and package
  import/export/import-ABR routing now also lives in
  `src/legacy_brush_preset_services.*` instead of staying inline in
  `src/node_panel_brush.cpp`, which trims another large preset-workflow pocket
  from the live UI node, while `NodePanelBrushPreset` init/menu wiring, click
  handling, item construction, and added-state update now also route through
  `src/legacy_brush_preset_panel_ui.*` instead of staying inline in
  `src/node_panel_brush.cpp`, which materially thins another retained preset
  panel UI pocket, while the retained `LegacyBrushPresetListServices` block
  now also lives in `src/legacy_brush_preset_list_services.*` instead of
  staying inline in `src/node_panel_brush.cpp`, which trims another retained
  preset-list pocket, while `NodeButtonBrush` clone/init/icon/read/write/draw
  behavior and `NodeBrushPresetItem` clone/init/draw behavior now also live in
  `src/legacy_brush_panel_item_ui.*` instead of staying inline in
  `src/node_panel_brush.cpp`, which trims the remaining brush-item UI pocket
  from the live brush panel file, while `NodePanelBrushPreset` popup-close
  event handling now also lives in
  `src/legacy_brush_preset_panel_ui.*`
  instead of staying inline in `src/node_panel_brush.cpp`, which removes the
  last inline brush-panel popup close handler from the live node. The
  `NodePanelBrushPreset` registration/lifecycle pocket now also routes through
  the preset-list helper registry instead of a node-local static vector,
  which removes the remaining live preset-panel ownership glue from
  `src/node_panel_brush.cpp`, and preset-restore notification visibility now
  also stays with `src/legacy_brush_preset_services.*` instead of the node
  wrapper. The broader preset workflow pocket still remains, while
  `NodeCanvas::handle_event()`
  now also routes
  through `execute_node_canvas_handle_event(...)`, which trims another coherent
  input-routing block from `src/node_canvas.cpp` even though the file is still
  a live canvas/controller shell, while `NodeCanvas` restore/clear context,
  resize handling, camera reset, buffer creation, cursor visibility/update,
  tick, and destroy ownership now also route through
  `src/legacy_node_canvas_state_services.*` instead of staying inline in
  `src/node_canvas.cpp`, which materially thins another retained state/control
  pocket, while shared canvas-mode GL wrappers plus the
  `CanvasModeBasicCamera` and `CanvasModeCamera` input handlers now also route
  through `src/legacy_canvas_mode_helpers.*` instead of staying inline in
  `src/canvas_modes.cpp`, while
  preview stroke preparation, dual-brush setup, and live pass-orchestration
  request assembly now also route through retained preview execution helpers,
  while `NodeStrokePreview` retained lifecycle, worker-thread shell,
  render-to-image path, on-screen handling, and preview texture ownership now
  also route through `src/legacy_node_stroke_preview_runtime_services.cpp`
  instead of staying inline in `src/node_stroke_preview.cpp`, while
  `NodeCanvas::init()` plus the remaining `NodeCanvas::draw()` outer shell now
  also route through `src/legacy_node_canvas_draw_services.*` instead of
  staying inline in `src/node_canvas.cpp`, which materially reduces the live
  node to a thinner controller surface around event routing and state wrappers,
  while `Node::on_event(...)` plus mouse/key capture and release ownership now
  also route through `src/legacy_ui_node_event.*` instead of staying inline in
  `src/node.cpp`, which materially thins the base scene-graph event shell
  without changing its public surface, while `Node` child attach/detach/reorder
  operations now route through named
  local helpers in `src/node.cpp`, and `Node::load_internal(...)` child XML
  loading now also routes through `src/legacy_ui_node_loader.*`, which makes
  the scene-graph mutation and child-instantiation paths easier to reason
  about without yet moving ownership into `pp_ui_core`, while the generic
  per-frame node execution/traversal family for `restore_context`,
  `clear_context`, `update`, `update_internal`, and `tick` now also lives in
  `src/legacy_ui_node_execution.cpp`, while `Node::parse_attributes(...)` now
  also routes through `src/legacy_ui_node_attributes.*` instead of staying
  inline in `src/node.cpp`, while the remaining generic `Node` lifecycle/state
  pocket for no-op lifecycle hooks, add/remove propagation, move construction,
  destruction cleanup, and base clone plumbing now also routes through
  `src/legacy_ui_node_lifecycle.*` instead of staying inline in
  `src/node.cpp`,
  while `Canvas` point-trace/unproject/project/camera push-pop-get-set and
  face-to-shape helpers now also route through
  `src/legacy_canvas_projection_services.*` instead of staying inline in
  `src/canvas.cpp`, while `App::draw(...)`, `App::update(...)`,
  `App::terminate(...)`, `App::update_memory_usage(...)`,
  `App::update_rec_frames(...)`, `App::res_from_index(...)`,
  `App::res_to_index(...)`, `App::res_to_string(...)`, `App::rec_clear(...)`,
  `App::rec_start(...)`, `App::rec_stop(...)`, `App::rec_export(...)`,
  `App::rec_loop(...)`, and `App::render_thread_tick(...)` now also route
  through `src/legacy_app_runtime_shell_services.cpp` instead of staying
  inline in `src/app.cpp`, while `Canvas::draw_merge(...)`, the temporary
  paint/branch orchestration helpers, final-plane composite, timelapse commit,
  create/destroy, clear-context, and camera accessors now also route through
  `src/legacy_canvas_render_shell_services.*` instead of staying inline in
  `src/canvas.cpp`, while `Node::destroy()`, `root()`, `set_manager(...)`,
  `added_to_root()`, `handle_on_screen(...)`, template loading helpers, child
  add/remove/move helpers, and child query helpers now also route through
  `src/legacy_ui_node_tree_services.cpp` instead of staying inline in
  `src/node.cpp`, while the `CanvasModeMaskFree` and `CanvasModeMaskLine`
  interaction families now also route through
  `src/legacy_canvas_mode_mask.cpp` instead of staying inline in
  `src/canvas_modes.cpp`, while the remaining live render/pass orchestration in
  `NodeStrokePreview::draw_stroke_immediate()` now also routes through
  `src/legacy_node_stroke_preview_draw_services.*` instead of staying inline in
  `src/node_stroke_preview.cpp`, and while the generic Yoga
  style/visibility pocket from `Node::SetWidth(...)` through `Node::GetRTL()`
  now also routes through `src/legacy_ui_node_style.*` instead of staying
  inline in `src/node.cpp`, while the preview sample execution pocket for
  sample-point conversion, brush vertex upload, request assembly, and the
  `execute_legacy_canvas_stroke_sample(...)` call now also lives in
  `src/legacy_node_stroke_preview_sample_services.*` instead of staying inline
  in `src/node_stroke_preview.cpp`.
- Modern C++23 usage exists in extracted components, especially `std::span`,
  explicit result/status objects, and a few concepts, but the live app still
  does not consistently express ownership, thread affinity, or renderer
  resources through safe component contracts.

Conclusion:

- the base component extraction is real
- the app shell thinning is not done
- the platform split is not done
- the UI split is not done
- the renderer/app ownership split is not done
- future backend lab work is still premature until those four statements change

## Final Target Architecture

The old roadmap showed a straight chain.
That was too simple.
The real target is a layered DAG with a thin composition root.

```text
pp_foundation
  -> pp_assets
  -> pp_paint
  -> pp_document

pp_foundation
  -> pp_renderer_api
  -> pp_renderer_gl

pp_document + pp_paint + pp_renderer_api
  -> pp_paint_renderer

pp_foundation + pp_document
  -> pp_app_core

pp_foundation
  -> pp_ui_core

pp_platform_api
  -> pp_platform_windows
  -> pp_platform_apple
  -> pp_platform_linux
  -> pp_platform_android
  -> pp_platform_web
  -> pp_platform_vr

pp_app_core + pp_ui_core + pp_paint_renderer + pp_platform_api
  -> pp_panopainter_ui

pp_app_core + pp_panopainter_ui + pp_platform_*
  -> panopainter_app
```

Key ownership rules:

- `pp_platform_api` is interface and policy only. No concrete platform service
  implementation files belong there.
- `pp_platform_*` owns platform SDK, OS handles, platform event loops, and
  concrete service bridges.
- `pp_ui_core` owns generic node/layout/overlay behavior and generic controls.
- `pp_panopainter_ui` owns app-specific panels, dialogs, canvas views, and
  UI-to-app bindings.
- `pp_app_core` owns planner logic, workflow policy, and service contracts. It
  does not own nodes, GL objects, or platform handles.
- `pp_paint_renderer` owns renderer-facing paint/export/preview contracts.
- `panopainter_app` owns composition only. It should stop being a second home
  for document workflow, dialog orchestration, platform state, or renderer
  execution.
- Threading and task dispatch are app runtime services, not incidental static
  queues on `App` or detached workers launched from panels, dialogs, canvas, or
  cloud helpers.
- UI ownership is handle/registry based at component boundaries. Raw `Node*`
  can remain as non-owning implementation detail only when lifetime is proven
  by checked handles or scoped connections.
- Renderer-facing app code uses `pp_renderer_api` resources and command/context
  contracts. `Texture2D`, `RTT`, direct GL dispatch, and render-thread helpers
  must not leak into future-backend-facing UI or document code.

## Workstreams

### 1. Break The Canvas And Preview Hotspots First

This is the highest-value work because it moves the largest concentration of
real app behavior out of the old shell.

Required outcomes:

- `canvas.cpp` loses major document-plus-render ownership
- `node_canvas.cpp` and `node_stroke_preview.cpp` lose major render-pass
  orchestration
- concrete GL execution moves behind renderer-facing services instead of living
  in app/node files

### 2. Thin The App Shell

`app.cpp`, `app_layout.cpp`, and `app_dialogs.cpp` must stop acting as mixed
workflow, UI, and composition files.

Required outcomes:

- `app_layout.cpp` becomes menu/toolbar binding composition
- `app_dialogs.cpp` becomes workflow dispatch plus retained dialog opening glue
- `app.cpp` becomes startup/frame/queue composition over named helpers

### 3. Finish The UI Core Split

`pp_ui_core` exists, but generic widget ownership is still incomplete.

Required outcomes:

- generic `Node` and control code moves out of `pp_legacy_ui_core`
- `pp_panopainter_ui` keeps only app-specific nodes
- shared overlay/lifetime behavior stays centered in `pp_ui_core`
- the scene graph has explicit ownership, non-owning references, scoped
  callback connections, and documented UI-thread affinity

### 4. Make Runtime And Thread Ownership Explicit

This is crucial for a modern app architecture and must move with the app-shell
split, not after it.

Required outcomes:

- render/UI/worker queues are owned by explicit runtime services
- detached worker threads are replaced by joinable/cancellable ownership or a
  task service with shutdown semantics
- render-thread and UI-thread access are expressed through small contracts that
  can be implemented by future platform shells
- `App::I` and `Canvas::I` stop being the way cross-thread code reaches state

### 5. Finish The Platform Split

This is still a real blocker, but it should follow the bulk code-moving work
above instead of taking priority over the main app hotspots.

Required outcomes:

- remove concrete platform code from `pp_platform_api`
- remove `App::I` reach from platform service implementations
- remove app-owned cross-platform handle storage
- reduce `platform_legacy` to thin composition or delete it

### 6. Retire Thick Workflow Bridges

Open/save/session/export/cloud/brush package flows are still too distributed
across retained app, panel, and dialog files.

Required outcomes:

- document workflow bridges become thin adapters over `pp_app_core`
- cloud transfer and cloud browser ownership move out of retained UI nodes
- brush package import/export ownership moves out of retained panel code and
  no longer depends on detached worker launch sites

### 7. Only Then Resume Future Backend Work

Vulkan, Metal, and package-only cleanup are explicitly downstream of the app
architecture work above.

Do not treat future backend scaffolds as proof that modernization is near done
while the current shell still depends on:

- `platform_legacy`
- `pp_legacy_app`
- `pp_legacy_ui_core`
- `pp_legacy_paint_document`
- large GL-heavy node and canvas files

## Exit Criteria

The modernization is not done until these are all true:

- `pp_platform_api` contains only platform-neutral code
- `src/platform_apple/*`, `src/platform_linux/*`, and other concrete platform
  implementations do not reach `App::I`
- `platform_legacy` is gone or reduced to a trivial temporary shim
- `App` no longer stores cross-platform handle state that belongs to platform
  shells
- `pp_panopainter_ui` no longer depends on `pp_legacy_app`
- `panopainter_app` is a composition root, not a second workflow layer
- `canvas.cpp`, `node_canvas.cpp`, and `node_stroke_preview.cpp` no longer own
  large renderer-orchestration bodies
- live app, UI, canvas, cloud, and platform code no longer launch detached
  worker threads without owned shutdown/cancellation
- render/UI task queues are owned behind explicit app runtime services
- raw app/UI pointers are non-owning implementation details only, backed by
  checked handles, scoped connections, or documented owner objects
- future-backend-facing app and UI code depends on renderer API contracts, not
  retained OpenGL resource classes or direct GL execution
- `pp_legacy_ui_core`, `pp_legacy_app`, and `pp_legacy_paint_document` are
  removed or reduced to narrow, explicit adapter seams with debt ids and clear
  removal conditions

## Active Queue

Use `docs/modernization/tasks.md` for the current architecture task bundles,
ordered by real code-moving priority.
Use `docs/modernization/tasks-done.md` only for history.
