# Modernization Task Tracker

Status: live
Last updated: 2026-06-17

This file now tracks only active architecture work.
Completed, blocked, and superseded task history moved to
`docs/modernization/tasks-done.md`.

## Operating Rules

- Keep this file short. If a task is done, blocked for a long time, or no
  longer relevant, move it to `tasks-done.md` instead of letting the active
  queue sprawl.
- Keep tasks architecture-first. Build, test, tool, planner, CLI, and
  automation cleanup are secondary unless they directly unblock or accompany a
  real ownership transfer in the live app.
- Prefer coherent bundles over tiny adapter nibbles. Each task here should make
  visible progress in a hotspot file or a legacy target, not just add another
  seam around the same code.
- Use legacy-target shrinkage and hotspot reduction as the main progress signal.
  If a slice does not materially reduce a large file or move code out of
  `PP_PANOPAINTER_*` or `PP_LEGACY_*`, it is probably not first-priority work.
- Do not restart Vulkan, Metal, or package-only work until the app shell,
  platform split, UI split, and canvas/render split are materially thinner.

## Current Architecture Read

- The extracted pure targets are real and useful: `pp_foundation`,
  `pp_assets`, `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_renderer_gl`,
  `pp_paint_renderer`, `pp_ui_core`, `pp_platform_api`, and `pp_app_core`.
- The remaining app still lives mostly in legacy containment or thick shell
  targets:
  - `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
- The biggest remaining single-file pressure is no longer the old entrypoint
  shell files; it now sits mostly in retained legacy service layers behind
  `src/node_stroke_preview.cpp`, `src/app.cpp`, `src/app_dialogs.cpp`, and the
  extracted canvas/platform containment files.
- The platform boundary is not finished:
  - `pp_platform_api` no longer compiles Apple or Linux implementation files,
    but broader concrete platform ownership is still not fully separated
  - `platform_apple` no longer reaches `App::I` directly, and Linux FPS title
    reporting now uses an injected callback, but retained Apple bridging and
    broader platform-to-app singleton reach are still open in
    `platform_legacy`
  - Apple retained bridge/state ownership now lives in
    `src/platform_apple/apple_platform_state.cpp` and
    `src/platform_apple/apple_platform_services.*`, and the live Apple
    `PlatformServices` surface now binds directly from those Apple-owned files,
    and Android now also binds directly from
    `src/platform_android/android_platform_services.*`, but the broader
    non-Windows fallback adapter still exists for Linux/Web
  - `platform_legacy` is still part of the live app shell
- The app runtime boundary is not finished:
  - render/UI queues are static `App` state
  - app-facing detached launches are no longer the main issue; preview and
    recording now use owned worker threads, but those families still rely on
    retained static/global ownership and ad hoc runtime control
  - canvas async import/export/save/open now run through an owned in-file
    worker, but their retained progress execution is still not a clean runtime
    service boundary
  - the Win32 input path now binds the active `WacomTablet*` explicitly
    through `windows_runtime_shell`, but that tablet binding still lives at a
    composition edge instead of a broader runtime/platform-owned controller
  - the touched Win32 app/window/session queries now route through narrow
    runtime-shell accessors, but the broader runtime/thread host still owns
    composition-edge global state and shutdown sequencing
    - thread-affinity rules are enforced by convention and asserts instead of
      explicit runtime contracts
- The UI ownership boundary is not finished:
  - base `Node` still carries raw parent/manager pointers beside shared handles
  - callback captures still frequently close over retained nodes and `App::I`
  - checked overlay/lifetime helpers exist but are not yet the default scene
    graph model
- Historical score/progress claims are not useful for prioritization here.
  The live app still mostly runs through the same shell and hotspot files, so
  the queue is now ordered by code movement instead.

Current 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 in
  `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 in
  `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 singleton does
  not change the live entrypoint ownership path.
- `src/platform_windows/windows_runtime_state.*` now also owns the bound
  Win32 `App*`, bound-runtime shutdown, and tablet binding surface alongside
  the retained owned `App`, runtime, and tablet objects.
- `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 thinner and no longer open-codes that
  retained runtime-state setup.
- `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.
- `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 thinner again and now
  imports that binding surface from `windows_runtime_state.h` instead of
  declaring shell-owned bind/release accessors itself.
- `src/platform_windows/windows_lifecycle_shell.cpp` now formats the FPS title
  with a stack buffer at the callsite instead of keeping a retained
  lifecycle-global scratch array.
- `src/platform_windows/windows_lifecycle_state.*` is now down to the 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.
- `src/platform_windows/windows_runtime_flow.cpp` is now only a thin handoff
  into that session helper.
- `src/platform_windows/windows_runtime_shell.h` no longer exposes the removed
  runtime-session entrypoint or carries bootstrap/splash declarations for it.
- `src/platform_windows/windows_runtime_shell.cpp` now calls
  `run_bound_main_window_runtime(...)` directly instead of keeping a second
  wrapper entrypoint around that seam.
- `src/main.cpp` now includes `windows_bootstrap_helpers.h` directly for
  `run_winmain_entry(...)` instead of depending on a transitive declaration
  through the shell header.
- `src/platform_windows/windows_runtime_flow.*` now owns the Win32 bound-app
  startup/message-loop/shutdown orchestration.
- `src/platform_windows/windows_runtime_shell.cpp` now delegates that runtime
  flow instead of carrying the whole orchestration pocket inline.
- `src/platform_windows/windows_runtime_shell.cpp` and
  `src/platform_windows/windows_lifecycle_shell.cpp` now use the explicit
  Windows runtime binding for thread start/stop control.
- `src/platform_windows/windows_platform_services.cpp` no longer uses
  `bound_app()->runtime()` for main-thread task brokering.
- `src/platform_windows/windows_runtime_state.*` now carries the active
  `AppRuntime*` binding used by that queue hook.
- `src/platform_windows/windows_runtime_state.*` now owns the retained Win32
  runtime object lifetime (`App`, `WacomTablet`).
- `src/platform_windows/windows_runtime_shell.cpp` no longer carries that
  retained runtime-state storage directly.
- `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 keeps a separate
  retained VR shell-state accessor.
- `src/platform_windows/windows_lifecycle_state.*` now owns the retained
  Win32 lifecycle running flag and FPS-title scratch buffer.
- `src/platform_windows/windows_lifecycle_shell.cpp` no longer carries that
  retained lifecycle state pocket.
- `src/platform_windows/windows_async_render_context.*` now owns the retained
  Win32 async GL context lock/swap state.
- `src/platform_windows/windows_platform_services.cpp` no longer carries the
  retained async render-context pocket.
- `src/platform_windows/windows_main_window_session.*` now owns the retained
  Win32 main-window session fields.
- `src/platform_windows/windows_runtime_shell.cpp` no longer carries the
  retained `HWND` / title-buffer / sandbox pocket.
- `src/platform_legacy/legacy_platform_services.*` now takes Android storage
  paths through explicit `create_platform_services(...)` config instead of the
  legacy singleton state.
- `android/src/cpp/main.cpp` now seeds Android storage paths into its owned
  legacy `PlatformServices` instance.
- `src/platform_legacy/legacy_platform_state.*` no longer keeps the Android
  storage-path singleton API.
- `src/platform_legacy/legacy_platform_state.*` no longer keeps a separate
  retained Web platform-service binding; the Web helper path now uses the
  shared `platform_api` injection hook.
- `src/platform_legacy/legacy_platform_state.*` no longer keeps any retained
  GLFW window state.
- `linux/src/main.cpp` and `webgl/src/main.cpp` no longer seed legacy GLFW
  retained state.
- `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 uses an injected
  `LegacyGlfwPlatformShell` for the remaining Linux/WebGL
  acquire/present/request-close path.
- `linux/src/main.cpp` and `webgl/src/main.cpp` now bind those GLFW operations
  directly from entrypoint-owned window state.
- `src/platform_legacy/legacy_platform_state.*` no longer carries the old GLFW
  acquire/present/request-close helper trio.
- `src/platform_windows/windows_platform_services.cpp` now owns the Win32
  virtual-key map initialization path, so `windows_window_shell.cpp` no longer
  carries that retained input setup.
- `src/platform_windows/windows_window_shell.*` no longer keeps the dead
  retained raw key-state array.
- `linux/src/main.cpp` now owns the Linux FPS-title callback lifecycle
  directly, and `src/app_events.cpp` no longer installs Linux-specific title
  behavior from `App::set_platform_services()`.
- `src/platform_windows/windows_window_shell.*` now stores the Win32 virtual-key
  map separately from the retained input-state struct.
- `src/platform_windows/windows_runtime_shell.cpp` no longer stores a second
  retained `App*` alongside `App::I`.
- `src/platform_windows/windows_window_shell.*` plus
  `src/platform_windows/windows_platform_services.cpp` now separate Win32 VR
  state from the generic retained input-state bundle.
- `src/platform_legacy/legacy_platform_state.*` no longer keeps the dead
  generic storage-path singleton for the current platform matrix.
- `src/platform_legacy/legacy_platform_services.cpp` now returns only
  platform-specific storage-path branches; the dead generic fallback read is
  gone.
- `src/legacy_app_startup_services.cpp` no longer writes the dead generic
  storage-path fallback state on non-Android startup.
- `src/platform_legacy/legacy_platform_services.cpp` plus
  `src/platform_legacy/legacy_platform_state.*` now give Android its own
  retained storage-path surface instead of reading the shared legacy singleton.
- `src/legacy_app_startup_services.cpp` and `android/src/cpp/main.cpp` now
  write Android-owned retained storage paths.
- `src/app_events.cpp`, `linux/src/main.cpp`, and
  `src/platform_legacy/legacy_platform_state.*` now use a narrow GLFW title
  helper for Linux FPS-title updates instead of direct retained-window access
  in the app event layer.
- `src/platform_legacy/legacy_platform_state.h` no longer exports the raw
  retained GLFW window accessor.
- `src/platform_apple/apple_platform_state.cpp` now reads Apple storage paths
  from Apple-owned retained state rather than the shared legacy storage-path
  singleton.
- `PanoPainter-OSX/main.cpp` and `PanoPainter/GameViewController.m` now seed
  Apple-owned storage paths directly.
- `src/platform_legacy/legacy_platform_state.*` now only exposes
  `create_legacy_web_platform_services()` for the retained default Web service
  implementation.
- `webgl/src/main.cpp` now owns and binds the Web platform-services
  implementation explicitly instead of relying only on the retained fallback
  object in legacy platform state.
- `src/platform_windows/windows_runtime_shell.*` now threads `HINSTANCE`
  through the startup/runtime path explicitly instead of keeping it in the
  retained main-window session state.
- `webgl/src/main.cpp` now binds an owned legacy `PlatformServices` instance
  instead of reading the process-global fallback directly during `StartApp()`.
- `android/src/cpp/main.cpp` now binds a function-lifetime owned legacy
  `PlatformServices` instance in `android_main()`, replacing the direct bind to
  the process-global fallback accessor.
- `src/platform_legacy/legacy_platform_services.*` now exposes an ownable
  `create_platform_services()` entrypoint for explicit per-entrypoint
  ownership.
- `linux/src/main.cpp` now binds an owned legacy `PlatformServices` instance
  into `App`, making Linux the first explicit per-entrypoint owner of that
  legacy service implementation.
- `src/app_events.cpp` no longer hides a fallback to
  `pp::platform::legacy::platform_services()`; touched app platform dispatch
  now expects an explicitly bound platform-services pointer.
- `linux/src/main.cpp`, `webgl/src/main.cpp`, and `android/src/cpp/main.cpp`
  now bind owned `create_platform_services(...)` instances at app creation,
  removing the hidden fallback path from live non-Windows entrypoints.
- `src/platform_windows/windows_runtime_shell.cpp` now owns the Windows tablet
  object directly, removing the composition-edge `WacomTablet::I` binding from
  the touched 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 that fallback behavior stays inline in the legacy platform
  singleton.
- `src/platform_windows/windows_runtime_shell.cpp` no longer keeps a separate
  retained `AppRuntime*`; the touched Windows lifecycle and main-thread task
  dispatch paths now derive runtime ownership directly from the owned app.
- `src/platform_windows/windows_runtime_shell.cpp` now owns the Windows `App`
  through a retained `std::unique_ptr<App>`, so startup/early-return/convert
  paths no longer manage raw `new`/`delete` app lifetime manually.
- `src/platform_windows/windows_lifecycle_shell.cpp` now releases the bound
  app through `release_bound_app()` after runtime shutdown instead of deleting
  it directly through the lifecycle shell.
- `src/platform_windows/windows_window_shell.cpp` now routes the touched
  key-map synchronization and VR close state through narrow helpers instead of
  carrying the broader retained window bundle live across the window-proc path.
- `src/platform_legacy/legacy_platform_state.*` no longer exports the mutable
  retained GLFW hook bundle; Linux/Web fallback render-context, present, and
  app-close paths now route through narrow GLFW helper functions instead.
- `scripts/automation/quiet-validate.ps1` now owns the recommended quiet
  checkpoint path and can bundle Windows build/test, Android/platform sweeps,
  and Apple remote compile gates into one compact JSON summary with
  `-IncludePlatformBuild` and `-IncludeAppleRemote`.
- `scripts/automation/platform-build.ps1` now supports `-Quiet`, per-preset log
  capture, and compact JSON-only output so standalone Android/headless sweeps
  still have a targeted quiet path when they need to be isolated from the
  bundled wrapper.
- `scripts/automation/apple-remote-build.ps1` now supports `-Quiet`, local SSH
  session log capture, remote log path reporting, and JSON-only output so the
  standalone Apple gate can still be run directly when the bundled wrapper is
  too broad.

## Active Bundles

### Priority Order

- `P0`: shrink the biggest live app hotspots and legacy ownership first
- `P0`: make app runtime, UI ownership, and renderer access safe enough for
  future backend work
- `P1`: finish supporting boundaries that unblock or stabilize the thinner app
- `P2`: only then clean up the remaining workflow adapters

### Bundle 1 - Break The Canvas And Preview Hotspots

Priority: `P0`

Why this bundle is first:
This is where the biggest block of real app behavior still lives.
If these files stay large and stateful, the rest of the modernization still
looks like a wrapper around the old renderer shell.

#### ARC-RND-001 - Split `canvas.cpp` Into Document State And Render Execution Shells

Status: In Progress

Why now:
The live `Canvas` ownership boundary is still active, but `src/canvas.cpp`
itself is now down to a thin static singleton plus mode-table shell. The
remaining canvas pressure now sits in the extracted legacy canvas service
files and the preview/canvas node render paths rather than the old monolithic
translation unit.

Current slice:
- The remaining `Canvas` member wrappers in `src/canvas.cpp` now live in the
  extracted canvas service files, leaving `canvas.cpp` as the static singleton
  and mode-table shell.
- Canvas state-management helpers for picking, clear/clear-all, layer
  add/remove/order/lookups, animation frame control, resize, and snapshot
  save/restore now live in `src/legacy_canvas_state_services.cpp` instead of
  staying inline in `src/canvas.cpp`, but the file still owns the larger
  document-plus-render shell.
- Canvas import/export/save/open/thumbnail ownership now lives in
  `src/legacy_canvas_document_io_services.cpp` instead of staying inline in
  `src/canvas.cpp`, which materially reduces document IO ownership in the live
  render shell.
- Canvas point-trace, unproject, project2D, face-to-shape, and camera
  push/pop/get/set helpers now also live in
  `src/legacy_canvas_projection_services.*` instead of staying inline in
  `src/canvas.cpp`, which trims another coherent non-UI state/query pocket
  from the live canvas shell.
- `Canvas::draw_objects_direct(...)` and `Canvas::draw_objects(...)` now also
  live in `src/legacy_canvas_object_draw_services.*` instead of staying inline
  in `src/canvas.cpp`, which trims another coherent object-draw and
  viewport-state execution pocket from the live canvas shell.
- `Canvas::stroke_draw_samples(...)`, `Canvas::stroke_commit()`, and the
  larger stroke sample/commit execution family now also live in
  `src/legacy_canvas_stroke_commit_services.*` instead of staying inline in
  `src/canvas.cpp`, which trims another large retained stroke-render pocket
  from the live canvas shell.
- Shared canvas-mode GL wrappers plus the `CanvasModeBasicCamera` and
  `CanvasModeCamera` input handlers now also live in
  `src/legacy_canvas_mode_helpers.*` instead of staying inline in
  `src/canvas_modes.cpp`, which trims another coherent retained canvas-view
  interaction pocket from the broader canvas/render hotspot family.
- The `CanvasModeTransform` interaction family now also lives in
  `src/legacy_canvas_mode_transform.cpp` instead of staying inline in
  `src/canvas_modes.cpp`, which materially thins another retained
  transform-mode pocket from the broader canvas/render hotspot family.
- The `CanvasModeFill` and `CanvasModeFloodFill` interaction families now
  also live in `src/legacy_canvas_mode_fill.cpp` instead of staying inline in
  `src/canvas_modes.cpp`, which materially thins another retained fill-mode
  pocket from the live canvas-mode shell.
- The live `Canvas::stroke_draw()` orchestration now also lives in
  `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 from the canvas shell.
- `Canvas::layer_merge(...)`, `Canvas::flood_fill(...)`, and
  `Canvas::FloodData::apply()` now also live in
  `src/legacy_canvas_layer_services.cpp` instead of staying inline in
  `src/canvas.cpp`, which trims another coherent retained layer/fill workflow
  pocket from the live canvas shell.
- `Canvas::stroke_end(...)`, `Canvas::stroke_cancel(...)`,
  `Canvas::stroke_draw_mix(...)`, `Canvas::stroke_draw_project(...)`,
  `Canvas::stroke_update(...)`, and `Canvas::stroke_start(...)` now also live
  in `src/legacy_canvas_stroke_runtime_services.*` instead of staying inline in
  `src/canvas.cpp`, which trims another large retained live stroke/runtime
  pocket from the canvas shell.
- `Canvas::draw_merge(...)`, the temporary-paint and merge-branch
  orchestration helpers, final-plane composite, timelapse commit, create,
  destroy, clear-context, and camera accessors now also live in
  `src/legacy_canvas_render_shell_services.*` instead of staying inline in
  `src/canvas.cpp`, which trims another large retained render/context shell
  from the live canvas owner.
- 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.
- 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.
- The `CanvasModePen` and `CanvasModeLine` interaction families now also live
  in `src/legacy_canvas_mode_pen_line.cpp` instead of staying inline in
  `src/canvas_modes.cpp`, which materially thins another retained pen/line
  interaction pocket from the broader canvas/render hotspot family.
- The `CanvasModeMaskFree` and `CanvasModeMaskLine` interaction families now
  also live in `src/legacy_canvas_mode_mask.cpp` instead of staying inline in
  `src/canvas_modes.cpp`, which materially thins another retained mask-tool
  interaction pocket from the broader canvas/render hotspot family.

Write scope:
- `src/canvas.cpp`
- `src/canvas_layer.cpp`
- `src/canvas_actions.cpp`
- `src/legacy_canvas_*services*.h`
- new `src/legacy_canvas_*.*` helpers if needed

Read scope:
- `src/document/*`
- `src/paint_renderer/*`
- `src/renderer_api/*`

Done when:
- `canvas.cpp` stops being the place where document state, render sequencing,
  history reads, and GL-side execution all meet.
- Non-render state/query/update helpers move out first.
- The remaining file reads as a render shell with explicit helper boundaries.
- The touched slice makes a substantial reduction in inline ownership, not just
  a thin wrapper extraction.

Mini-model packet:
- Do not try to delete `Canvas` in one slice.
- Prioritize ownership separation over clever abstractions.
- Aim for a visible reduction in `canvas.cpp`, on the order of hundreds of
  lines, not a token helper extraction.

#### ARC-RND-002 - Isolate Preview And Canvas View Render Execution

Status: In Progress

Why now:
`src/node_stroke_preview.cpp` and `src/node_canvas.cpp` still own a large amount
of live preview/canvas render sequencing around the renderer boundary.

Current slice:
- `NodeStrokePreview` final composite plus preview-texture copy now route
  through `legacy_node_stroke_preview_execution_services.h`, but the preview
  node still owns most live-pass and retained GL resource execution.
- `NodeStrokePreview` render-target setup plus immediate-pass sequence
  orchestration now also route through
  `legacy_node_stroke_preview_execution_services.h`, and duplicate render-
  target setup was removed from `render_to_image()` and the queued worker path,
  but the preview node still owns broader live-pass state and thread-facing
  orchestration.
- `NodeStrokePreview` main live-pass request assembly and preview framebuffer-
  copy setup now also route through
  `legacy_node_stroke_preview_execution_services.h`, which trims another
  coherent pass-setup block from `src/node_stroke_preview.cpp`, but broader
  preview-pass orchestration is still inline.
- `NodeStrokePreview` remaining mix-pass planning and execution now also route
  through `legacy_node_stroke_preview_draw_services.*`, which trims the last
  dedicated mix-orchestration pocket from `src/node_stroke_preview.cpp`.
- `NodeStrokePreview::draw_stroke_immediate()` immediate preview pass
  sequencing now also routes through the private
  `execute_stroke_draw_immediate_pass_sequence(...)` helper, which removes
  another live orchestration block from the node even though worker/readback
  flow still remains in the file.
- `NodeStrokePreview` stroke preparation, dual-brush setup, and live-pass
  request assembly now also route through retained preview execution helpers,
  which trims another coherent setup pocket from
  `src/node_stroke_preview.cpp` even though worker/readback ownership and
  broader preview flow still remain inline.
- `src/node_stroke_preview.cpp` is now 192 lines after moving the remaining
  preview GL dispatch and texture-slot binding pocket into
  `legacy_node_stroke_preview_runtime_services.*`.
- The remaining immediate preview pass shell in
  `NodeStrokePreview::draw_stroke_immediate()` now also routes through
  `execute_legacy_node_stroke_preview_draw_immediate_shell(...)`, which
  materially reduces the live preview-pass body even though worker/readback
  ownership still remains inline.
- `NodeCanvas` merged-path per-plane merged-texture draw execution now also
  routes through `execute_legacy_canvas_draw_merge_layer_texture(...)`.
- `NodeCanvas` merged-path and non-blend checkerboard background setup now also
  route through `execute_legacy_canvas_draw_merge_background_setup(...)`.
- `NodeCanvas` cache-to-screen checkerboard-plane callback setup now also routes
  through a retained helper in `legacy_canvas_draw_merge_services.h`.
- `NodeCanvas` display resolve plus cache-to-screen checkerboard/cache-texture
  composite now route through `legacy_canvas_draw_merge_services.h`.
- `NodeCanvas` smoothing-mask overlay, smoothing-mask face pass, grid keepalive
  draw, heightmap draw, and current-mode draw now also route through
  `execute_legacy_canvas_draw_merge_post_draw(...)`, but broader canvas draw
  orchestration is still inline.
- `NodeCanvas` heightmap draw callback setup now also routes through
  `make_legacy_canvas_draw_merge_heightmap_draw(...)`, but the node still owns
  current-mode traversal and broader post-draw orchestration.
- `NodeCanvas` current-mode draw callback setup now also routes through
  `make_legacy_canvas_draw_merge_current_modes_draw(...)`, and grid-mode
  callback setup now also routes through
  `make_legacy_canvas_draw_merge_grid_modes_draw(...)`, but broader post-draw
  orchestration is still inline.
- `NodeCanvas` checkerboard background plane callback setup now also routes
  through `make_legacy_canvas_draw_merge_background_checkerboard_plane(...)`,
  but the node still owns broader live layer traversal and renderer-state
  sequencing.
- `NodeCanvas` merged-path per-plane merged-texture draw callback setup now
  also routes through `make_legacy_canvas_draw_merge_layer_texture_draw(...)`,
  but the node still owns broader live layer traversal and renderer-state
  sequencing.
- `NodeCanvas` non-`draw_merged` per-frame layer draw callback setup now also
  routes through `make_legacy_canvas_draw_merge_layer_frame_draw(...)`, but
  the node still owns broader live layer traversal and renderer-state
  sequencing.
- `NodeCanvas` smoothing-mask face shader setup plus per-face draw execution
  now also route through
  `execute_legacy_canvas_draw_merge_smask_faces(...)`, but the node still owns
  the broader canvas draw flow and renderer-state sequencing around that seam.
- `NodeCanvas` non-`draw_merged` per-layer/per-plane retained draw execution
  now also routes through `execute_legacy_canvas_draw_merge_layer_plane(...)`,
  but the node still owns substantial live layer traversal and renderer state
  orchestration.
- `NodeCanvas` post-draw callback assembly now also routes through
  `execute_legacy_canvas_draw_merge_post_draw_callbacks(...)`, and the
  remaining per-layer render-path orchestration now also routes through
  `execute_legacy_canvas_draw_merge_layer_path(...)`, but the node still owns
  broader draw-loop and renderer-state shell sequencing.
- `NodeCanvas` outer non-`draw_merged` layer/plane traversal, onion-range
  failure handling, and visit payload setup now also route through
  `execute_legacy_canvas_draw_layer_traversal(...)`.
- `NodeCanvas` non-`draw_merged` per-layer temporary erase/paint, layer-texture,
  and blend setup now also route through
  `make_legacy_canvas_draw_merge_layer_path_gl_execution(...)`, but the node
  still owned the remaining draw lambdas and broader renderer-state shell.
- `NodeCanvas` now routes that remaining non-`draw_merged` per-layer blend,
  composite, debug-outline, and frame callback assembly through the local
  `make_node_canvas_layer_path_execution(...)` helper, which materially thins
  `NodeCanvas::draw()` even though the broader draw loop still lives in
  `src/node_canvas.cpp`.
- `NodeCanvas` post-draw callback assembly, smoothing-mask face execution, and
  optional display resolve now also route through
  `execute_node_canvas_draw_merge_tail(...)`, which trims another live tail
  block from `NodeCanvas::draw()` even though the broader outer draw shell is
  still inline.
- `NodeCanvas` non-`draw_merged` cache/background/layer-traversal/cache-
  composite shell now also routes through
  `execute_legacy_canvas_draw_unmerged_shell(...)`, which removes another
  coherent orchestration block from `NodeCanvas::draw()` even though the
  broader node draw loop still lives in `src/node_canvas.cpp`.
- `NodeCanvas` 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(...)`, which trims
  another outer draw-shell block even though the broader node draw loop still
  lives in `src/node_canvas.cpp`.
- `NodeCanvas` broader unmerged cache/viewport/background/composite pass setup
  now also routes through
  `execute_legacy_canvas_draw_unmerged_node_canvas_pass(...)`, which removes
  another coherent outer-shell block even though the broader node draw loop
  still lives in `src/node_canvas.cpp`.
- `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.
- `NodeCanvas::draw()` unmerged-pass blend-gate query, layer-orientation
  assembly, and callback wiring into
  `execute_legacy_canvas_draw_node_canvas_unmerged_pass(...)` now also route
  through `execute_node_canvas_draw_unmerged_pass(...)`, which trims another
  coherent unmerged draw-orchestration block from the live node even though
  the file size remains roughly flat.
- `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-orchestration block from the live node even
  though the broader draw loop still remains inline.
- `NodeCanvas::handle_event()` now routes through
  `handle_legacy_node_canvas_event(...)` in
  `src/legacy_canvas_tool_services.*`, which moves the live input/controller
  pocket out of `src/node_canvas.cpp` and keeps the node on a thinner shell.
- `App::dialog_layer_rename()` now routes through
  `open_legacy_document_layer_rename_dialog(...)` in
  `src/legacy_document_layer_services.*`, which moves the remaining dialog
  overlay-open/wire/close workflow out of `src/app_dialogs.cpp` and leaves
  the app dialog shell thinner.
- `App::open_document(...)` now routes through
  `execute_legacy_document_open(...)` in `src/legacy_document_open_services.*`,
  which moves route classification and unsaved-project gating out of
  `src/app.cpp` and into the retained document-open helper.
- `NodeCanvas` restore/clear context, resize handling, camera reset, buffer
  creation, cursor visibility/update, tick, and destroy ownership now also
  live in `src/legacy_node_canvas_state_services.*` instead of staying inline
  in `src/node_canvas.cpp`, which materially thins another retained
  state/control pocket without reopening the draw path.
- `NodeStrokePreview` retained lifecycle, worker-thread shell, render-to-image,
  on-screen handling, and preview texture ownership now also live in
  `src/legacy_node_stroke_preview_runtime_services.cpp` instead of staying
  inline in `src/node_stroke_preview.cpp`, which materially thins the preview
  node around its runtime-facing shell even though live pass execution still
  remains.
- The remaining low-level preview GL/runtime pocket for viewport queries,
  clear-color restore, texture-slot binding, mixer unbind, and
  destination/pattern texture plumbing now also lives in
  `src/legacy_node_stroke_preview_runtime_services.*` instead of staying
  inline in `src/node_stroke_preview.cpp`, which trims another coherent
  runtime-facing shell pocket from the live node.
- The remaining live render/pass orchestration in
  `NodeStrokePreview::draw_stroke_immediate()` now also lives in
  `src/legacy_node_stroke_preview_draw_services.*` instead of staying inline in
  `src/node_stroke_preview.cpp`, which trims another coherent preview
  draw-pass pocket while preserving the current runtime-facing shell.
- The immediate preview runtime/orchestration block in
  `NodeStrokePreview::draw_stroke_immediate()` for stroke setup, prepared
  stroke construction, pass planning, shader setup, and live render request
  assembly now also lives in
  `src/legacy_node_stroke_preview_runtime_services.*` instead of staying
  inline in `src/node_stroke_preview.cpp`, which trims another coherent
  preview runtime pocket while preserving the current live draw path.
- 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`, which trims another coherent preview
  execution pocket while preserving the live draw path.
- `NodeStrokePreview::draw_stroke_immediate()` now also 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 and leaves
  `src/node_stroke_preview.cpp` at 160 lines.
- `NodeStrokePreview` clone-finalize setup, stroke-frame planning, mix-pass
  adapter wiring, sample-pass adapter wiring, and the remaining
  immediate-draw request construction now also route through
  `src/legacy_node_stroke_preview_runtime_services.*`,
  `src/legacy_node_stroke_preview_draw_services.*`, and
  `src/legacy_node_stroke_preview_sample_services.*`, which leaves
  `src/node_stroke_preview.cpp` at 76 lines.
- `CanvasModeGrid` plus `ActionModeGrid` undo/redo now also live in
  `src/legacy_canvas_mode_helpers.cpp` instead of staying inline in
  `src/canvas_modes.cpp`, which leaves the live canvas-modes file as a
  minimal shell.
- `NodeCanvas::init()` plus the remaining `NodeCanvas::draw()` outer shell now
  also live in `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.
- The remaining generic node geometry/state pocket for `Node::SetSize(...)`,
  `SetMinSize(...)`, `SetMaxSize(...)`, and `SetPosition(const glm::vec2)`
  now also lives in `src/legacy_ui_node_style.*` instead of staying inline in
  `src/node.cpp`, which trims another coherent base scene-graph shell pocket
  without changing the public surface.
- `Node::app_redraw()` and `Node::watch(...)` now also live in
  `src/legacy_ui_node_execution.cpp` instead of staying inline in `src/node.cpp`,
  which trims another small generic node utility pocket from the live file.
- `NodePanelBrushPreset` global panel registration now also lives in
  `src/legacy_brush_preset_list_services.*` instead of staying on the live
  node type as a static registry field, which trims another retained
  controller-state pocket from `src/node_panel_brush.cpp`.
- The remaining `NodePanelBrush` and `NodePanelBrushPreset` member bodies now
  also 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.*`), which leaves
  `src/node_panel_brush.cpp` as a 2-line translation unit.

Write scope:
- `src/node_stroke_preview.cpp`
- `src/node_canvas.cpp`
- `src/legacy_node_stroke_preview_execution_services.h`
- `src/legacy_canvas_stroke_preview_services.h`
- `src/paint_renderer/compositor.*`

Read scope:
- `src/renderer_api/*`
- `src/renderer_gl/*`

Done when:
- Preview/canvas files stop carrying large inline render-pass orchestration.
- Concrete GL texture/bind/copy work is pushed behind explicit renderer-facing
  service seams.
- The next renderer backend would have one place to implement preview/canvas
  execution contracts instead of reading node code.
- The touched node files are materially smaller and less stateful afterward.

Mini-model packet:
- Keep the existing `pp_paint_renderer` planner surface and extend it only when
  the node files clearly need a missing renderer-owned callback contract.
- Prefer deleting inline orchestration over adding another planner layer around
  the same node code.

#### ARC-RND-003 - Replace App-Facing `Texture2D`/`RTT` Use With Renderer API Contracts

Status: In Progress

Why now:
Future Vulkan and Metal work needs the live app to stop treating retained
OpenGL resource classes as the renderer boundary.

Write scope:
- `src/node_canvas.cpp`
- `src/node_stroke_preview.cpp`
- `src/canvas.cpp`
- `src/texture.*`
- `src/rtt.*`
- `src/renderer_api/*`
- `src/paint_renderer/*`

Read scope:
- `src/renderer_gl/*`
- `src/legacy_gl_*dispatch.h`
- `src/app_shaders.cpp`

Done when:
- App/UI-facing render work talks to `pp_renderer_api` resource and command
  abstractions or narrow retained renderer services, not directly to
  `Texture2D`, `RTT`, or GL dispatch helpers.
- The remaining `Texture2D`/`RTT` references are contained in retained GL
  backend/adapters with explicit removal conditions.
- Canvas, preview, and export paths expose the same execution contract a Vulkan
  or Metal implementation would need.
- The touched slice shrinks app/UI render ownership instead of only renaming
  wrappers.

Mini-model packet:
- Do not start a Vulkan or Metal backend in this task.
- Use existing `pp_renderer_api` and `pp_paint_renderer` contracts first.
- Treat direct GL classes in UI/app code as debt to move behind backend-owned
  services.

### Bundle 2 - Thin The App Shell

Priority: `P0`

Why this bundle is next:
`app_layout.cpp`, `app_dialogs.cpp`, and `app.cpp` still make the modernized
targets look like helpers under one old monolith.

#### ARC-APP-001 - Split `app_layout.cpp` Into UI Binding Modules

Status: In Progress

Why now:
`src/app_layout.cpp` is still a 125-line mixed file that builds menus,
attaches callbacks, computes planner inputs, and mutates UI state directly.

Current slice:
- Tools-menu UI binding, including the nested panels/options submenu wiring,
  now lives in `src/app_layout_tools_menu.cpp`, and `App::init_menu_tools()`
  is now a thin call-through, but file/about/layer/sidebar and broader layout
  composition are still inline in `src/app_layout.cpp`.
- File-menu UI binding, including the export submenu wiring, now also lives in
  `src/app_layout_file_menu.cpp`, and `App::init_menu_file()` is now a thin
  call-through, but about/layer/sidebar and broader layout composition are
  still inline in `src/app_layout.cpp`.
- 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, but edit/sidebar and
  broader layout composition are still inline in `src/app_layout.cpp`.
- Sidebar panel binding plus popup wiring now also live in
  `src/app_layout_sidebar.cpp`, and `App::init_sidebar()` is now a thin
  call-through, but edit-menu wiring and broader layout composition are still
  inline in `src/app_layout.cpp`.
- Main-toolbar binding now also lives in `src/app_layout_main_toolbar.cpp`,
  and `App::init_toolbar_main()` is now a thin call-through, but edit-menu
  wiring and broader layout composition are still inline in `src/app_layout.cpp`.
- Edit-menu binding now also lives in `src/app_layout_edit_menu.cpp`, and
  `App::init_menu_edit()` is now a thin call-through, but draw-toolbar,
  brush-refresh, and broader layout composition are still inline in
  `src/app_layout.cpp`.
- UI-direction plus persisted floating/docked panel-state ownership now also
  lives in `src/app_layout_ui_state.cpp`, and `src/app_layout.cpp` is down to
  the remaining draw-toolbar, brush-refresh, and layout/bootstrap composition.
- Draw-toolbar binding now also lives in `src/app_layout_draw_toolbar.cpp`, and
  `src/app_layout.cpp` is down to the remaining brush-refresh and
  layout/bootstrap composition.
- Brush-refresh now also lives in `src/app_layout_brush.cpp`, and
  `src/app_layout.cpp` is down to the remaining layout/bootstrap composition.
- Layout bootstrap plus reload/load continuation wiring now also lives in
  `src/app_layout_bootstrap.cpp`, and `src/app_layout.cpp` is down to thin
  call-through entrypoints plus the remaining local helper pocket.

Write scope:
- `src/app_layout.cpp`
- `src/legacy_app_shell_services.*`
- new `src/app_layout_*.*` or `src/legacy_*_ui_services.*` files if needed

Read scope:
- `src/app_core/*menu*.h`
- `src/app_core/brush_ui.h`
- `src/app_core/document_layer.h`
- `src/app_core/main_toolbar.h`

Done when:
- `app_layout.cpp` becomes a composition/binding file instead of a giant mixed
  controller.
- File-menu, toolbar, tools-menu, about-menu, and layer-menu wiring each live
  in named helper modules or services.
- The split follows planner/service boundaries already present in `pp_app_core`.
- The touched slice materially shrinks the file instead of just moving a few
  lambdas around.

Mini-model packet:
- Start by carving out one coherent family at a time, not by reshuffling lines.
- Preserve the current planner calls; the goal is ownership, not new behavior.
- Aim for a real file-size drop, not cosmetic decomposition.

#### ARC-APP-002 - Split `app_dialogs.cpp` Into Workflow Adapters And Widget Openers

Status: In Progress

Why now:
`src/app_dialogs.cpp` still mixes document workflow decisions, export routing,
dialog construction, and overlay ownership in one 106-line shell.

Current slice:
- Informational overlay opener paths for user manual, changelog, about,
  what's-new, and shortcuts now live in `src/app_dialogs_info_openers.cpp`,
  and the corresponding `App::dialog_*` entrypoints are now thin call-throughs,
  but document/export workflow and retained dialog execution are still inline in
  `src/app_dialogs.cpp`.
- Export, video-export, and PPBR export entrypoints now also live in
  `src/app_dialogs_export.cpp`, and the corresponding `App::dialog_*`
  entrypoints are now thin call-throughs, but new/open/save/browse/resize and
  retained dialog execution are still inline in `src/app_dialogs.cpp`.
- New/open/save/browse/resize workflow entrypoints now also live in
  `src/app_dialogs_workflow.cpp`, and the layer-rename dialog open / wire /
  close pocket now lives in `src/legacy_document_layer_services.*`, leaving
  `src/app_dialogs.cpp` down to the remaining thin entrypoints plus other
  retained dialog glue.
- `App::open_document()` now routes document classification and unsaved-
  project gating through `src/legacy_document_open_services.cpp` instead of
  keeping that shell pocket inline in `src/app.cpp`, which trims the live app
  entry file a little further while preserving the retained open-action
  behavior.
- `App::show_progress(...)`, `App::message_box(...)`, and
  `App::input_box(...)` now also route through
  `src/legacy_app_dialog_services.*` instead of building dialog plans and
  factories inline in `src/app_dialogs.cpp`, which trims another coherent
  dialog-construction pocket from the remaining app dialog shell.

Write scope:
- `src/app_dialogs.cpp`
- `src/legacy_app_dialog_services.*`
- `src/legacy_document_session_services.*`
- `src/legacy_document_open_services.*`
- `src/legacy_document_export_services.*`

Read scope:
- `src/app_core/app_dialog.h`
- `src/app_core/document_session.h`
- `src/app_core/document_export.h`

Done when:
- `app_dialogs.cpp` is reduced to thin entrypoints plus named helper modules.
- Dialog creation/opening is clearly separated from document/export workflow
  routing.
- The remaining direct node-specific code is isolated to retained dialog
  adapters.
- The slice removes a meaningful amount of mixed live ownership from
  `app_dialogs.cpp`.

Mini-model packet:
- Preserve existing planner usage.
- Prefer new narrow helper files over leaving another giant dialog utility file.
- Do not spend time extending dialog planners or CLI surfaces unless the live
  adapter gets thinner in the same slice.

#### ARC-APP-003 - Reduce `app.cpp` To Frame, Queue, And Composition Shell

Status: In Progress

Why now:
`src/app.cpp` still carries startup, frame flow, queue draining, recording,
and composition logic in one 171-line file.

Current slice:
- UI observer math now routes through `src/legacy_app_frame_services.cpp`
  instead of staying inline in `src/app.cpp`.
- The repeated UI child traversal in `App::draw()` now routes through the same
  retained helper.
- Canvas toolbar refresh in `App::update()` now also routes through that helper
  file, materially shrinking `src/app.cpp`.
- `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 live in
  `src/legacy_app_runtime_shell_services.cpp` instead of staying inline in
  `src/app.cpp`, which materially thins the remaining frame/runtime shell.
- `App::create(...)`, `App::initAssets(...)`, `App::initLog(...)`, and
  `App::init(...)` now also live in `src/legacy_app_startup_services.*`
  instead of staying inline in `src/app.cpp`, which reduces the remaining app
  file to a thinner retained composition surface around startup and runtime
  delegation.
- `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`, which reduces the remaining app file to a thinner
  retained shell around document routing and runtime thread entrypoints.
- `App::request_close()`, `App::renderdoc_frame_start()`,
  `App::renderdoc_frame_end()`, and the render/UI thread entrypoint wrappers
  now also route through `src/legacy_app_runtime_shell_services.*` instead of
  staying inline in `src/app.cpp`, which reduces the live app file to a much
  thinner retained composition shell.

Write scope:
- `src/app.cpp`
- `src/legacy_app_startup_services.*`
- `src/legacy_recording_services.*`
- small new `src/app_runtime_*.*` helpers if needed

Read scope:
- `src/app_core/app_frame.h`
- `src/app_core/app_shutdown.h`
- `src/app_core/app_startup.h`
- `src/app_core/document_recording.h`

Done when:
- `app.cpp` reads like a shell over `pp_app_core` planners and named retained
  services.
- Startup, frame/update, queue/thread, and recording glue are split into named
  helpers instead of living inline.
- `App` keeps ownership of composition state only where it truly has to.
- The file becomes materially thinner in the same slice.

Mini-model packet:
- Keep thread behavior unchanged.
- Split by responsibility boundaries already present in `pp_app_core`.
- Prefer moving live ownership out over creating new planner wrappers.

#### ARC-APP-004 - Move Render/UI Queues Into An Owned App Runtime Service

Status: In Progress

Why now:
`App` still owns static render/UI queues, mutexes, condition variables, and
thread ids. That makes thread safety hard to reason about and keeps platform
entrypoints coupled to the singleton.

Current slice:
- render/UI queues, mutexes, condition variables, and thread identity already
  live in `AppRuntime`
- `AppRuntime` render/UI worker ownership now also uses `std::jthread` plus
  explicit stop requests instead of raw `std::thread`
- Windows main-loop run-state and VR worker coordination flags in `main.cpp`
  now use `std::atomic` ownership instead of unsynchronized globals
- `main.cpp` Win32 window handles, GL task/mutex state, splash-dialog state,
  stylus timers, and VR worker state now sit behind one retained local state
  object instead of separate file-scope globals
- the splash-screen dialog loop, HWND ownership, and bitmap setup now also live
  in `src/platform_windows/windows_splash.cpp` instead of `src/main.cpp`, and
  `main.cpp` now just owns the helper lifecycle
- Win32 async GL/context lock state now lives in
  `src/platform_windows/windows_platform_services.cpp` instead of `main.cpp`
  retained state, and `main.cpp` only seeds that platform-owned context handle
  pair during initialization and context recreation
- `main.cpp` main-thread queued task state now lives under
  `src/platform_windows/windows_platform_services.cpp` instead of staying in
  the entry TU
- Win32 pointer API loading, stylus/ink timer ownership and decay, `WT_PACKET`
  reset, and `WM_POINTERUPDATE` pen/touch handling now live in
  `src/platform_windows/windows_stylus_input.cpp` instead of `src/main.cpp`,
  but broader retained Win32 shell state is still open
- the retained Win32 VR/HMD shell, including worker start/stop and VR FPS
  state, now routes through `src/platform_windows/windows_vr_shell.h` instead
  of staying inline in `src/main.cpp`, but broader retained Win32 shell state
  is still open
- RenderDoc startup/frame capture, SHCore DPI bootstrap, Win32 error-string
  conversion, `UnadjustWindowRectEx`, and GL debug pre/post callbacks now also
  live in `src/platform_windows/windows_bootstrap_helpers.cpp` instead of
  `src/main.cpp`
- the WMI startup probe now also lives in
  `src/platform_windows/windows_bootstrap_helpers.cpp` instead of
  `src/main.cpp`
- Win32 lifecycle running-state, close/shutdown handling, FPS title update and
  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`
- Win32 startup/window/bootstrap flow now also lives in
  `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
- BugTrap/SEH recovery setup now also lives in
  `src/platform_windows/windows_bootstrap_helpers.cpp` instead of
  `src/main.cpp`
- the Win32 window procedure and retained message-handling shell now also live
  in `src/platform_windows/windows_window_shell.*` instead of `src/main.cpp`,
  which materially thins the entry file even though broader runtime/entrypoint
  composition is still open
- the `WinMain` argv conversion bridge now also lives in
  `src/platform_windows/windows_bootstrap_helpers.*` instead of staying inline
  in `src/main.cpp`, which trims another Windows-specific entrypoint pocket
- retained input-state zeroing and reverse key-map initialization now also live
  in `src/platform_windows/windows_window_shell.*` instead of `src/main.cpp`,
  which trims another small but real retained-state pocket from the entry TU
- the remaining interactive Win32 runtime pocket for touch registration,
  render/UI thread startup, debug GL callback hookup, Wintab init/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 reduces the entry TU to a much smaller composition root
- the remaining Windows app shell in `main(...)` now also routes through
  `run_main_application(...)` in `src/platform_windows/windows_runtime_shell.*`,
  which reduces `src/main.cpp` to a minimal `main`/`WinMain` wrapper
- the retained Win32 main-thread task queue now also lives in `AppRuntime`
  instead of `src/platform_windows/windows_platform_services.cpp`, which
  removes another runtime queue from retained platform-local static state
- prepared-file background work now runs through an `AppRuntime`-owned worker
  queue instead of a retained static worker in `src/app_events.cpp`
- canvas async import/export/save/open background work now also runs through an
  `AppRuntime`-owned worker queue instead of a retained static worker in
  `src/canvas.cpp`
- `App::request_close()` plus the render/UI thread entrypoint wrappers now
  also route through `src/legacy_app_runtime_shell_services.*`, which further
  reduces direct runtime orchestration living on `src/app.cpp` even though the
  owned runtime contract is still centered on `AppRuntime`
- retained `App` composition, task call sites, and platform/runtime entrypoint
  coupling are still not fully reduced behind the runtime contract

Write scope:
- `src/app.h`
- `src/app.cpp`
- `src/app_events.cpp`
- `src/main.cpp`
- new `src/app_runtime_*.*` or retained runtime service files if needed

Read scope:
- `src/app_core/app_thread.h`
- `src/platform_api/platform_services.h`
- render/UI task call sites under `src/*.cpp`

Done when:
- Render and UI task queues are owned by an explicit runtime object or service
  with startup, drain, stop, and thread-affinity APIs.
- `App` composes that runtime instead of exposing static global queue state.
- Platform event code and retained services post work through the runtime
  contract rather than by reaching `App::I` static queues.
- Shutdown behavior remains deterministic and the touched slice reduces
  singleton/thread coupling.

Mini-model packet:
- Keep public behavior and thread ordering unchanged.
- Prefer a small runtime owner over broad task-system redesign.
- Make ownership and shutdown semantics explicit before adding new features.

#### ARC-APP-005 - Replace Detached App Workers With Joinable Or Service-Owned Work

Status: In Progress

Why now:
The biggest app-facing async families have been moved off detached launches,
but retained worker ownership and ad hoc runtime control are still not a safe
modernization foundation.

Current slice:
- app-owned render/UI runtime queues and cloud worker ownership are already
  moving behind owned runtime/service objects
- Windows splash-dialog and HMD renderer worker ownership in `main.cpp` now
  also use `std::jthread` with explicit stop requests instead of raw
  `std::thread`
- Windows VR device ownership in `main.cpp` now also uses `std::unique_ptr`
  instead of a raw `Vive*`
- `LogRemote` worker ownership in `src/log.*` now also uses `std::jthread`
  with explicit stop requests instead of raw `std::thread`
- brush package import/export now use service-owned `std::jthread` workers and
  UI-thread completion handoff
- prepared-file save work and grid lightmap launch now also use service-owned
  workers with explicit UI-thread handoff
- canvas async import/export/save/open and timelapse export now also use owned
  worker queues instead of detached threads
- `src/app_events.cpp` prepared-file worker ownership and `src/canvas.cpp`
  async import/export/save/open worker ownership now also sit behind named
  retained local worker-state helpers instead of bare static worker accessors
- the prepared-file worker and the broader canvas async import/export/save/open
  worker have now both moved into `AppRuntime`, removing the retained static
  workers from `src/app_events.cpp` and `src/canvas.cpp`
- preview background rendering, recording, and the retained
  `NodePanelGrid::bake_uvs()` worker now also use `std::jthread`, but their
  retained loop/control flow is still open
- `App::rec_loop()` now delegates its worker-iteration orchestration into the
  retained recording bridge in `src/legacy_recording_services.cpp`, while
  `App::update()` no longer carries the dead update mutex residue; retained
  recording loop control, readback ownership, and MP4 execution are still open
- `App::update_rec_frames()` now delegates recording label refresh through
  `src/legacy_recording_services.cpp`, but retained recording label lookup,
  encoder-state reads, and MP4 execution still stay on the legacy bridge

Write scope:
- `src/canvas.cpp`
- `src/app_cloud.cpp`
- `src/app_events.cpp`
- `src/legacy_cloud_services.*`
- `src/legacy_brush_package_import_services.*`
- `src/legacy_brush_package_export_services.*`
- `src/legacy_grid_ui_services.*`
- `src/node_dialog_cloud.*`
- `src/node_stroke_preview.*`

Read scope:
- `src/app_core/app_thread.h`
- `src/foundation/task_queue.*`
- `src/legacy_recording_services.*`

Done when:
- Touched worker families are owned by joinable `std::jthread`, a scoped worker
  object, or an injected task service with cancellation/shutdown semantics.
- Worker callbacks do not capture raw retained nodes or `this` across unknown
  lifetime without a checked handle, weak ownership, or explicit owner.
- App shutdown can stop the touched worker family without racing UI/layout or
  renderer destruction.
- Detached `std::thread` count drops materially in app-facing code.

Mini-model packet:
- Start with one coherent worker family, such as cloud or brush package import.
- Do not rewrite all threading at once.
- Preserve the existing UI/progress behavior while changing ownership.

### Bundle 3 - Finish The UI Core Split

Priority: `P0`

Why this bundle is still top priority:
Until generic `Node` and control code leaves `pp_legacy_ui_core`, the UI
architecture remains mostly the old one with a modern overlay/lifetime helper
attached to it.

#### ARC-UI-001 - Move Generic Node And Control Code Out Of `pp_legacy_ui_core`

Status: In Progress

Why now:
`pp_ui_core` has layout, color, node lifetime, and overlay lifetime, but the
generic widget layer still sits in `pp_legacy_ui_core`.

Current slice:
- `Node::load_internal(...)` child XML loading now routes through
  `src/legacy_ui_node_loader.*` instead of staying inline in `src/node.cpp`.
  That trims another coherent generic node-instantiation pocket and makes the
  remaining scene-graph load path easier to isolate, even though ownership has
  not yet moved into `pp_ui_core`.
- 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` instead of staying
  inline in `src/node.cpp`, which materially thins the base scene-graph file
  without changing its public surface.
- `Node::on_event(...)` plus mouse/key capture and release now also live in
  `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.
- `Node::parse_attributes(...)` now also routes through
  `src/legacy_ui_node_attributes.*` instead of staying inline in `src/node.cpp`,
  which trims another coherent XML/Yoga attribute decoding pocket from the base
  scene-graph shell without changing its public surface.
- `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 live in
  `src/legacy_ui_node_tree_services.cpp` instead of staying inline in
  `src/node.cpp`, which materially thins the remaining generic scene-graph
  lifecycle/tree shell without changing the public surface.
- The generic Yoga style/visibility pocket from `Node::SetWidth(...)` through
  `Node::GetRTL()` now also lives in `src/legacy_ui_node_style.*` instead of
  staying inline in `src/node.cpp`, which trims another coherent generic node
  shell pocket without changing the public surface.
- The remaining geometry/state pocket for `Node::SetSize(...)`,
  `Node::SetMinSize(...)`, `Node::SetMaxSize(...)`, and
  `Node::SetPosition(const glm::vec2)` now also lives in
  `src/legacy_ui_node_style.*` instead of staying inline in `src/node.cpp`,
  which materially thins the live base shell by moving the cached size/position
  mutation and redraw signaling out of the node file.
- 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 lives in `src/legacy_ui_node_lifecycle.*`
  instead of staying inline in `src/node.cpp`, which materially thins another
  coherent generic scene-graph shell without changing the public surface.
- `Node::load_internal(...)` now also 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`
  and leaves the remaining live node file thinner.
- The remaining default `Node` event/capture/resize shell now also lives in
  `src/legacy_ui_node_execution.cpp`, while the remaining
  width/height/padding/margin/flex/visibility/geometry wrappers now also live
  in `src/legacy_ui_node_style.*`, which reduces `src/node.cpp` to the final
  attribute/load entrypoints without changing the public surface.

Write scope:
- `src/node.cpp`
- `src/layout.cpp`
- generic `src/node_*` base control files from `PP_LEGACY_UI_CORE_SOURCES`
- `src/ui_core/*`
- `CMakeLists.txt`
- `cmake/PanoPainterSources.cmake`

Read scope:
- `src/node_panel_*`
- `src/node_dialog_*`

Done when:
- Generic controls and base node/layout code are clearly separated from
  PanoPainter-specific panels and dialogs.
- `pp_ui_core` grows as the home of generic widgets and node behavior.
- `pp_panopainter_ui` keeps only app-specific panels, dialogs, canvas, preview,
  and workflow nodes.
- The touched slice removes real file ownership from `pp_legacy_ui_core`, not
  just adds wrappers around it.

Mini-model packet:
- Start with the controls that have no app-specific policy:
  button, checkbox, icon, image, scroll, slider, text, text input.
- Do not mix panel/dialog rewrites into the same slice.
- Prefer target ownership moves over purely internal helper reshuffles.

#### ARC-UI-002 - Make Checked Handles The Default UI Ownership Model

Status: Ready

Why now:
`pp_ui_core` has lifetime and overlay handle helpers, but retained UI code still
mixes raw `Node*`, shared ownership, direct `add_child(...)`, and callback
captures across mutation points.

Current slice:
- `src/node.cpp` child attach/detach/reorder paths now route through named
  local helpers instead of repeating raw mutation logic inline. That improves
  scene-graph mutation clarity, but it does not yet change the ownership model
  or move those paths behind checked handles.

Write scope:
- `src/node.*`
- `src/layout.*`
- `src/legacy_ui_overlay_services.*`
- retained `src/node_dialog_*` and `src/node_panel_*` files touched by a slice
- `src/ui_core/node_lifetime.*`
- `src/ui_core/overlay_lifetime.*`

Read scope:
- existing popup/dialog call sites found with `add_child`, `remove_child`, and
  `on_*` callback captures

Done when:
- New or touched UI surfaces open, close, and dispatch callbacks through checked
  handles or scoped connections by default.
- Raw `Node*` fields and callback parameters are documented or reshaped as
  non-owning views, not lifetime owners.
- Destroy-during-callback and close-during-dispatch behavior is owned by
  `pp_ui_core` rather than each panel/dialog.
- App-specific panels become view/controller shells over safe UI-core
  lifetime primitives.

Mini-model packet:
- Convert one popup/dialog family at a time.
- Do not redesign the UI appearance in this task.
- Prefer deleting raw lifetime assumptions over adding more guard comments.

#### ARC-UI-003 - Split UI Rendering From Scene Graph And App State

Status: Ready

Why now:
The generic node layer still mixes layout, input, rendering, direct app access,
and retained OpenGL resource usage. That blocks both a cleaner UI component and
future renderer backends.

Write scope:
- `src/node.cpp`
- `src/node_canvas.cpp`
- `src/node_stroke_preview.cpp`
- generic control files moved toward `src/ui_core/*`
- `src/renderer_api/*`
- `src/paint_renderer/*`

Read scope:
- `src/font.*`
- `src/shape.*`
- `src/texture.*`
- `src/rtt.*`

Done when:
- Scene graph/layout/input code has a renderer-neutral draw contract.
- Generic controls do not need to know app singleton state or concrete GL
  resource classes.
- App-specific canvas and preview rendering depends on renderer-facing services
  rather than base `Node` internals.
- The touched slice makes `pp_ui_core` more reusable without hiding app policy
  inside it.

Mini-model packet:
- Keep visual behavior unchanged.
- Do not move PanoPainter-specific panel policy into `pp_ui_core`.
- Use renderer-neutral contracts first; backend implementation follows later.

### Bundle 4 - Make The Platform Boundary Real

Priority: `P1`

Why this bundle is not `P0`:
It matters, but moving platform code first will not change the day-to-day shape
of the app as much as reducing `canvas.cpp`, the app shell, and the generic UI
layer.

#### ARC-PLT-001 - Split `pp_platform_api` From Concrete Platform Code

Status: In Progress

Why now:
`pp_platform_api` is supposed to be the SDK-free policy and interface layer,
and while Apple implementation has now moved out, it still compiles concrete
Linux platform sources.

Current slice:
- `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` now link that
  concrete target where needed.
- `pp_platform_api` now also stops compiling
  `src/platform_linux/linux_platform_services.*`.
- Linux concrete platform code now lives in the new `pp_platform_linux`
  target, and `pp_legacy_app` plus `pp_platform_api_tests` now link that
  concrete target where needed.
- The dependency direction is cleaner for Apple and Linux, but the broader
  concrete platform family is still being separated.

Write scope:
- `CMakeLists.txt`
- `src/platform_api/*`
- `src/platform_apple/*`

Read scope:
- `cmake/PanoPainterSources.cmake`
- `src/platform_windows/*`
- `src/platform_linux/*`

Done when:
- `pp_platform_api` contains only platform-neutral interfaces, policies, and
  shared helpers.
- Apple and Linux implementation files are built by concrete platform targets
  instead of the API target.
- The dependency direction is obvious from CMake without reading debt notes.

Mini-model packet:
- Start in `CMakeLists.txt` around `pp_platform_api`.
- Keep the change structural; do not broaden into new feature work.
- Preserve current Apple service entrypoints while moving ownership.

#### ARC-PLT-002 - Remove `App::I` Reach From Apple And Linux Services

Status: In Progress

Why now:
The current Apple and Linux service files still call into the app singleton,
which means the platform layer is not a platform layer yet.

Current slice:
- Linux FPS title updates now route through an injected callback installed from
  `App::set_platform_services()`
- `platform_apple` clipboard, display/share, cursor, and save-ui-state calls
  now route through injected Apple bridge callbacks instead of `App::I`
- `LegacyPlatformServices::prepare_storage_paths()` now routes Apple path
  preparation through a narrow local helper instead of reading `App::I`
  directly in that method body
- iOS virtual-keyboard visibility and prepared-file save handoff now also route
  through explicit Apple bridge callbacks instead of direct `App::I` calls in
  `LegacyPlatformServices`
- Apple render-context acquire/release/present hooks and iOS
  `bind_main_render_target()` now also route through explicit Apple bridge
  callbacks instead of direct `App::I` calls in `LegacyPlatformServices`
- Apple crash-test, app-close, and iOS SonarPen hooks now also route through
  explicit Apple bridge callbacks instead of direct `App::I` calls in
  `LegacyPlatformServices`
- retained Apple ObjC handles plus storage paths now live in one local
  `platform_legacy` helper, and the iOS SonarPen bridge now starts through
  that retained Apple state instead of reading `App::I` inside the bridge body
- Linux/Web GLFW render-context acquire/present hooks and Linux app-close now
  also route through retained local GLFW callback hooks instead of direct
  method-body `App::I` access in `LegacyPlatformServices`
- retained GLFW window hooks and the non-Linux fallback storage-path return now
  also route through retained local state helpers instead of reading `App::I`
  directly in those method bodies
- retained storage-path state now also lives in
  `src/platform_legacy/legacy_platform_state.*` instead of staying inline in
  `src/platform_legacy/legacy_platform_services.cpp`
- `active_legacy_storage_paths()` now consumes storage roots seeded explicitly
  through `set_legacy_storage_paths(...)` from app startup plus the iOS,
  macOS, and Android entrypoints instead of lazily snapshotting `App::I`
- retained GLFW window hooks/state and retained Apple UI/app handle state now
  also live in `src/platform_legacy/legacy_platform_state.*`, and
  `src/platform_legacy/legacy_platform_services.cpp` now consumes those
  snapshots without direct `App::I` reads in the touched paths
- the retained Apple document bridge now also lives behind
  `active_legacy_apple_document_platform_services()` in
  `src/platform_legacy/legacy_platform_state.*` instead of being built inline
  inside `src/platform_legacy/legacy_platform_services.cpp`, and that retained
  service resets when seeded Apple handles change
- retained Apple callback injection and broader `platform_legacy` singleton
  reach are still open
- the `platform_legacy`-mirrored Apple/GLFW handle cluster is now seeded
  explicitly from the iOS, macOS, Linux, and WebGL entrypoints through
  `src/platform_legacy/legacy_platform_state.*` instead of being copied out
  of `App`
- 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`, which leaves the live entry file as a thinner
  startup/runtime dispatcher.

Write scope:
- `src/platform_apple/*`
- `src/platform_linux/*`
- `src/app_events.cpp`
- `src/app.h`

Read scope:
- `src/platform_api/platform_services.h`
- `src/platform_legacy/legacy_platform_services.*`

Done when:
- `src/platform_apple/*` and `src/platform_linux/*` no longer call `App::I`.
- The app injects the minimum callbacks or bridge state those services need.
- Platform files stop depending on app-global state for clipboard, sharing, FPS
  title updates, or native UI saves.

Mini-model packet:
- Keep the interface small. Prefer injected callbacks/bridges over passing the
  whole `App`.
- Do not rewrite Windows in the same slice.

#### ARC-PLT-003 - Remove App-Owned Cross-Platform Handle Storage

Status: In Progress

Why now:
`src/platform_legacy/legacy_platform_services.cpp` and `src/app.h` still keep
platform-handle state on `App`, which blocks a real `pp_platform_*` shell split.

Current 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` and
  `src/platform_apple/apple_platform_services.*`, and the macOS/iOS
  entrypoints now seed that state through `pp::platform::apple`.
- `src/platform_legacy/legacy_platform_services.cpp` now consumes the
  Apple-owned retained provider/factory through `pp::platform::apple::...`
  call-throughs instead of constructing or caching Apple retained bridge
  state in the legacy layer.
- The Win32 stylus and pointer-input path no longer reaches
  `WacomTablet::I` directly in
  `src/platform_windows/windows_window_shell.cpp` or
  `src/platform_windows/windows_stylus_input.cpp`;
  `src/platform_windows/windows_runtime_shell.*` now binds the active tablet
  explicitly and the touched consumers read it through that runtime-shell
  binding instead of the process-global singleton.
- 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 in
  `src/platform_windows/windows_bootstrap_helpers.*`, and
  `src/app_events.cpp` now dispatches platform services through the
  `App` instance it is operating on instead of a global 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
  in `src/platform_legacy/legacy_platform_services.cpp`, which trims another
  retained fallback/state pocket out of the legacy platform shell.
- The touched Win32 shell path no longer reaches `App::I` directly for
  window-procedure dispatch, stylus state updates, lifecycle shutdown, or VR
  callback/thread setup; `src/platform_windows/windows_runtime_shell.*` now
  binds the active `App*` explicitly and clears that binding on shutdown.
- Windows VR session snapshot ownership no longer lives on `App`.
- `VrSessionSnapshot` now lives behind
  `src/platform_windows/windows_vr_shell.h` and
  `read_platform_vr_session_snapshot()` in
  `src/platform_windows/windows_platform_services.*`, with app-side reads now
  routed through `App::vr_session_snapshot()`.
- The `platform_legacy`-mirrored Apple/GLFW handle cluster also 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.*`.
- `App` also 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.
- Win32 main-thread task dispatch also no longer reaches `AppRuntime` through
  `App::I`; `src/platform_windows/windows_runtime_shell.*` now binds the
  active runtime explicitly and
  `src/platform_windows/windows_platform_services.cpp` consumes that bound
  runtime for enqueue/drain.
- `App` still owns broader retained legacy platform state, so this remains a
  live ownership task.

Write scope:
- `src/platform_legacy/legacy_platform_services.*`
- `src/app.h`
- `src/app_events.cpp`
- `src/platform_windows/*`

Read scope:
- `src/main.cpp`
- Apple/Android/Web/Linux entrypoint files only as needed

Done when:
- `App` no longer owns platform-specific handle fields that belong to shells.
- The legacy platform adapter becomes thin composition or disappears for the
  touched path.
- Platform setup state lives with the relevant `pp_platform_*` implementation.

Mini-model packet:
- Keep this slice about state ownership, not feature behavior.
- Prefer moving state to shell-local structs or service singletons owned by the
  platform target.

### Bundle 5 - Retire The Thick Workflow Bridges

Priority: `P2`

Why this bundle is later:
These bridges still matter, but many recent slices spent too much effort
polishing adapters without changing the bulk shape of the live app. This bundle
stays active only after the main hotspots are moving.

#### ARC-WKF-001 - Thin Document Open/Save/Session Bridges To Pure Adapters

Status: Ready

Why now:
The document session/open/save planners exist, but the live bridges still own a
lot of retained dialog, metadata, title, and save execution behavior.

Write scope:
- `src/legacy_document_open_services.*`
- `src/legacy_document_session_services.*`
- `src/legacy_document_export_services.*`
- `src/legacy_history_services.*`

Read scope:
- `src/app_core/document_route.h`
- `src/app_core/document_session.h`
- `src/app_core/document_export.h`

Done when:
- The remaining bridge files are thin adapters from planner outputs to retained
  execution.
- Save/open/session flows stop mutating app/document/UI state inline across
  multiple bridge layers.
- Title updates, history clearing, overwrite prompts, and save routing are each
  owned in one obvious place.

Mini-model packet:
- Preserve current planner contracts.
- Favor one adapter per workflow family over catch-all helper growth.

#### ARC-WKF-002 - Split Cloud And Brush Package Work Out Of Retained UI Nodes

Status: Ready

Why now:
Cloud browse/download/upload and brush package import/export still close over
retained nodes, worker threads, and direct UI ownership.

Current slice:
- `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 a coherent retained brush-workflow
  pocket from the live UI node even though cloud and package-worker ownership
  still remain separate follow-up work.
- `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.
- `NodePanelBrushPreset` save/restore plus PPBR/ABR import/export 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 even though the broader cloud/package worker
  split still remains follow-up work.
- `NodePanelBrushPreset` init/menu wiring, click handling, item construction,
  and added-state update now also live in
  `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 even though cloud/package worker ownership remains the
  follow-up.
- `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 trims the remaining inline popup tail
  from the live brush panel file.
- 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 workflow
  pocket while preserving the live list-plan dispatch path.
- `NodePanelBrush` brush texture panel init, selection dispatch,
  popup-close event handling, restore-failure prompt flow, and added-state
  reset now also live in `src/legacy_brush_panel_ui.*` instead of staying
  inline in `src/node_panel_brush.cpp`, which trims another retained brush UI
  workflow pocket while preserving the live node as a thinner controller
  surface.

Write scope:
- `src/legacy_cloud_services.*`
- `src/node_dialog_cloud.*`
- `src/legacy_brush_package_import_services.*`
- `src/legacy_brush_package_export_services.*`
- `src/node_panel_brush.cpp`

Read scope:
- `src/app_core/document_cloud.h`
- `src/app_core/brush_package_import.h`
- `src/app_core/brush_package_export.h`
- `src/assets/brush_package.*`

Done when:
- Network transfer execution, thumbnail loading, and brush package worker
  ownership are isolated behind named services.
- Retained nodes become view/controller shells instead of workflow owners.
- Cloud and brush package code no longer need to be understood through panel or
  dialog internals first.

Mini-model packet:
- Split worker ownership from UI ownership first.
- Do not try to redesign cloud UX or brush preset UX in the same slice.

## Deferred On Purpose

- Vulkan and Metal lab work
- package-only and automation-only cleanup
- scorekeeping tasks that do not move app architecture

These remain in history only until the app shell, platform split, UI split, and
canvas/render split are materially thinner.
