# PanoPainter Modernization Roadmap

Status: live
Last updated: 2026-06-15

This is the living roadmap for modernizing PanoPainter into independently
testable C++23 components while retaining all existing functionality. Keep this
file current as phases are implemented. Do not let shortcuts, skipped platforms,
or temporary adapters live only in chat history.

## How To Keep This Roadmap Live

- Update the phase status before and after each implementation pass.
- Use `docs/modernization/tasks.md` for measurable execution tasks and
  percentage/progress claims. The phase prose explains direction; the task
  tracker is the source of truth for score changes.
- When a shortcut is introduced, add it to the debt log section in this file
  until `docs/modernization/debt.md` exists, then move debt entries there.
- When a major architectural decision is made, add an ADR under `docs/adr/`
  once that directory exists.
- Every phase must preserve old behavior unless the roadmap explicitly says
  otherwise.
- Each phase must leave the repo in a buildable and testable state.
- Do not add stubs without a debt entry, validation command, and removal
  condition.

## Locked Decisions

- Graphics path: keep OpenGL working first; add Vulkan and Metal after the
  renderer boundary exists.
- Required platforms at phase gates: Windows desktop/AppX, macOS, iOS,
  Android standard, Quest, Focus/Wave, Linux, and WebGL.
- Dependency policy: use vcpkg where reliable; keep SDK, patched, or
  vendor-only dependencies with documented reasons.
- Test stack: Catch2, golden/approval tests, and fuzz/property tests where
  useful.
- Automation: local reproducible matrix first; hosted CI can be added later.
- Documentation: ADRs, debt log, and this living roadmap.
- "vkpkg" in older notes means `vcpkg`.
- Target C++ standard: C++23.
- Initial Windows CMake generator target: Visual Studio 2026 when available.

## Phase Status

| Phase | Name | Status | Gate |
| --- | --- | --- | --- |
| 0 | Inventory, Safety Rails, And Memory | Complete | No behavior changes; old builds still work |
| 1 | Unified CMake Skeleton | In progress | Root CMake builds the Windows app and owns the source list |
| 2 | Toolchain, Diagnostics, And Dependencies | In progress | Strict desktop library builds compile cleanly |
| 3 | Test Harness And Agent-Ready Automation | In progress | `ctest --preset desktop-fast` runs headlessly |
| 4 | Component Split Without Behavior Change | Started | Each extracted target builds and tests |
| 5 | Renderer Boundary And OpenGL Parity | Started | OpenGL output matches golden readbacks |
| 6 | Platform Alignment | Started | Every supported platform has named validation |
| 7 | Hardening, Coverage, And Breaking-Point Tests | Started | Each component has edge/failure tests |
| 8 | Future Backend Readiness | Not started | Vulkan/Metal lab targets remain non-default |

## Measurable Task Tracking

Use `docs/modernization/tasks.md` before starting implementation work. It
contains the current 100-point modernization scorecard, task states, ready
queue, blocked queue, validation commands, and completion rules. Do not move the
percentage for a narrowed adapter or added planner unless a task in that file is
marked `Done` with validation and a debt/roadmap update.

The 2026-06-14 planning audit added measurable blockers for roadmap
finalization: component boundary self-tests (`ARCH-001`), renderer backend
contract freeze and conformance (`RND-007`, `RND-008`), opt-in Vulkan/Metal lab
targets (`RND-009`, `RND-010`), UI thread-affinity and checked-handle migration
(`UI-001`, `UI-002`), render/UI queue race coverage (`APP-001`), package gates
for backend/UI work (`PLT-010`), and historical task cleanup (`AUD-001`), which
normalized the historical tracker hygiene.
Recent 2026-06-15 app-dialog lifetime cleanup now routes app-owned
progress/message/input overlays through checked handle registration in
`src/legacy_ui_overlay_services.*` before they fall back to retained dialog
node close helpers, narrowing another app-owned `DEBT-0058`/`DEBT-0063`
surface without changing dialog plans or captions.
The same checked-overlay seam now also owns main-toolbar settings dialog
opening from `src/legacy_app_shell_services.cpp`, removing another raw
app-owned dialog insertion path while leaving the remaining retained dialog
families debt-tracked.
The stale `make_canvas_draw_merge_branch_dispatch(...)` forward declaration in
`src/canvas.cpp` now matches the existing 7-argument implementation, shrinking
the remaining draw-merge helper API mismatch while `STR-016` stays blocked.
The remaining `Canvas::draw_merge()` inline-default comment in `src/canvas.cpp`
now matches the header's `SIXPLETTE(true)` default, so the draw-merge mismatch
is comment-only and aligned while `STR-016` stays blocked.
The cloud browser dialog now also opens through that seam from
`src/legacy_cloud_services.cpp`, so the remaining cloud modernization debt is
now concentrated in retained background worker threads, transfer helpers,
project-open refresh, and dialog-internal legacy behavior instead of raw root
insertion.
Retained upload/download CURL setup, TLS-bypass policy consumption, and
progress callback dispatch now also live in `src/legacy_cloud_services.*`
instead of `App::upload` and `App::download`, leaving the remaining cloud debt
focused on save-before-upload execution, upload form/response handling,
prompt/progress lifetime, OpenGL context guarding, downloaded-project open,
layer refresh, and action-history reset.
The retained upload-form setup is now also isolated behind a dedicated helper
in `src/legacy_cloud_services.cpp`, so the remaining cloud debt is further
concentrated on save-before-upload execution, response/error handling,
prompt/progress lifetime, OpenGL context guarding, downloaded-project open,
layer refresh, and action-history reset.
The retained upload post-transfer response/error handling is now also isolated
behind a dedicated helper in `src/legacy_cloud_services.cpp`, so the remaining
cloud debt is further concentrated on save-before-upload execution,
prompt/progress lifetime, OpenGL context guarding, downloaded-project open,
layer refresh, and action-history reset.
Retained save-before-upload execution now also routes through
`src/legacy_document_session_services.*` instead of
`src/legacy_cloud_services.cpp` calling `Canvas::I->project_save_thread(...)`
directly, so the remaining cloud debt is further concentrated on
prompt/progress lifetime, OpenGL context guarding, downloaded-project open,
layer refresh, and action-history reset.
Retained cloud downloaded-project open camera reset and layer-clear setup in
`execute_legacy_downloaded_project_open()` now also routes through a focused
helper in `src/legacy_document_open_services.*`, so the remaining cloud bridge
debt is further concentrated on prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
Retained cloud downloaded-project open, layer refresh, and action-history
reset in `execute_legacy_downloaded_project_open()` now also route through a
focused helper in `src/legacy_document_open_services.*`, so the remaining
cloud bridge debt is further concentrated on prompt/progress lifetime, OpenGL
context guarding, and the still-retained transfer-thread execution model.
Retained cloud downloaded-project post-open reconciliation now also routes
through `src/legacy_document_open_services.*` instead of living inline in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is further
concentrated on prompt/progress lifetime, OpenGL context guarding,
`NodeDialogCloud`, and transfer-thread execution.
The initial cloud-browser loading placeholder setup in
`NodeDialogCloud::load_thumbs_thread()` now also routes through a focused
helper in `src/node_dialog_cloud.*`, so the remaining cloud bridge debt is
further concentrated on prompt/progress lifetime, OpenGL context guarding,
`NodeDialogCloud` network/thumbnail execution, and transfer-thread execution.
The cloud-browser file-list request/response handling in
`NodeDialogCloud::load_thumbs_thread()` now also routes through a focused
helper in `src/node_dialog_cloud.*`, so the remaining cloud bridge debt is
further concentrated on prompt/progress lifetime, OpenGL context guarding,
`NodeDialogCloud` thumbnail execution, and transfer-thread execution.
The cloud-browser slot creation and selection wiring in
`NodeDialogCloud::load_thumbs_thread()` now also route through a focused
helper in `src/node_dialog_cloud.*`, so the remaining cloud bridge debt is
further concentrated on prompt/progress lifetime, OpenGL context guarding,
`NodeDialogCloud` thumbnail execution, and transfer-thread execution.
The retained cloud-browser thumbnail fetch, decode, and texture-apply path in
`NodeDialogCloud::load_thumbs_thread()` now also routes through a focused
helper in `src/node_dialog_cloud.cpp`, so the remaining cloud bridge debt is
further concentrated on prompt/progress lifetime, OpenGL context guarding,
and transfer-thread execution.
The retained cloud-download detached worker body in
`LegacyCloudServices::start_download()` now also routes through a focused
helper in `src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt
is further concentrated on prompt/progress lifetime, OpenGL context guarding,
and the still-retained transfer-thread execution model.
The retained cloud-publish worker body in
`LegacyCloudServices::prompt_publish()` now also routes through a focused
helper in `src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt
is further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud-publish prompt button wiring in
`LegacyCloudServices::prompt_publish()` now also routes through a focused
helper in `src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt
is further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud-browser OK-button wiring in
`LegacyCloudServices::show_browser()` now also routes through a focused helper
in `src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud bulk-upload progress lifetime wiring in
`LegacyCloudServices::begin_bulk_upload()` and `end_bulk_upload()` now also
routes through focused helpers in `src/legacy_cloud_services.cpp`, so the
remaining cloud bridge debt is further concentrated on retained
prompt/progress lifetime, OpenGL context guarding, and the still-retained
transfer-thread execution model.
The retained cloud bulk-upload per-file execution loop in
`LegacyCloudServices::upload_all_bulk_files()` now also routes through a
focused helper in `src/legacy_cloud_services.cpp`, so the remaining cloud
bridge debt is further concentrated on retained prompt/progress lifetime,
OpenGL context guarding, and the still-retained transfer-thread execution
model.
The retained cloud save-required warning path in
`LegacyCloudServices::show_save_required_warning()` now also routes through a
focused helper in `src/legacy_cloud_services.cpp`, so the remaining cloud
bridge debt is further concentrated on retained prompt/progress lifetime,
OpenGL context guarding, and the still-retained transfer-thread execution
model.
The retained cloud publish prompt path in
`LegacyCloudServices::prompt_publish()` now also routes through a focused
helper in `src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt
is further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud browser dialog creation path in
`LegacyCloudServices::show_browser()` now also routes through a focused helper
in `src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud download thread launch in
`LegacyCloudServices::start_download()` now also routes through a focused
helper in `src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt
is further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud download progress-dialog creation path in
`execute_cloud_download_thread()` now also routes through a focused helper in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud download URL construction and progress-callback wiring in
`execute_cloud_download_thread()` now also routes through a focused helper in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud download post-transfer open/close sequence in
`execute_cloud_download_thread()` now also routes through a focused helper in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud download flow in
`execute_cloud_download_thread()` now also routes through a focused helper in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud publish transfer-and-success body in
`execute_cloud_publish_worker()` now also routes through a focused helper in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud publish detached-thread launch in
`show_cloud_publish_prompt()` now also routes through a focused helper in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.
The retained cloud publish prompt setup in
`show_cloud_publish_prompt()` now also routes through a focused helper in
`src/legacy_cloud_services.cpp`, so the remaining cloud bridge debt is
further concentrated on retained prompt/progress lifetime, OpenGL context
guarding, and the still-retained transfer-thread execution model.

Recent 2026-06-13 retained preview reductions continue to narrow DEBT-0036:
`NodeStrokePreview::draw_stroke_immediate()` now also routes
feedback/material/composite planning and stroke-shader uniform assembly through
`plan_legacy_node_stroke_preview_pass_orchestration(...)`, leaving the preview
node with a smaller live-GL callback surface around pass execution.
`Canvas::draw_merge()` now also routes its per-plane merge-target clear,
blend-state gating, and optional checkerboard prepass through
`execute_legacy_canvas_draw_merge_plane_setup(...)`, reducing another retained
plane-setup branch.
`Canvas::draw_merge()` also now routes its standard per-layer texture-alpha pass
through `execute_legacy_canvas_draw_merge_layer_texture(...)`, reducing another
piece of retained layer-composite sequencing.

## Target Component Architecture

The refactor should move toward one-way dependencies:

```text
pp_foundation
  -> pp_assets
  -> pp_paint
  -> pp_document
  -> pp_renderer_api
  -> pp_renderer_gl
  -> pp_paint_renderer
  -> pp_ui_core
  -> pp_panopainter_ui
  -> pp_platform_*
  -> panopainter_app
```

Intended responsibilities:

- `pp_foundation`: logging facade, math/util helpers, events, task queues,
  binary streams.
- `pp_assets`: `Asset`, `Image`, `Settings`, serialization, ABR, PPBR, and PPI
  helpers.
- `pp_paint`: pure `Brush`, `Stroke`, stroke sampling, and CPU reference blend
  math.
- `pp_document`: canvas document model, layers, animation frames, and undo/redo
  model.
- `pp_renderer_api`: renderer-neutral interfaces for textures, render targets,
  shaders, meshes, readback, frame capture, and tracing.
- `pp_renderer_gl`: current OpenGL implementation behind renderer interfaces.
- `pp_paint_renderer`: stroke rasterization, layer compositing, cube/equirect
  export using `pp_renderer_api`.
- `pp_ui_core`: `Node`, layout, generic controls, text/image primitives.
- `pp_panopainter_ui`: panels, dialogs, `NodeCanvas`, and app-specific
  workflows.
- `pp_platform_api`: SDK-free service interfaces for clipboard, cursor,
  virtual keyboard, file pickers, sharing, and future platform automation.
- `pp_platform_*`: Windows, macOS/iOS, Android, Linux, and WebGL shells.
- `panopainter_app`: composition root only.

Rules:

- Component headers must not include platform SDK or graphics API headers unless
  the component name includes that backend or platform.
- Pure libraries must build and test without a window, GL context, network,
  tablet, VR headset, or filesystem outside test temp directories.
- Public APIs should return explicit status/result objects. PanoPainter app
  code keeps exceptions disabled unless isolated SDK wrappers require them.
- Singleton access should be replaced at component boundaries with context or
  service objects. Temporary facade shims require debt entries.

## Phase 0: Inventory, Safety Rails, And Memory

Status: complete on 2026-05-31. Created this roadmap,
`docs/modernization/debt.md`, `docs/modernization/capability-map.md`,
`docs/modernization/build-inventory.md`, and ADR 0001.

Goal: create durable project memory and prevent silent shortcuts before large
refactors begin.

Implementation tasks:

- Add `docs/modernization/roadmap.md`, `docs/modernization/debt.md`, and
  `docs/adr/`.
- Add a shortcut rule: every temporary adapter, fallback, skipped platform, or
  retained vendored dependency must have owner, reason, validation command, and
  removal condition.
- Generate a current capability map covering:
  - project open/save and PPI compatibility
  - image import/export and thumbnails
  - brush presets, ABR import, PPBR export/import
  - layers, blend modes, alpha lock, selection mask
  - animation frames and MP4/timelapse recording
  - VR, tablet, touch, mouse, keyboard, gestures
  - cloud upload/download/browse
  - UI dialogs, panels, layout XML, settings
  - Windows/AppX, macOS, iOS, Android standard, Quest, Focus/Wave, Linux, WebGL
- Record current build commands and known platform prerequisites.

Gate:

- No behavior changes.
- Existing Visual Studio, platform CMake, Gradle, Apple, Linux, and WebGL paths
  are not removed.

## Phase 1: Unified CMake Skeleton

Goal: make CMake the canonical source list without breaking existing projects.

Status: in progress. Root `CMakeLists.txt`, `CMakePresets.json`, and project
option targets exist. The Windows desktop app builds through CMake as
`PanoPainter`; the raw Visual Studio solution/project files were removed on
2026-05-31 by user decision. The root CMake Windows app graph now includes a
`panopainter_app` composition target and `pp_platform_windows` shell target so
`PanoPainter` is only the executable/resource wrapper; Windows and vendor link
dependencies now belong to the platform shell target, and Windows runtime
payload deployment lives behind `cmake/PanoPainterRuntime.cmake`.
`pp_legacy_vendor` now owns the retained third-party source bundle as an
interim containment boundary until vcpkg, SDK imports, or documented permanent
vendoring decisions replace each dependency. `pp_legacy_engine` now contains
the retained legacy tablet, video, and low-level runtime sources as an interim
containment boundary while pure replacement components take over.
`pp_legacy_assets_io` now owns retained ABR, asset/file, binary stream, image,
serializer, and settings implementations until `pp_assets` fully replaces those
paths in the app. `pp_legacy_paint_document` now owns retained action, bezier,
brush, canvas, canvas-layer, and event implementations until `pp_paint` and
`pp_document` fully replace those paths in the app.
`pp_legacy_renderer_gl` now owns the retained OpenGL runtime implementations
for `Font`, `RTT`, `Shader`, `Shape`, `Texture2D`, `TextureCube`, `Sampler`,
and `TextureManager` as an object-library boundary folded into the retained
engine until the renderer API inversion is complete. `pp_legacy_ui_core` now
owns retained base `Node`, layout, text, image, input, popup, slider, scroll,
and settings UI controls as an object-library boundary folded into the legacy
app adapter until those paths are replaced by `pp_ui_core` and app-specific UI
targets.
`pp_app_core` now owns tested app-level document-open routing for project
files, ABR imports, and PPBR imports without UI, filesystem, platform, or
renderer dependencies; `App::open_document` and `pano_cli classify-open`
consume this route contract. It also owns tested session decisions for
project-open, app-close, save, save-as, and save-version flows;
`App::open_document`, `App::request_close`, file-menu save actions,
`NodeCanvas` save hotkeys, and `pano_cli simulate-app-session` consume those
contracts while legacy canvas/project loading remains in place.
`pp_app_core` also owns tested app preference plans for UI scale/font scale,
scale option selection, viewport scale, RTL layout direction, timelapse
recording toggles, VR mode start/stop, VR controller enablement, and canvas cursor mode;
the live tools/options menu and `pano_cli plan-app-preferences` consume those
contracts. Options-menu preference execution now dispatches through
`AppPreferenceServices` and `src/legacy_app_preference_services.*` before
legacy widgets, settings persistence, recording toggles, and canvas cursor
updates continue.
It also owns tested startup plans for run-counter increments, preference-save
intent, auto-timelapse startup, stored VR-controller state, license-warning
visibility, and main startup resource sequencing for shader, asset, layout,
title, and UI render-target setup. `App::init` now plans those decisions before
heavy initialization, executes run-counter persistence through
`src/legacy_app_startup_services.*` before resource setup, dispatches the
resource sequence through the same bridge, and executes runtime startup side
effects after the UI layout and main render target exist.
It also owns tested app status/display plans for document title text,
resolution mapping/labels, DPI text, history-memory text, and recording-frame
status text, plus renderer diagnostic indicator labels for framebuffer fetch
and floating-point render targets; `App::title_update`,
`App::update_memory_usage`, `App::update_rec_frames`, resolution helpers,
`App::initLayout`, and `pano_cli plan-app-status` consume those contracts while
legacy UI nodes still render the strings and status lights.
App-level progress, message, and input dialog metadata now also lives in
`pp_app_core` through `plan_app_progress_dialog`,
`plan_app_message_dialog`, and `plan_app_input_dialog`; `App::show_progress`,
`App::message_box`, `App::input_box`, and `pano_cli plan-app-dialog` consume
those plans before `src/legacy_app_dialog_services.*` creates retained
`NodeProgressBar`, `NodeMessageBox`, and `NodeInputBox` instances. Legacy
dialog node lifetime/layout ownership remains tracked under `DEBT-0058`.
Frame-level app decisions for the initial surface size, redraw/animation update
gating, layout ticking, resize render-target recreation, canvas-stroke drawing,
VR UI drawing, main UI drawing, UI observer clipping/on-screen transition/scissor
projection, and redraw reset now live in `pp_app_core`;
`App::create`, `App::tick`, `App::resize`, `App::update`, `App::draw`, and
`pano_cli plan-app-frame` consume those plans while retained layout traversal,
render-target recreation, `Node` parent walking, on-screen callbacks, and
OpenGL/UI drawing stay in the legacy app.
App input dispatch decisions for pointer coordinate normalization, mouse
designer-first routing, gesture midpoint/delta math, touch/key main-layout
routing, VR spacebar camera-sync intent, UI visibility toggling, and stylus
touch-lock attachment now live in `pp_app_core`; `App::mouse_*`,
`App::gesture_*`, `App::touch_tap`, `App::key_*`, `App::toggle_ui`,
`App::set_stylus`, and `pano_cli plan-app-input` consume those plans while
retained event objects, child-node mutation, and legacy `Node` dispatch stay
in the app shell.
App thread orchestration decisions for render/UI task dispatch, unique queued
task replacement, queue draining, render-context wrapping, async redraw
notification, UI tick redraw scheduling, UI-loop timer/report/reload cadence,
and thread start/stop intents now live in `pp_app_core`; `App::render_task*`,
`App::ui_task*`, `App::async_redraw`, `App::render_thread_*`,
`App::ui_thread_*`, and `pano_cli plan-app-thread` consume those plans while
retained `std::thread`, condition-variable, OpenGL context, live reload, and
task execution remain in the app shell.
Shutdown lifecycle staging for UI-state save, stroke-preview renderer shutdown,
recording stop, texture/shader invalidation, layout unload, render-target
destruction, panel-node release, and quick-mode cleanup now lives in
`pp_app_core`; `App::terminate` and `pano_cli plan-app-shutdown` consume that
plan while retained cleanup execution stays in the legacy app.
Command-line panorama conversion planning for renderer-state setup, temporary
canvas allocation, project open, and equirectangular export now lives in
`pp_app_core`; `App::cmd_convert` and `pano_cli plan-command-convert` consume
that sequence while retained OpenGL state dispatch and legacy `Canvas`
open/export execution stay in the legacy app.
`panopainter_app` is now a real static target that owns app orchestration
sources, app version metadata, and version-header generation.
`pp_panopainter_ui` now owns app-specific modal, dialog, panel, canvas,
viewport, color-picker, stroke-preview, and tool UI workflow nodes outside
`pp_legacy_app`; base `Node` controls and layout plumbing remain in the legacy
target until the UI core/app UI boundary is tightened. Android arm64 now
configures and builds headless
foundation/tool targets through the root CMake/NDK path. Non-Windows platform
app/package files remain during Phase 6 alignment.

Implementation tasks:

- Add root `CMakeLists.txt` and shared CMake modules under `cmake/`.
- Add `CMakePresets.json` with at least:
  - `windows-vs2026-x64`
  - `windows-clangcl-asan`
  - `linux-clang`
  - `android-arm64`
  - `android-x64`
  - `emscripten`
  - `macos`
  - `ios-device`
  - `ios-simulator`
- Keep Android CMake, Linux CMake, WebGL CMake, Apple project files, and AppX
  packaging during the transition until each consumes shared component targets.
- Move version generation into a CMake custom command using
  `scripts/pre-build.py`.
- Fix `scripts/pre-build.py` only if required to avoid unnecessary rewrites or
  missing-tag failures.
- Add CMake options:
  - `PP_BUILD_APP`
  - `PP_BUILD_TESTS`
  - `PP_BUILD_TOOLS`
  - `PP_ENABLE_OPENGL`
  - `PP_ENABLE_VULKAN_EXPERIMENTAL=OFF`
  - `PP_ENABLE_VR`
  - `PP_ENABLE_CLOUD`
  - `PP_ENABLE_VIDEO`
- Define source-list helper targets so per-platform source duplication can be
  reduced incrementally.

Gate:

- Windows desktop app builds through CMake.
- New CMake can configure on Windows.
- Source list differences are understood and documented.
- Non-Windows platform migration is debt-tracked until Phase 6.

## Phase 2: Toolchain, Diagnostics, And Dependencies

Goal: turn the build into an error-finding system before deep refactors.

Status: in progress. Initial warning/sanitizer option targets, `vcpkg.json`,
a validated Windows headless vcpkg preset, `pp_ui_core` support for vcpkg
tinyxml2 on that preset, and a headless `panopainter_validate_shaders` target
exist. `windows-clangcl-asan` now configures as a headless Ninja/clang-cl ASan
preset and uses the release MSVC runtime required by clang-cl ASan, but local
ASan builds are blocked by DEBT-0014 until Clang and the selected MSVC STL are
compatible. Dependency migration is not complete until remaining component
dependencies and mobile/Apple triplets are validated. Root CMake now also
exposes `panopainter_platform_build_vcpkg_ui_core`, a focused automation target
that resolves `VCPKG_ROOT` through the platform-build wrapper and validates the
vcpkg-backed `pp_ui_core`/tinyxml2 XML test boundary from the CMake target
graph.

Implementation tasks:

- Set C++23 through target features, not raw compiler flags.
- Add warning profiles:
  - MSVC: `/W4 /permissive- /Zc:__cplusplus /Zc:preprocessor`, with
    `C4100` muted temporarily under `DEBT-0019`.
  - Optional MSVC analysis preset: `/analyze`.
  - Clang/GCC: `-Wall -Wextra -Wpedantic -Wconversion -Wshadow
    -Wnull-dereference`, with `-Wunused-parameter` muted temporarily under
    `DEBT-0019`.
- Keep exceptions disabled for PanoPainter targets, except isolated SDK wrapper
  targets when unavoidable.
- Add sanitizer presets:
  - Clang/GCC ASan and UBSan for headless libraries.
  - MSVC ASan where supported.
  - TSan only for pure/headless targets.
- Add tooling hooks:
  - `clang-tidy`
  - `cppcheck`
  - shader validation or compile checks
  - CTest dashboard output
- Add `vcpkg.json`.
- Move reliable dependencies to vcpkg first:
  - `fmt`
  - `glm`
  - `tinyxml2`
  - `stb`
  - `curl`
  - `sqlite3`
  - `glad`
  - `Catch2`
- Keep vendored until proven:
  - OpenVR only as the temporary desktop compatibility fallback while OpenXR is
    introduced behind `pp_platform_vr`
  - OVR/Wave SDKs
  - Wacom WinTab
  - AppCenter
  - openh264
  - mp4v2
  - libyuv
  - patched or SDK-specific libraries

Gate:

- Desktop library targets compile with strict diagnostics.
- New warnings caused by refactor are fixed or locally justified.
- Any global warning suppression must have an open debt entry, validation
  command, and removal condition.

## Phase 3: Test Harness And Agent-Ready Automation

Goal: make each component reachable by automated tools and future agents.

Status: in progress. `tests/` exists, `desktop-fast`, `fuzz`, and `stress`
CTest presets run headlessly, and
PowerShell/bash wrappers exist for
configure/build/test/analyze/platform-build/package-smoke. `pano_cli` exists
with JSON automation commands for app document-open routing, app session
dirty-state and save decisions, creating a `pp_document` model, metadata-only
PPI project loading, and inspecting image signatures, PPI headers, and layout
XML; full document/app integration is debt-tracked as DEBT-0010 and full PPI
body parsing is debt-tracked as DEBT-0013. Agent code navigation now includes
`scripts/dev/clangd_nav.py` with symbol/detail/path regex filters and a
`panopainter_clangd_nav_regex_self_test` CTest so broad symbol-family searches
can be validated before they guide refactors. Agents must use the
`panopainter-code-navigation` skill before broad text search whenever C++
symbol identity, generated-style symbol families, declarations/definitions,
override groups, or platform/backend path slices are the real question.

Implementation tasks:

- Add `tests/` with one executable per component.
- Register CTest labels:
  - `foundation`
  - `assets`
  - `paint`
  - `document`
  - `renderer`
  - `ui`
  - `platform`
  - `integration`
  - `fuzz`
  - `slow`
  - `gpu`
- Add `tools/pano_cli` for headless automation.
- `pano_cli` should support:
  - create document
  - load project
  - save project
  - apply scripted strokes
  - import/export images
  - inspect layers
  - run layout parse
  - emit JSON results
- Add local automation wrappers under `scripts/automation/`:
  - configure
  - build
  - test
  - analyze
  - package smoke
- All wrappers must return machine-readable logs or summaries.
- Establish `tests/data/` fixtures:
  - tiny PPI files
  - corrupt/truncated PPI cases
  - PNG/JPEG fixtures
  - ABR/PPBR samples
  - layout XML
  - shader snippets
  - brush stroke scripts

Gate:

- `ctest --preset desktop-fast --build-config Debug` runs without a GL
  context.
- Non-render components can be tested on a headless machine.

## Phase 4: Component Split Without Behavior Change

Goal: split libraries while keeping current app behavior.

Status: started. `pp_foundation` exists with binary stream utilities and
boundary/overread/overlapping-write tests. It also owns strict decimal `uint32` parsing used by
`pano_cli`, with rejection tests for empty, signed, mixed, and overflowing
input. A synchronous event dispatcher, structured logging facade, bounded FIFO
task queue, and deterministic `TraceRecorder` now record
component/name/thread/frame/stroke metadata with filtering, capacity, and
invalid-end tests. `pp_assets` has started with PNG/JPEG signature detection,
PNG IHDR metadata parsing, PPI header/project byte-layout/body-summary
recognition, layer/frame indexing, dirty-face PNG payload metadata validation,
asset-level RGBA PNG payload decoding, and a pure typed settings document
model, with
corrupt/truncated/unsupported, non-finite opacity, unsupported blend-mode,
extreme-dimension, and key/value limit tests.
`pp_paint` has started with pure brush parameter validation/stamp evaluation,
CPU reference math for the five current final RGBA shader blend modes plus the
shader-style stroke-alpha blend modes used by pattern/dual-brush mixing, and deterministic
stroke spacing/interpolation plus duplicate-segment, non-finite, sample-limit,
and 1001-sample stress coverage, plus a pure text stroke-script parser.
`pp_document` has
started with a pure canvas/layer/frame model, alpha-lock metadata, snapshot
construction, per-layer frame metadata, layer metadata operations, frame
move/duration queries, renderer-free RGBA8 cube-face payload storage,
renderer-free alpha8 selection-mask storage, PPI image import/export, and
layer/frame/undo-redo history invariant tests. Snapshot construction validates
embedded face-pixel payload bounds, byte counts, duplicate face payloads, and
duplicate selection masks.
`pp_renderer_api` has started with renderer-neutral
texture/readback descriptors and validation tests. `pp_paint_renderer` has
started with deterministic CPU layer compositing over renderer extents using
the paint blend reference, and now exposes pure `pp_document` face and
six-face frame compositors that expand per-layer dirty face payload rectangles
into full renderer-sized RGBA buffers for a requested document frame. It can
also upload those six composited faces through the renderer-neutral
`IRenderDevice` texture API, with the recording backend validating upload and
explicit-transition command streams.
`pp_ui_core` has started with XML-layout-facing
length parsing, color parsing, tinyxml-backed layout XML parsing, and invalid
input tests. Retained `Node` tree lifetime safety is now an explicit
modernization track under `DEBT-0063`: the current UI still mixes raw parent
and lookup pointers, public mutable child ownership, raw callback targets, and
manual destroy flags. `pp_ui_core` should introduce owned tree/handle APIs,
scoped callback connections, mutation-safe event dispatch, and focused
destroy-during-callback tests before broad `NodePanel*`/`NodeDialog*`
migration accelerates. The first pure `pp_ui_core::NodeLifetimeTree` slice now
models checked handles, parent/child invariants, scoped callback connections,
and mutation-safe dispatch for destroy-during-callback and connection-addition
cases. It now also models pointer/keyboard capture release and whole-tree
`clear()` invalidation for layout reload. `pp_ui_core::UiOverlayLifetime` now
layers popup/dialog stack ownership on top of that model, covering root and
nested popups, modal versus modeless dialogs, capture restoration, parent-popup
branch close, and layout-reload invalidation. Wiring those semantics into
retained `Node`/`NodePopupMenu`/`NodeDialog*` remains tracked by the same debt.
The first retained adoption point is `src/legacy_ui_overlay_services.*`, which
now centralizes app-dialog overlay initialization and root attachment for
progress/message/input dialogs, about/manual/changelog, document open/save/new/
browse/resize/layer-rename dialogs, PPBR export, shortcuts, and the what's-new
remote page before those paths reach raw `Node` insertion. It also now owns
retained app-menu popup template cloning and root attachment for File, Export,
Edit, Tools, Panels, Options, About, and Layers menu popups, replacing the
previous direct template-child indexing in `App::init_menu_*`. The helper now
also exposes retained root-popup attachment for `pp_panopainter_ui`, and the
quick-panel brush/color popups, stroke-panel preset/tip/dual/pattern popups,
brush-panel preset menu root insertion, retained combo-box popups,
Open/Browse delete-confirmation dialogs, popup tick decoration nodes, and
top-toolbar panel popups route through it. The helper also centralizes retained
popup mouse-ignore/flood/capture-child activation for those popup families.
Repeated menu, submenu, combo-box, and brush-preset popup close/release
execution now uses the same retained helper, and popup tick-decoration close
callbacks are bound through the overlay service instead of per-panel lambdas.
Brush, brush-preset, color, layer, grid, stroke, and color-picker popup-panel
outside-click close handling now shares the same retained release/remove/
callback helper.
About, changelog, settings, and user-manual close buttons now share retained
destroy-on-click binding through the same overlay service.
Message-box submit/cancel and input-box cancel destroy callbacks now use the
same retained close binding helpers.
Document open/save/new/browse/resize, layer-rename, cloud-browse, and PPBR
export dialog cancel buttons now use those same retained close helpers.
App-level new-document, save, and layer-rename cancel callbacks now share the
retained dialog close plus virtual-keyboard hide helper.
Browse accept, resize accept/failure, and what's-new read-later/close callbacks
now use a named retained dialog close helper instead of direct `destroy()` calls.
Open/Browse delete-confirmation message boxes and shared destroy-on-click
bindings now use that same retained dialog close helper.
Document-session overwrite, unsaved-close, save-before-workflow, and accepted
new/save document cleanup now route retained dialog closing through the same
helpers.
Cloud publish prompts, upload/download progress dialogs, and cloud browser
download-close now use the retained overlay close helpers.
App message-dialog cancel-button removal, layer-rename finish cleanup, and
recording export progress cleanup also route through those helpers.
Floating-panel close and drag-outline cleanup now use the same retained close
helper while drag reparenting remains legacy-owned.
`NodePopupMenu` mouse-up close/release now uses the retained popup close helper.
Layer-row, animation-timeline, and heightmap-overlay drag release paths now use
the same retained mouse-capture release helper, and brush/grid progress or
recovery message dialogs now route through the retained dialog close helper.
Floating-panel placeholder detach/destroy, cloud-loading alignment cleanup,
brush-preset item removal, and retained popup-panel parent detach now use named
retained node helpers; dock/drop reparenting remains legacy-owned.
Canvas export/project progress-bar cleanup and remote-page loading placeholder
cleanup now route through those same retained close/destroy helpers.
Toolbar popup restoration from docked floating stroke/layer/grid panels and
restored floating color-panel title cleanup now use the retained detach/close/
destroy helpers.
Canvas mode mouse-capture release for camera, paint, line, grid, mask, and fill
interactions now routes through the retained capture-release helper.
Button, slider, scroll, color-wheel, color-quad, and canvas gesture-end capture
release now route through that same helper.
Legacy document-open import/discard prompts and brush-package export completion
now route retained dialog closing through the same helper.
Flood-fill tool teardown and checkbox icon removal now route retained node
destruction through that helper as well.
Layout unload root destruction and popup tick-overlay close callbacks now route
retained node destruction through that helper.
Raw popup callback captures and full close/capture ownership remain part of
`DEBT-0063`.
`pano_cli inspect-image` exposes PNG IHDR metadata as JSON,
`pano_cli import-image` accepts a PNG path and imports decoded RGBA8 pixels
into a new pure `pp_document` face payload,
with checked-in decodable PNG and truncated PNG automation coverage,
`pano_cli export-image` writes a deterministic RGBA8 PNG through `pp_assets`
and round-trips it back through file import automation,
`pano_cli inspect-project` reports validated PPI thumbnail/body byte layout,
body summary, layer/frame descriptors, dirty-face PNG payload metadata, and
asset-level decode coverage, and
`pano_cli load-project` creates a `pp_document` projection with per-layer frame
counts, durations, and decoded face-pixel payload attachment when PPI image
payloads are present.
`pp_assets` can write generated PPI projects with explicit per-layer names,
visibility, opacity, blend mode, alpha lock, per-layer frame durations, and
dirty-face payloads targeted to layer/frame/face slots. `pano_cli save-project`
exposes the generated writer for metadata-only and test dirty-face-payload
round-trips through `load-project` and rejects non-finite automation float
inputs before writing files.
`pp_document::export_ppi_project_document` converts pure documents into PPI
bytes using that writer, including PNG-encoded layer/frame face payloads.
`pano_cli simulate-document-export` exercises that pure document export path,
decodes the generated PPI bytes, reimports them, and emits JSON round-trip
metadata.
`pano_cli simulate-document-render` exercises the pure document-to-renderer
frame compositor and renderer texture-upload bridge, emitting six-face render
summaries, renderer upload-command summaries, and OpenGL command-planner
support counts for headless automation.
`pano_cli save-document-project` writes the same pure document export to a PPI
file for inspect/load round-trip automation.
`pano_cli create-document` can create simple animation documents with explicit
frame count/duration. `pano_cli simulate-document-edits` exercises pure
layer metadata, frame reordering, active-index preservation, tiny face-payload
attachment, and selection-mask attachment. `pano_cli simulate-document-history` exercises the
pure `pp_document::DocumentHistory` apply/undo/redo path and emits JSON state
summaries. `pano_cli simulate-image-import` decodes an embedded tiny PNG
through `pp_assets` and attaches the resulting RGBA8 payload to `pp_document`.
`pano_cli simulate-document-export` exercises pure document-to-PPI export,
asset-level PPI image decode, and document reimport in one automation command.
`pano_cli save-document-project` writes a deterministic pure document export
PPI and verifies it through inspect/load smoke coverage.
`pano_cli simulate-blend` exposes deterministic final RGBA and stroke-alpha
blend reference vectors through JSON automation.
`pano_cli simulate-stroke` exercises the pure stroke sampler for
scripted-stroke automation. `pano_cli simulate-stroke-script`
loads stroke script fixtures, parses them through `pp_paint`, and samples every
stroke. `pano_cli apply-stroke-script` maps sampled script points into a
bounded `pp_document` RGBA8 face payload, writes a PPI file, and verifies that
the applied stroke payload survives inspect/load round-trip automation, with a
rejection smoke test for unsafe tiny canvas dimensions.
`pano_cli classify-open` exposes the pure `pp_app_core` document-open route
contract for project files, ABR imports, PPBR imports, and malformed path
rejection. `pano_cli plan-open-route` exposes the pure `pp_app_core`
document-open action plan for clean project open, dirty project prompt, and
brush-import prompt flows. `pano_cli simulate-app-session` exposes the pure
`pp_app_core` session decisions used by project-open, app-close, save, save-as,
and save-version flows, plus the save-before-continue workflow gate used by
new-document/open/browse dialogs. `pano_cli plan-new-document` exposes the
same app-core new-document target, legacy resolution-index mapping, and
overwrite decision used by the live new-document dialog, including invalid
resolution rejection. `pano_cli plan-document-file` exposes the same app-core
document-name validation, legacy `.ppi` path construction, and overwrite
prompt decision used by save-as dialogs through one combined save-file plan.
`pano_cli plan-document-version` exposes the save-version suffix parsing,
candidate generation, collision skipping, and no-slot failure behavior used by
the live save-version dialog.
`pano_cli plan-export-target` exposes app-core export target planning for
equirectangular image files, layer/frame collection stems, picked-directory
stems, cube-face work-directory file sets, and MP4 suggested names used by the
live export dialogs and cube-face writer bridge.
`pano_cli plan-export-start` exposes the app-core export availability decision
used by live image, layer, animation-frame, depth, and cube-face export dialogs
plus MP4 animation and timelapse export dialogs before they call legacy
canvas/recording export execution.
`pano_cli plan-export-message` exposes the app-core export completion dialog
metadata now consumed by the live legacy export bridge for equirectangular,
layer/frame, depth/cube, animation MP4, and timelapse success reporting,
including platform-style destinations and no-message/suppressed branches.
`pano_cli plan-export-report` exposes app-core failure and license-disabled
dialog metadata now consumed by live export dialogs before retained legacy
export execution/logging continues.
`pano_cli plan-recording-session` exposes the app-core recording start, stop,
clear, platform recorded-file cleanup, frame reset, export progress-total, and
export progress-dialog decisions used by the live recording controls. Recording
lifecycle and MP4 export execution now dispatch through `RecordingServices` in
`src/legacy_recording_services.*` before legacy recording threads, PBO readback,
and MP4 encoder execution continue. The retained MP4 export progress bar now
uses `src/legacy_app_dialog_services.*` for creation while progress lifetime
and MP4 writing remain legacy-owned.
`pano_cli plan-share-file` exposes the app-core saved-path decision used by the
live platform share command before iOS/macOS sharing bridges or retained no-op
platform branches execute.
`pano_cli plan-picked-path` exposes the app-core selected-path decision used by
live image, file, save-file, and directory picker branches before retained
platform callbacks or legacy picker bridges continue.
`pano_cli plan-display-file` exposes the app-core external file presentation
decision used by live display-file requests before retained platform open-file
bridges continue.
`pano_cli plan-keyboard-visibility` exposes the app-core virtual keyboard
visibility decision used by live show/hide keyboard requests before retained
mobile platform keyboard bridges continue.
`pano_cli plan-cursor-visibility` exposes the app-core cursor visibility
decision used by live canvas cursor requests before retained desktop platform
cursor bridges continue.
`pp_platform_api::probe_asset_file_timestamp` now owns the retained asset mtime
probe used by layout reload planning, with missing/existing file coverage.
`LayoutManager::load` now consumes that explicit probe plus the pure
`plan_asset_file_load_with_probe` decision instead of calling the probing
wrapper directly.
`pano_cli plan-clipboard-read` and `pano_cli plan-clipboard-write` expose the
app-core clipboard text decisions used by live clipboard get/set requests
before retained platform clipboard bridges continue.
`pano_cli plan-document-resize` exposes the app-core resize dialog state and
selected-resolution commit plan used by the live document resize dialog, and
resize execution now dispatches through `DocumentResizeServices` before the
shared app-shell document-canvas bridge runs the legacy `Canvas` resize adapter
and history clearing.
`pano_cli plan-layer-rename` exposes the app-core layer rename decision used by
the live layer rename dialog, and rename execution now dispatches through
`DocumentLayerRenameServices` in the shared app-shell layer bridge
`src/legacy_document_layer_services.*` before legacy `Canvas`, `NodeLayer`, and
`ActionManager` undo adapters continue.
`pano_cli plan-layer-operation` exposes app-core planning for layer add,
duplicate, select, reorder, remove, opacity, visibility, alpha-lock, blend-mode,
and highlight actions used by the live layer panel. Direct layer-panel
operations now dispatch through `DocumentLayerOperationServices` before the
shared app-shell layer bridge continues legacy `Canvas` and UI layer execution.
`pano_cli plan-layer-panel-view` exposes the app-core layer panel view model for
current opacity, alpha-lock, blend mode, and per-layer visibility state, and
live `NodePanelLayer::update_attributes()` now consumes that tested projection
before writing the retained legacy UI controls.
`pano_cli plan-layer-menu` exposes app-core planning for Layer menu clear,
rename, and merge-down labels/actions, and direct Layer menu commands now
dispatch through `DocumentLayerMenuServices` before the legacy canvas/layer UI
adapter in `src/legacy_document_layer_services.*` continues execution. Layer
menu clear now routes through the shared `DocumentCanvasClearServices` executor
from that bridge before the legacy canvas-clear adapter continues, and Layer
menu merge now validates and dispatches through `DocumentLayerMergeServices`
before the legacy layer-panel merge adapter continues.
`pano_cli plan-animation-operation` exposes app-core planning for animation
frame add, duplicate, remove, duration adjustment, timeline moves, timeline
goto/next/previous, onion-size updates, frame selection, no-reload playback
stepping, and play-mode toggles used by the live animation panel.
`pp_app_core` also owns onion-skin frame range and alpha falloff planning now
consumed by live `NodeCanvas` panorama drawing.
`pano_cli plan-animation-panel-action` exposes the higher-level animation panel
state/action planner for goto, next, previous, playback-step, and play-toggle
automation without requiring the legacy UI or canvas.
Panel-control, timeline, selected-frame click, playback tick, and play-button
toggle execution now dispatch through `DocumentAnimationServices` before the
shared `src/legacy_document_animation_services.*` bridge continues legacy
`Canvas`/`Layer`/canvas-mode and animation-panel state execution.
`pano_cli plan-brush-operation` exposes app-core planning for brush color
changes, tip/pattern/dual texture changes, preset brush replacement, and stroke
settings refreshes used by the live brush, quick, color, and floating panel
callbacks. Brush UI execution now dispatches through `BrushUiServices` before
the shared `src/legacy_brush_ui_services.*` bridge mutates legacy `Brush` and
panel state or loads brush resources.
`pano_cli plan-brush-texture-list` exposes app-core planning for brush/pattern
texture add, remove, and reorder actions, and `NodePanelBrush` now dispatches
those actions through `BrushTextureListServices` in the shared brush bridge
before the legacy image load/save and UI-list adapter continues.
`pano_cli plan-brush-stroke-control` exposes app-core planning for the live
stroke panel's slider, checkbox, blend-mode, tip-aspect reset, and default
brush reset commands. `NodePanelStroke` now dispatches those controls through
`BrushStrokeControlServices` in the shared brush bridge before the legacy
`Canvas::I`/`Brush`/stroke-panel adapter continues.
`pano_cli plan-brush-stroke-panel-view` exposes the app-core stroke-panel view
projection for brush float settings, toggles, blend modes, and thumbnail paths,
and live `NodePanelStroke::update_controls()` now consumes that tested
projection before applying retained slider-curve, preview, and thumbnail UI
updates.
`pano_cli plan-brush-refresh` exposes app-core planning for app-level brush
refresh fan-out, and live `App::brush_update()` now consumes that view before
applying retained stroke, quick, and floating color widget updates.
`pano_cli plan-canvas-tool` exposes app-core planning for draw/erase/line,
camera, grid, copy, cut, fill, mask, flood-fill, pick, and touch-lock toolbar
commands. Canvas tool execution now dispatches through `CanvasToolServices`
in `src/legacy_canvas_tool_services.*` before legacy toolbar selection, `Canvas`
mode, pen picking, touch-lock, and transform state adapters continue.
`pano_cli plan-canvas-tool-toolbar` now exposes the full draw-toolbar binding
set, including button ids, select/toggle actions, button-class expectation, and
the default draw-mode initialization. Live `App::init_toolbar_draw` consumes the
same app-core toolbar plan to install handlers and apply the initial draw tool
while retained `NodeButton`/`NodeButtonCustom` lookup and legacy canvas-tool
execution remain under `DEBT-0027`.
`pano_cli plan-canvas-tool-state` exposes the matching toolbar active-state
refresh used by `App::update` before legacy `Canvas` mode state remains the
source of truth. `NodeCanvas` stylus eraser mode switching consumes the same
shared bridge through its input-only path before legacy canvas mode execution
continues. Canvas mode pointer-tip visibility and Windows pressure remapping
now dispatch through `PlatformServices`, preserving iOS tip behavior and the
Windows pressure curve outside `canvas_modes.cpp`. `NodeCanvas` keyboard and
touch command handling now consumes
`pp_app_core` canvas-hotkey planning for E draw/erase, Ctrl+Z, Ctrl+Shift+Z,
Ctrl+S, Ctrl+Shift+S, Tab UI toggle, brush-size brackets, Android back, Alt
cursor reveal, and two-finger undo before the shared bridge delegates to legacy
UI/canvas/history adapters. `pano_cli plan-canvas-cursor` exposes the
canvas-specific cursor visibility policy for draw/erase versus non-paint
modes, small-brush thresholds, active-stroke hiding, and modifier/tool forced
visibility; live `NodeCanvas::update_cursor()` consumes that planner before
retained `App::show_cursor`/`App::hide_cursor` platform dispatch.
`pano_cli plan-canvas-clear` exposes app-core planning for the main toolbar
clear-current-layer command, including clear color validation, no-canvas
handling, undo recording intent, and dirty-state intent; live toolbar execution
and Layer menu clear now dispatch through the shared app-shell document-canvas
bridge before the legacy `Canvas::clear` adapter continues.
`pano_cli plan-canvas-document-snapshot` exposes the app-core projection from
live canvas metadata into a pure `pp_document::CanvasDocument`, including
dimensions, active layer/frame, layer visibility/opacity/alpha/blend metadata,
frame durations, captured RGBA8 face payloads, and remaining renderer
payload-readback counts; the retained `legacy_document_canvas_services` bridge
now builds the same metadata snapshot from live `Canvas` state and has an
opt-in dirty-face payload snapshot path backed by retained `Layer::snapshot()`
readback. Live Save, Save As, Save Version, and save-before-workflow paths now
prepare and log a payload-completeness report from that snapshot before
delegating to retained `Canvas::project_save`; payload-complete live save
snapshots also run the tested pure PPI exporter and log the app-core
save-writer route/byte count before retained save continues. The app-core
snapshot boundary also has a tested pure PPI export helper, and
`pano_cli plan-canvas-document-snapshot` runs that helper for payload-complete
snapshots and reports generated byte/dirty-face summaries plus the same
save-writer route JSON. `pp_app_core` also owns the retained project-save target
path planner for target, temporary PPI, and timelapse sidecar paths; live
`Canvas::project_save_thread` consumes that planner and
`pano_cli plan-canvas-project-save-target` exposes it for automation. The same
app-core boundary now also plans the retained save write mode, distinguishing
direct first saves from existing-target temporary writes that swap into place
and preserving the legacy direct-write fallback when the temporary file cannot
be opened. The same app-core boundary now also classifies the post-write
commit result for direct writes, successful temporary swaps, remove failures,
and rename-after-remove failures before retained save metadata mutation
continues. Post-save side effects are now planned there too: mark-clean,
new-document commitment, timelapse sidecar gating, platform flush, progress
cleanup, and title refresh are reported before retained execution performs the
actual mutations and sidecar serialization. The same automation now feeds
payload-complete snapshots through the shared
`pp_paint_renderer::prepare_document_frame_export_readiness` report, which
records renderer-neutral six-face texture upload commands and encodes the
active document frame's six composited faces to PNG bytes. This gives CLI
automation and live export adapters the same document/canvas-to-renderer
readiness boundary before broader writer replacement. Live save writer
replacement, export adoption, and renderer-owned readback remain under
`DEBT-0010`/`DEBT-0013`/`DEBT-0036`.
The same CLI snapshot report now emits `depthExport` readiness from
`pp_paint_renderer::plan_document_depth_export_render`: metadata-only snapshots
report pending renderer payload readback, while payload-complete snapshots
report the legacy 1024x1024 depth-render draw plan and still flag the final
3D view/depth image readback as renderer-owned.
Live equirectangular, layer, animation-frame, and cube-face export adapters now
prepare the same payload-bearing document snapshot and shared renderer export
readiness report. Cube-face export writes those
document/renderer-owned PNG bytes through a tested app-core write/publish
executor using the app-core-planned legacy face filenames when available and
falls back to retained `Canvas::export_cube_faces` on snapshot/write failure.
PNG equirectangular export now uses the same document/composite payload to
generate an equirectangular PNG through `pp_paint_renderer`, then writes it
through the app-core file write/publish executor before the retained fallback.
Payload-complete layer and animation-frame PNG collections now use
pure `pp_paint_renderer` equirectangular PNG generation plus app-core
collection write/publish execution before retained fallback. Payload-complete
desktop JPEG equirectangular export now uses the same projection through
`pp_paint_renderer`, `pp_assets` JPEG encoding, GPano XMP injection, and the
same app-core file write/publish executor before retained fallback.
`pp_app_core` now owns the document-snapshot export route
decision used by those live adapters, covering platform support, target support,
PNG/JPEG equirectangular target support, collection/cube target support, and
incomplete renderer-payload fallback; `pano_cli plan-export-snapshot-route`
exposes the same decision for automation, including unsupported target paths
and unsupported platform fallback. The retained export bridge now consumes the
app-core current-platform policy for snapshot-writer attempts instead of owning
local Web writer gates.
Web handoff, video, depth writer replacement, and incomplete-readback cases
still delegate to retained `Canvas` writers after route/readiness reporting.
Depth export now also plans the retained image/depth file targets in
`pp_app_core`, logs the document-snapshot route fallback as an unsupported
snapshot-writer target, and logs a `pp_paint_renderer` document depth render
plan for the legacy 1024x1024 perspective render plus per-layer depth pass
before falling back to retained `Canvas::export_depth`; actual depth rendering,
readback, and format parity remain retained.
`pano_cli plan-image-import` exposes app-core planning for File > Import image
route decisions, including wide equirectangular images, legacy vertical cube
strips, regular transform-placement images, and invalid image dimensions; live
File > Import execution now dispatches through `DocumentImageImportServices`
before legacy image loading, `Canvas::import_equirectangular`, or import
transform-mode setup continues.
`pano_cli plan-file-menu` exposes app-core planning for the top-level File menu
commands, including new/open/import, save/save-as/save-version, share, resize,
cloud upload/browse, JPEG export, and export-submenu routing. Direct File menu
commands now dispatch through `FileMenuServices` in the shared app-shell bridge
`src/legacy_app_shell_services.*` before legacy dialogs, pickers, platform
services, cloud code, and canvas workflows continue.
`pano_cli plan-export-menu` exposes app-core planning for File menu export
choices, including image, layer, cube-face, depth, animation-frame, MP4, and
timelapse dialog routing plus license/canvas gating. Export menu commands now
dispatch through `DocumentExportMenuServices` in the shared app-shell bridge
before legacy export dialogs and renderer/video execution continue.
Export success-message metadata now also comes from `pp_app_core` through
`pano_cli plan-export-message` and the legacy document-export bridge, reducing
the bridge to export execution, platform handoff, and retained threading.
Export failure/license dialog metadata now comes from `pp_app_core` through
`pano_cli plan-export-report`, with legacy `App::dialog_export*` only showing
the planned dialog and dispatching retained export calls.
`pano_cli plan-grid-operation` exposes app-core planning for grid heightmap
pick/load/reload/clear, lightmap render capability/limit checks, and heightmap
commit used by the live grid panel. Grid execution now dispatches through
`GridUiServices` in `src/legacy_grid_ui_services.*` before legacy image loading,
OpenGL texture updates, nanort lightmap baking, and `Canvas::draw_objects`
execution continue.
The retained `NodePanelGrid` lightmap bake now uses the shared `parallel_for`
helper instead of platform-specific Win32 Concurrency Runtime and Apple
`dispatch_apply` branches, keeping the row-dispatch policy in common legacy
infrastructure while the bake itself remains debt-tracked.
`pano_cli plan-history-operation` exposes app-core planning for undo, redo, and
clear-history availability used by toolbar buttons and canvas shortcuts; live
toolbar and canvas-hotkey execution now dispatch through a shared app-shell
legacy history bridge before the legacy `ActionManager` stack adapter
continues. The bridge also centralizes saturated history metrics so app-core
plans never receive wrapped negative counts from oversized legacy stacks.
`pano_cli plan-main-toolbar` exposes app-core planning for the live main
toolbar/status-bar shell, including open/save dialogs, undo/redo availability,
clear-history availability, clear-canvas no-canvas blocking, message-box
creation, and settings dialog routing. The toolbar test-message dialog metadata
now lives in `pp_app_core` through `plan_main_toolbar_message_dialog`, and
`pano_cli plan-main-toolbar --command message-box` exposes it for automation.
`pp_app_core` now also owns a `MainToolbarServices` executor boundary, so
`App::init_toolbar_main` dispatches through `src/legacy_app_shell_services.*`
before legacy dialogs, history/canvas adapters, and settings UI execution
continue; retained toolbar message-box creation now uses
`src/legacy_app_dialog_services.*`.
`pano_cli plan-quick-operation` exposes app-core planning for quick brush/color
slot selection versus popup opening, plus quick mini-state restore/reset
validation used by the live quick panel. Quick-panel execution now dispatches
through `QuickUiServices` in `src/legacy_quick_ui_services.*` before the legacy
`Brush`, color picker, stroke preview, and preset popup adapter continues.
`pano_cli plan-quick-slider-preview` exposes app-core planning for quick
size/flow slider preview cursor placement, RTL offset handling, and pen/line
mode tip flags; live `NodePanelQuick` slider callbacks now consume that plan
before the retained `CanvasModePen`/`CanvasModeLine` and brush-preview updates.
`pano_cli plan-tools-menu` and `pano_cli plan-tools-panel` expose app-core
planning for top-level Tools commands and floating-panel requests, including
already-visible no-ops, panel chrome metadata, shortcuts, camera reset,
grid-clear, and platform-only SonarPen gating. Direct Tools commands now
dispatch through `ToolsMenuServices` in the shared app-shell bridge before the
legacy UI/panel/canvas/platform adapters continue execution. The live animation
panel route now also checks animation panel visibility and applies animation
panel layout state instead of using the grid panel by mistake.
`pano_cli plan-canvas-camera-reset`, `pano_cli plan-canvas-view-density`, and
`pano_cli plan-canvas-view-cursor-mode` expose shared app-core canvas-view
state used by live reset-camera, viewport-density, and cursor-mode paths.
Tools reset-camera, document open/new-document reset, cloud download reset, and
options viewport/cursor callbacks now dispatch through
`src/legacy_canvas_view_services.*` before retained legacy canvas mutation and
settings writes.
The live SonarPen menu action now asks the active `PlatformServices` instance
for availability and startup, removing the local iOS branch from the Tools menu
and shared Tools executor while preserving the retained iOS bridge in the
legacy platform adapter.
Options-menu preference callbacks now dispatch UI scale, viewport scale, RTL,
VR mode, VR-controller, auto-timelapse, and cursor-mode side effects through
`AppPreferenceServices` in `src/legacy_app_preference_services.*` before
retained settings writes, recording lifecycle calls, and legacy canvas/UI
adapters continue.
VR mode start/stop now enters `App` platform wrappers that dispatch through
`PlatformServices`; the desktop runtime-selection policy in `pp_platform_api`
prefers OpenXR and marks OpenVR as a legacy fallback. Windows still reaches the
retained OpenVR bridge in `WindowsPlatformServices` until the OpenXR backend is
wired, while the legacy fallback reports unsupported VR startup on non-Windows
platforms until their shells own the service.
`pano_cli plan-about-menu` exposes app-core planning for About menu help,
about, what's-new, crash-test, and performance-test commands, including
versioned what's-new labels, diagnostic gating, and no-canvas performance-test
blocking. `pp_app_core` now also owns an `AboutMenuServices` executor boundary,
so `App::init_menu_about` dispatches through `src/legacy_app_shell_services.*`
before legacy dialogs, platform crash hooks, and Canvas performance strokes
continue.
`pp_platform_api` now owns a headless `PlatformServices` interface for
startup storage path preparation, clipboard text, cursor visibility,
virtual-keyboard visibility, UI-thread lifecycle hooks, render-context
acquire/release/present hooks, render-target binding hooks, render-capture
frame hooks, render platform hint hooks, render debug callback hooks, external
file display, file sharing, recording file cleanup, live asset/layout reload
policy, diagnostic stacktrace/crash hooks, SonarPen availability/startup,
VR mode start/stop,
image/file/save-file pickers, and directory pickers.
It also owns the SDK-free layout/asset file load policy helper used by
`LayoutManager`, so XML layout hot-reload timestamp checks no longer live in
the shared UI parser.
Windows installs an injected `WindowsPlatformServices` implementation from
`src/platform_windows/windows_platform_services.*` in `pp_platform_windows`;
other platforms still route through the debt-tracked legacy fallback adapter
now isolated in `src/platform_legacy/legacy_platform_services.*`, so behavior
is preserved while their platform shell implementations are extracted.
The central `App` header now forward-declares retained platform handles instead
of including Objective-C, Android, or GLFW SDK headers; the full platform
headers live in the legacy platform adapter until those handles move out of
`App` during Phase 6.
The retained `Asset` header is now Android-SDK-free, hides the Android asset
handles behind opaque pointers and `Asset::set_android_asset_manager`, and keeps
concrete Android asset-manager headers in `asset.cpp`/the Android entrypoint.
This reduces legacy asset I/O header coupling while the static manager bridge
and actual Android asset-reader implementation remain inside retained legacy
asset I/O.
Default canvas allocation size now dispatches through `PlatformServices`, so
`NodeCanvas` and command-line conversion creation paths preserve the desktop
1536 and WebGL 512 defaults without carrying the old `CANVAS_RES` platform
macro in `canvas.h`; DEBT-0057 tracks moving the retained WebGL policy branch
out of the legacy fallback when the Web shell owns injected services.
OpenGL runtime build-target classification now lives in `pp_renderer_gl`
through CMake-owned compile definitions and
`opengl_runtime_for_current_build()`, so `app_shaders.cpp` no longer decides
desktop GL/GLES/WebGL capability policy with local platform branches.
OpenGL extension enumeration now also lives in `pp_renderer_gl` through a
dispatch-tested `query_opengl_extensions` helper; shader startup still logs and
applies the resulting feature flags, but the GL extension query loop is no
longer app-owned.
OpenGL shader startup feature negotiation now also flows through
`pp_renderer_gl::query_opengl_capability_detection` and
`detect_opengl_feature_state`, so extension enumeration, desktop/GLES/WebGL
capability policy, and renderer-neutral feature conversion are tested together
behind the backend boundary. `App::initShaders` remains a legacy adapter that
copies the backend-owned feature snapshot into retained `ShaderManager` static
flags until `ShaderManager` itself becomes an OpenGL backend service.
Prepared-file save/download handoff is now also part of the service contract,
so iOS/Web export completion routes through `PlatformServices` after the app
writes the temporary/exported payload.
Prepared-file writable target selection now also dispatches through
`PlatformServices`, preserving the existing iOS temporary background-write path
and Web data-path synchronous write path while removing those platform branches
from `App::pick_file_save`.
PPBR and MP4 export dialogs now ask `PlatformServices` whether prepared-file
writes are used, so those dialog flows no longer spell local `__IOS__ ||
__WEB__` branches for mobile/Web export handoff.
Layer and animation-frame export dialogs now also ask `PlatformServices`
whether work-directory collection exports are used, then feed that into the
pure `pp_app_core` `plan_document_export_collection_target` policy. This
removes the local iOS branches from those dialogs while preserving iOS
`work_path/doc_layers` and `work_path/doc_frames` targets in the legacy
adapter until Apple platform services are injected.
App-owned curl helpers for download, upload, and license checks now ask
`PlatformServices` whether network TLS verification is disabled, removing the
local Android branches from those helpers while preserving Android's existing
TLS-verification bypass in the legacy adapter until a network/platform service
owns cloud transport.
The remaining legacy curl sites in `Asset::open_url`, `LogRemote::net_init`,
and `NodeDialogCloud::load_thumbs_thread` now consume the shared
`pp_platform_api` default TLS policy helper instead of spelling local Android
branches; this keeps the current Android behavior aligned with
`PlatformServices` while a dedicated network service is still pending.
The Tools menu SonarPen entry now asks `PlatformServices` whether SonarPen is
available and dispatches startup through the same service, preserving the
current iOS Objective-C bridge in the legacy adapter while removing iOS branches
from `App::init_menu_tools` and `LegacyToolsMenuServices`.
App VR lifecycle start/stop now asks `PlatformServices`, preserving the current
Windows OpenVR startup/shutdown bridge as the selected legacy fallback in
`WindowsPlatformServices` while non-Windows fallback adapters keep the existing
unsupported/no-op behavior.
Canvas image export publishing and explicit persistent-storage flushes now
dispatch through `PlatformServices` too, preserving iOS photo-library export
publication and WebGL filesystem sync behavior in the legacy adapter while
removing those direct platform calls from `Canvas` and brush preset storage.
Document-browser search root selection now dispatches through
`PlatformServices`, preserving the iOS `Inbox` root in the legacy adapter while
removing the iOS-specific branch from `App::dialog_browse`.
Save, New Document, and Browse dialog working-directory picker availability and
display-path formatting now also dispatch through `PlatformServices`, removing
desktop-only branches and Win32/macOS path formatting from those UI nodes while
preserving Windows and macOS picker behavior in platform adapters.
Native UI/window state saving now dispatches through `PlatformServices`,
preserving Windows window placement persistence in `WindowsPlatformServices`
and macOS UI state persistence in the legacy adapter while removing platform
guards from `App::ui_save`.
`App::show_cursor`, `App::hide_cursor`, `App::showKeyboard`, and
`App::hideKeyboard` now dispatch through the active service without local
platform guards; unsupported platforms rely on their service no-op behavior.
The unsaved-document close prompt now requests native app/window close through
`PlatformServices`, with Windows implemented by `WindowsPlatformServices` and
macOS/Linux still handled by the legacy adapter until those platform shells
are injected.
The UI loop's per-frame platform hooks now dispatch through
`PlatformServices`: Windows stylus timeout polling and FPS-title updates live
in `WindowsPlatformServices`, while Linux FPS-title updates remain in the
legacy adapter pending Phase 6 platform shell extraction.
Canvas input tip-visibility and pressure-remap policies now also dispatch
through `PlatformServices`, removing the local iOS and Windows branches from
pen, line, and flood-fill canvas modes.
The UI thread's platform attach/detach hooks now also dispatch through
`PlatformServices`, preserving Android JNI attach/detach behavior in the
legacy adapter while removing direct Android lifecycle calls from the main app
loop.
The app's render context acquire/release/present path now dispatches through
`PlatformServices` as well. Windows owns WGL acquisition, default framebuffer
rebinding, and swap in `WindowsPlatformServices`; Apple, Android, Linux, and
WebGL behavior is preserved behind the legacy adapter until their platform
shells are injected.
Render-task default-target binding and visible main-target binding now dispatch
through `PlatformServices`, preserving the existing iOS drawable bind in the
legacy adapter while removing the iOS drawable branch from `App::draw`.
Initial render platform hints now also dispatch through `PlatformServices`,
preserving the previous Windows/macOS program-point-size and line-smoothing
enablement while removing the Windows/macOS branch from `App::init`. The
Windows service now delegates the actual OpenGL program-point-size and
line-smooth enable sequence to a tested `pp_renderer_gl` dispatch helper, so
the platform shell no longer owns those backend state tokens. The legacy
non-Windows fallback now consumes the same helper for retained macOS render
platform hints, keeping the debt-tracked adapter as a thin GL call bridge
rather than a source of backend state policy.
Windows OpenGL debug callback setup now dispatches through `PlatformServices`,
moving Win32 console coloring and debug-break callback behavior into
`WindowsPlatformServices` while keeping other platform adapters as no-ops; the
debug-output/debug-output-synchronous state enable sequence is now a tested
`pp_renderer_gl` backend helper consumed by the Windows service.
Initial PanoPainter OpenGL depth/blend startup state is now represented and
applied by tested `pp_renderer_gl` startup-state contracts; `App::init`
delegates to the backend dispatch path instead of hard-coding the policy or
operation order, and its retained callback endpoints now reuse the shared UI
GL bridge instead of a local raw callback cluster.
OpenGL runtime version/vendor/renderer/GLSL string queries now also use a
tested `pp_renderer_gl` dispatch contract, leaving `App::init` to log the
result while the backend owns the query set and order. The Windows entrypoint
also uses that contract for early context logging and renderer-name window
title construction before replacing the temporary WGL context. Retained runtime
info and extension query callback endpoints now share
`legacy_gl_runtime_dispatch`. The global OpenGL error drain and Windows debug
message callback installation now also route through that retained runtime
dispatch, keeping those entrypoints beside the other runtime callbacks until
renderer/runtime services own context diagnostics.
The default app clear color and color-buffer clear operation now dispatch
through `pp_renderer_gl` as well, moving another direct OpenGL operation out
of `App::clear` while preserving the current gray clear behavior; the live
callback endpoints now share `legacy_ui_gl_dispatch`.
Main app UI viewport and scissor execution now dispatch through tested
`pp_renderer_gl` viewport/scissor contracts, leaving `App::draw` and UI node
clipping to provide rectangles while the backend owns scissor-state tokens and
the live OpenGL call sequence. The retained viewport/scissor callback endpoints
now share `legacy_ui_gl_dispatch`.
VR UI framebuffer viewport and scissor-test setup now also consumes those
`pp_renderer_gl` contracts, keeping desktop and VR UI rendering aligned while
the desktop XR path moves from the retained OpenVR app path toward OpenXR; its
retained callback endpoints now reuse the shared UI GL bridge.
VR draw blend/depth state snapshots, transitions, restore, and depth-buffer
clears, active texture unit switches, and fallback 2D texture unbinds now use
generic tested `pp_renderer_gl` capability query/apply, clear, active-texture,
and texture-bind dispatch contracts, reducing direct OpenGL execution in the
retained VR app path without changing state restore behavior. The remaining
retained VR draw adapter endpoints for these calls now share
`legacy_ui_gl_dispatch`.
The retained `gl_state` save/restore utility now snapshots and restores through
tested `pp_renderer_gl` saved-state dispatch contracts, covering capability
state, viewport, clear color, framebuffer/program bindings, active texture,
2D texture slots, samplers, and cube-map binding without changing the legacy
utility's public fields.
Legacy `Texture2D` allocation, binding, deletion, mipmap generation, region
update, and framebuffer readback now execute through tested `pp_renderer_gl`
texture dispatch contracts. This keeps the app API stable while moving another
resource lifecycle path behind the renderer backend boundary.
Legacy `RTT` resize/copy blits and byte/float framebuffer readbacks now execute
through tested `pp_renderer_gl` framebuffer dispatch contracts with draw/read
framebuffer binding restore handled by the backend helper.
Legacy `RTT::create`/`RTT::destroy` now route render-target texture allocation,
default texture parameter setup, optional depth renderbuffer allocation,
framebuffer color/depth attachment, framebuffer status checks, binding restore,
and resource deletion through tested `pp_renderer_gl` dispatch helpers.
Legacy `RTT` also exposes an RGBA8 region-readback helper that uses the same
backend framebuffer readback dispatch; canvas pick/history/snapshot and
transform history paths now call that helper instead of binding an RTT and
calling `glReadPixels` directly.
Legacy `RTT` now also exposes an RGBA8 region-update helper that routes dirty
rectangle texture writes through the tested `pp_renderer_gl` texture-update
dispatch; canvas undo, layer restore, and flood-fill apply paths now call it
instead of issuing direct `glTexSubImage2D` calls.
Retained `PBO` recording readbacks now route pixel-buffer allocation,
framebuffer readback, map, unmap, and deletion through tested
`pp_renderer_gl` dispatch helpers; recording thread ownership, progress UI, and
MP4 execution remain tracked by DEBT-0037.
Legacy `RTT::bindFramebuffer` and `RTT::unbindFramebuffer` now use tested
`pp_renderer_gl` draw/read framebuffer binding snapshot and restore contracts,
moving render-target pass entry/exit state management behind the backend.
Legacy `RTT::clear`, `RTT::clear_mask`, `RTT::bindTexture`, and
`RTT::unbindTexture` now dispatch through `pp_renderer_gl` clear,
color-write-mask restore, and texture-bind contracts, keeping render-target
utility operations behind the backend boundary. The retained RTT clear and
masked-clear callback endpoints now share `legacy_ui_gl_dispatch`.
Windows RenderDoc frame capture hooks now also dispatch through
`PlatformServices`, keeping capture integration in the platform service while
leaving non-Windows adapters as no-ops.
Startup data/work/recording/temp path preparation now dispatches through
`PlatformServices`, with Windows creating the Documents/PanoPainter folder
tree in `WindowsPlatformServices` and Apple/Linux/Web behavior preserved in the
legacy adapter until platform shells are injected.
Recording clear now asks `PlatformServices` whether the platform owns recorded
file deletion and dispatches the cleanup through the service, preserving the
current Apple recorded-frame cleanup while removing Apple-specific file cleanup
guards from `App::rec_clear`.
The UI loop now asks `PlatformServices` whether live shader/layout reloading
should run, preserving the previous Windows/macOS reload behavior while removing
the direct `(_WIN32 || __OSX__)` guard from `App::ui_thread_main`.
Layout XML reload read/skip decisions now go through `pp_platform_api` as well,
preserving desktop mtime-based reloads and non-desktop single-load behavior
while removing the direct Windows/macOS guard from `LayoutManager::load`.
`App::stacktrace` and `App::crash_test` now dispatch through `PlatformServices`,
with Windows retaining the debug-break crash hook and the legacy adapter
preserving Apple stacktrace/crash and Android crash-test behavior.
`pano_cli plan-cloud-upload` exposes the app-core cloud upload decision used by
the live cloud upload command for missing-canvas, new-document warning, publish
prompt, and dirty-document save-before-upload states before legacy UI, canvas,
and network execution continue.
`pano_cli plan-cloud-upload-all` exposes the app-core bulk upload file-count,
progress UI, and progress-total clamping decision used by the live upload-all
command before legacy asset listing, OpenGL context guard, progress UI, and
network upload execution continue.
`pano_cli plan-cloud-browse` exposes the app-core cloud browse and selected
download decisions used by the live cloud browse command before legacy dialog,
network download, canvas project-open, layer UI, and action-history execution
continue.
Cloud upload, bulk upload, and browse/download live execution now flows through
the `CloudServices` app-core boundary and `src/legacy_cloud_services.*`, keeping
`App::cloud_upload`, `App::cloud_upload_all`, and `App::cloud_browse` as thin
planning adapters while legacy save, progress UI, network, dialog, canvas-open,
layer-refresh, and action-history work remains tracked under `DEBT-0038`.
Cloud warning/publish/success prompts, bulk progress, and download-progress
prompt creation now route through `src/legacy_app_dialog_services.*`, while
cloud prompt/progress lifetime and network/document execution remain legacy.
The app-owned curl upload/download/license helpers now consume the platform TLS
verification policy through `PlatformServices`, and the retained Asset,
LogRemote, and cloud browse-dialog curl sites consume the same default platform
policy helper; retained cloud/network execution remains tracked under
`DEBT-0038`.
`pp_app_core` now also owns tested cloud transfer request and progress planning
through `plan_cloud_download_transfer`, `plan_cloud_upload_transfer`, and
`plan_cloud_transfer_progress`. Live `App::download` and `App::upload` consume
those plans before retained CURL setup, including missing endpoint rejection,
progress-callback enablement, TLS-verification policy, and zero/overrun progress
guards; `pano_cli plan-cloud-transfer` exposes the same path for automation.
Actual CURL ownership, upload form construction, response/error handling,
progress UI, and downloaded-project execution remain tracked under
`DEBT-0038`.
`pano_cli parse-layout` exercises the XML layout path. Continue expanding
document behavior toward legacy Canvas parity and then port OpenGL classes
behind the renderer boundary.
Brush preset-list add/select/move/remove/clear decisions now consume
`pp_app_core` through `NodePanelBrushPreset` and
`pano_cli plan-brush-preset-list`, so preset UI callbacks share tested
headless index/selection planning. Live preset-list execution now also
dispatches through `BrushPresetListServices` and the shared app-core executor
before the retained legacy bridge mutates child nodes. The remaining direct
`NodePanelBrushPreset` child-node execution, legacy `Brush` cloning, friend
adapter, and preset save/reload behavior stay tracked under `DEBT-0023`.
`App::open_document` now routes through the app-core document-open executor and
`src/legacy_document_open_services.*`, preserving ABR/PPBR import prompts,
unsaved-project discard prompts, project open, layer refresh, title updates,
and history clearing while those live effects remain tracked under
`DEBT-0039`. Accepted ABR/PPBR import prompts now delegate import execution to
the app-core brush package import executor and
`src/legacy_brush_package_import_services.*`, preserving detached legacy preset
panel import threads while retained brush asset execution remains tracked under
`DEBT-0048`.
The retained ABR/PPBR import prompt wiring in `src/legacy_document_open_services.*`
now also routes through a focused helper, so the remaining document-open bridge
debt is further concentrated on unsaved-project prompts, project-open
execution, layer refresh, title updates, and retained history services.
The retained document-browse button wiring in `App::dialog_browse()` now also
routes through a focused helper, so the remaining document-open bridge debt is
further concentrated on unsaved-project prompts, project-open execution, layer
refresh, title updates, and retained history services.
The retained unsaved-project discard prompt wiring in
`LegacyDocumentOpenServices::prompt_discard_unsaved_project()` now also routes
through a focused helper, so the remaining document-open bridge debt is
further concentrated on project-open execution, layer refresh, title updates,
and retained history services.
The retained project-open success/failure handling in `open_legacy_project()`
now also routes through a focused helper, so the remaining document-open
bridge debt is further concentrated on layer refresh, title updates, and
retained history services.
The retained project-open layer/title update work and failure-path error
dialog in `handle_open_legacy_project_result()` now also route through focused
helpers, so the remaining document-open bridge debt is further concentrated on
retained history services.
The retained project-open history-planning call in `open_legacy_project()`
now also routes through a focused helper, so the remaining document-open
bridge debt is further concentrated on retained history services.
The retained history adapter now also reuses the shared
`src/legacy_history_services.*` helper from both document-open and
document-session bridges, so the remaining bridge debt is further concentrated
on retained history services.
`App::request_close`, `App::save_document`, and
`App::continue_document_workflow_after_optional_save` now route through
app-core document-session executors and `src/legacy_document_session_services.*`,
preserving close prompts, save dialogs, save-version routing, existing-project
save execution, and dirty-workflow save-before-continue prompts while retained
legacy UI/canvas behavior remains tracked under `DEBT-0040`.
The retained document-session prompt boxes now consume a pure prompt catalog for
close-unsaved, save-before-workflow, new-document overwrite, Save As overwrite,
and save-error metadata; `pano_cli plan-document-session-prompt` exposes the
same titles, messages, button captions, and cancel visibility for automation.
Close-unsaved, save-before-workflow, new-document overwrite, and Save As
overwrite prompt creation now also goes through
`src/legacy_app_dialog_services.*` before the document-session bridge attaches
its legacy callbacks.
The retained new-document and Save As overwrite prompt wiring in
`src/legacy_document_session_services.*` now also routes through a focused
helper, so the remaining document-session bridge debt is further concentrated
on close prompts, native close requests, save-version routing, app document
field mutation, and keyboard/dialog cleanup.
The retained save-before-continue workflow prompt wiring in
`src/legacy_document_session_services.*` now also routes through a focused
helper, so the remaining document-session bridge debt is further concentrated
on close prompts, native close requests, save-version routing, app document
field mutation, and keyboard/dialog cleanup.
The retained close-unsaved prompt wiring in
`src/legacy_document_session_services.*` now also routes through a focused
helper, so the remaining document-session bridge debt is further concentrated
on save-before-workflow prompts, save-version routing, app document field
mutation, and keyboard/dialog cleanup.
The retained Save Version execution in `src/legacy_document_session_services.*`
now also routes through a focused helper, so the remaining document-session
bridge debt is further concentrated on close prompts, save dialogs,
overwrite prompts, and keyboard/dialog cleanup.
The retained Save dialog button wiring in `src/app_dialogs.cpp` now also
routes through a focused helper, so the remaining document-session bridge debt
is further concentrated on close prompts, save-version routing, app document
field mutation, and keyboard/dialog cleanup.
The retained Save Version dialog wiring in `src/app_dialogs.cpp` now also
routes through a focused helper, so the remaining document-session bridge debt
is further concentrated on close prompts, save dialogs, app document field
mutation, and keyboard/dialog cleanup.
`App::dialog_newdoc` now routes accepted new-document plans through the
app-core new-document executor and `src/legacy_document_session_services.*`,
preserving target overwrite prompts, legacy canvas resize/layer setup, history
clearing, title updates, dirty/new-document flag mutation, and keyboard/dialog
cleanup while retained execution remains tracked under `DEBT-0041`.
`App::dialog_save` and `App::dialog_save_ver` now route accepted Save As and
Save Version plans through app-core document file/version save executors and
`src/legacy_document_session_services.*`, preserving overwrite prompts,
legacy `Canvas::project_save`, app document field updates, title updates, and
keyboard/dialog cleanup while retained execution remains tracked under
`DEBT-0042`.
`App::dialog_export`, `App::dialog_export_layers`,
`App::dialog_export_anim_frames`, `App::dialog_export_depth`, and
`App::dialog_export_cube_faces` now route accepted file/stem/collection and
named export work through app-core document export executors and
`src/legacy_document_export_services.*`; layer/frame collection export target
destination is now planned in `pp_app_core` and selected by `PlatformServices`,
preserving existing platform messages, directory creation, picker-selected
stems, Web prepared-file handoff, and legacy `Canvas` export calls while
retained execution remains tracked under `DEBT-0043`.
`App::dialog_timelapse_export` and `App::dialog_export_mp4` now route
picker-selected MP4 export paths through the app-core document video export
executor and `src/legacy_document_export_services.*`, preserving mobile/Web
suggested-name save callbacks, desktop worker-thread timelapse export,
`App::rec_export`, animation `Canvas::export_anim_mp4` dispatch, and existing
success messages while retained execution remains tracked under `DEBT-0044`.
`App::dialog_ppbr_export` now routes picker-selected PPBR brush package exports
through the app-core brush package export executor and
`src/legacy_brush_package_export_services.*`, preserving dialog metadata
collection, legacy `Image` header ownership, desktop worker-thread export,
mobile/Web save completion, and `NodePanelBrushPreset::export_ppbr` while
export success-dialog metadata now comes from `pp_app_core` and is exposed by
`pano_cli plan-brush-package-export`. Retained execution remains tracked under
`DEBT-0047`.
PPBR package header validation and export target/data-directory planning now
live in `pp_assets::brush_package` and are exercised by
`pp_assets_brush_package_tests` plus `pano_cli plan-brush-package-export`.
The macOS-specific PPBR preview data-directory override now dispatches through
`PlatformServices`, so `NodePanelBrushPreset::export_ppbr` no longer spells a
local `__OSX__` branch while the actual PPBR serialization path remains
legacy-owned.
The live PPBR import/export path consumes those helpers, while legacy
Serializer/Image payload reading, stroke preview generation, preset storage,
and the historical permissive version check remain tracked under `DEBT-0047`
and `DEBT-0049`.
ABR and PPBR import image target planning for brush tips and patterns also now
uses `pp_assets::brush_package`, so the legacy preset panel no longer owns the
`data/brushes`, `data/brushes/thumbs`, `data/patterns`, and
`data/patterns/thumbs` path construction rules. Actual ABR/PPBR parsing,
duplicate policy, preset creation, save/reload, and progress/UI refresh remain
legacy-owned under `DEBT-0048`.

Implementation tasks:

- Extract components in this order:
  1. `pp_foundation`
  2. `pp_assets`
  3. `pp_paint`
  4. `pp_document`
  5. `pp_renderer_api`
  6. `pp_renderer_gl`
  7. `pp_paint_renderer`
  8. `pp_ui_core`
  9. `pp_panopainter_ui`
  10. `pp_platform_api`
  11. `pp_platform_*`
  12. `panopainter_app`
- Remove renderer/platform dependencies from pure headers first, especially:
  - `Brush`
  - document/layer model
  - serializer
  - UI core headers
- Make UI lifetime safety a first-class extraction criterion:
  - define a `pp_ui_core` ownership model for the retained `Node` tree
  - replace raw callback targets with scoped connections or checked handles
  - move panel/dialog side effects toward app-core command dispatch
  - test destroy-during-callback, capture release, popup close, and layout
    reload mutation cases before replacing retained UI nodes wholesale
- Keep facade shims where needed, but debt-track every shim.
- Avoid large behavioral rewrites during extraction.
- Each extracted component gets a focused test suite before moving to the next.

Gate:

- Old app still launches.
- Component tests pass after every extraction.
- No undocumented stubs or shortcuts.

## Phase 5: Renderer Boundary And OpenGL Parity

Goal: make OpenGL an implementation detail and establish parity tests before
adding new backends.

Status: started. `pp_renderer_api` exists as a headless renderer-neutral target
with renderer feature flags, explicit texture usage flags, texture descriptor, byte-size, viewport,
mesh, readback bounds, command context, render device, shader program
descriptor, mesh, render target, readback byte-size helpers,
texture mip-level validation, resource debug-label validation,
texture-upload/readback command validation,
mipmap-generation command validation, texture-state transition validation, frame-capture byte-size helpers,
readback/copy/frame-capture/blit descriptor validation, frame-capture command validation, render-target blit validation, texture-slot
binding validation, blend-state validation, scissor-state validation,
depth-state validation, trace marker/scope validation, sampler-state validation,
texture/mesh/shader resource-label validation,
recording-device reuse/reset validation, and the canonical PanoPainter shader
catalog now consumed by the legacy OpenGL app initialization path.
`pp_renderer_gl` now exists as the first OpenGL backend library and owns pure
OpenGL capability detection for framebuffer fetch, map-buffer alignment, and
float texture support. It also owns the OpenGL texture upload-type mapping used
by legacy `Texture2D` and `RTT` creation, RGBA pixel-format mapping used by
`RTT` texture allocation, plus image channel-count to texture format mapping
for `Texture2D` image uploads and framebuffer status naming for `RTT` and
`Texture2D` diagnostics. Live stroke rasterization has started moving toward renderer
services: `Canvas::stroke_draw` now consumes a named
`CanvasStrokeRasterizationPlan` through a legacy adapter boundary for
destination feedback/copy decisions, and `pp_paint_renderer` owns pure
stroke material/pass planning for pattern, mixer, dual-brush, and final
composite texture/uniform intent. Live `Canvas::stroke_draw`, stroke commit,
and draw-merge paths consume that material plan for destination feedback,
each-sample pattern, dual-brush, and final composite decisions, and
`NodeStrokePreview` now consumes the same plan for preview material decisions.
Canvas and `NodeStrokePreview` stroke sample execution also delegate optional
destination copy, brush vertex upload, brush-shape draw, and destination unbind
through `execute_legacy_canvas_stroke_sample`, creating the first retained
OpenGL stroke execution service seam. Live stroke `kShader::Stroke` binding,
setup, blend, pattern, and per-sample uniform writes now pass through
`legacy_canvas_stroke_shader_services.h`. Live draw-merge and preview final
stroke composite `kShader::CompDraw` setup now pass through
`legacy_canvas_stroke_composite_services.h`, and `Canvas::stroke_commit` now
reuses that same composite service for commit-time final stroke compositing.
Stroke padding and commit dilate `kShader::StrokePad`/`kShader::StrokeDilate`
setup now pass through `legacy_canvas_stroke_edge_services.h`, leaving
RTT/texture ownership, checkerboard/non-stroke composite shaders, and retained
callback execution under `DEBT-0036`. Stroke sample copy bounds, live face
dirty-box accumulation, and preview padding region math now live as tested
`pp_paint_renderer` planners while retained Canvas/preview code still owns GL
ordering, RTT/texture binding, history mutation, and final dirty state storage.
`NodeStrokePreview` also now consumes a tested preview composite sequence plan
for mixer intent, and retained preview background capture, composite input
binding/draw, and preview texture copy are centralized behind
`legacy_canvas_stroke_preview_services.h`. Preview mixer-pass `CompDraw` setup
now also reuses `legacy_canvas_stroke_composite_services.h`.
It also owns renderer API texture-format to
OpenGL internal/pixel/component token mapping, including depth-stencil formats,
for future backend texture objects. `Texture2D` 2D texture binding, upload,
mipmap generation, framebuffer readback setup, and update component-type tokens
now delegate to `pp_renderer_gl`; retained `Texture2D`, `TextureCube`, and
`RTT` texture allocation, bind, parameter, update, mipmap, and delete dispatch
now share the retained `legacy_gl_texture_dispatch` raw callback bridge.
`TextureCube` cube-map binding, allocation
face targets, RGBA allocation format, and unsigned-byte component type also
delegate to `pp_renderer_gl`. RGBA8/RGBA32F readback formats, checked byte-count math, PBO
pixel-buffer target/usage/access tokens, and PBO allocation/readback/map/unmap/delete
dispatch sequences used by retained recording readbacks now live in
`pp_renderer_gl`; retained `PBO` allocation, framebuffer readback, map, unmap,
and delete dispatch now share the retained `legacy_gl_pixel_buffer_dispatch`
raw callback bridge. Retained `Sampler` create, parameter, border-color, bind,
and unbind dispatch now share the retained `legacy_gl_sampler_dispatch` raw
callback bridge. The framebuffer blit color mask and linear/nearest
filter tokens used by `RTT::resize` and `RTT::copy`, renderer API blit-filter
to OpenGL token mapping, plus the default
render-target texture parameters and parameter dispatch, texture/renderbuffer
targets, depth format, framebuffer targets, binding queries, attachment points,
render-target framebuffer allocation/delete, binding restore, and completion
status used by `RTT::create`/`RTT::destroy` and framebuffer bind/restore paths,
also live in `pp_renderer_gl`; retained `Texture2D` readback and RTT
framebuffer allocation, deletion, bind/restore, blit, readback, and PBO
readback dispatch now share the retained `legacy_gl_framebuffer_dispatch` raw
callback bridge. Depth renderbuffer allocation/storage/delete and
framebuffer depth attach/detach sequences used by retained `RTT` and canvas
object-drawing helpers now execute through tested `pp_renderer_gl` dispatch
contracts and share the retained `legacy_gl_renderbuffer_dispatch` raw callback
bridge. 2D framebuffer-to-texture
copies used by canvas, transform, layer-conversion, panorama UI, brush preview,
and CanvasLayer cube-face generation paths now route through a tested
`pp_renderer_gl` copy dispatch via the retained target-aware framebuffer-copy
utility bridge. The copy bridge remains retained until renderer services own
cube and 2D framebuffer copy commands under `DEBT-0036`. RTT render-target clear, masked color clear
with color-write-mask restore, and texture bind/unbind dispatch now execute
through `pp_renderer_gl`; renderer API render-pass color/depth/stencil
clear-mask and clear-value mapping, and color-write-mask query tokens also live
there. `RTT` no longer spells GL enum names directly.
Renderer API primitive-topology to OpenGL draw-mode mapping, mesh index-type
and primitive-mode decisions used by legacy `Shape` drawing, plus Shape buffer
targets, static upload usage, and vertex attribute component/normalization
tokens, also live in `pp_renderer_gl`. Retained `Shape`, `TextMesh`, and
`NodeColorWheel` mesh buffer/VAO creation, dynamic vertex/index uploads,
fill/stroke/text draws, and buffer/VAO deletion now execute through tested
`pp_renderer_gl` dispatch contracts via the shared retained
`legacy_gl_mesh_dispatch` raw callback bridge. The
PanoPainter cube-face to
OpenGL texture-target mapping used by `TextureCube` also lives in
`pp_renderer_gl`. The legacy app delegates extension, upload-format,
framebuffer diagnostic, framebuffer blit, render-target setup, clear-state,
2D/cube texture setup, mesh draw-mode, and cube-face texture-target
interpretation to that backend library. Sampler wrap, min/mag filter, and
desktop border-color parameter mapping for legacy `Sampler` also lives in
`pp_renderer_gl`. Renderer API sampler filter/address-mode to OpenGL token
mapping, including mirrored-repeat, and aggregate renderer API sampler-state to
OpenGL min/mag/wrap mapping are also tested there. The PanoPainter shader attribute
binding catalog, shader stage tokens, compile/link status queries, active-uniform
count query, and matrix-uniform transpose token also live in `pp_renderer_gl`
and are consumed by legacy `Shader` creation; retained shader source
compilation/deletion, program attach/link/use/delete, attribute rebinding and
location lookup, active-uniform enumeration, uniform-location discovery, and
uniform writes now share the retained `legacy_gl_shader_dispatch` raw callback
bridge. Renderer API blend factor/op to
OpenGL token mapping also lives in `pp_renderer_gl`, with explicit support flags
so `GL_ZERO` remains distinguishable from unsupported enum values. Aggregate
renderer API blend-state to OpenGL enable/factor/equation/color-mask mapping,
depth compare-op to OpenGL depth-function mapping, and aggregate renderer API
depth-state to OpenGL enable/write/compare mapping also live in
`pp_renderer_gl`. Shader uniform hashing, catalog
validation, active-uniform mapping, and the legacy uniform uniqueness check now
delegate to `pp_renderer_gl` as well. `Shader` no longer spells GL enum names
directly. App OpenGL initialization debug severity, debug output, GL info
string, renderer API viewport/scissor rect conversion, default
depth/program-point/line-smooth state, blend factor/equation, and UI
render-target RGBA8 format tokens are now also cataloged and tested in
`pp_renderer_gl`; the legacy convert command now applies its depth,
program-point-size, source-alpha blend, and add-equation startup policy through
a tested backend dispatch contract, and the resize path consumes the same
backend-owned mapping. App clear color-buffer masks, default framebuffer
binding, scissor state, and sampler filter/wrap tokens now share that backend
mapping too. OpenGL extension enumeration query tokens used before runtime
capability detection also live in `pp_renderer_gl`. Legacy font atlas texture
formats, text mesh buffer targets, attribute component/normalization, draw
primitive/index type, upload usage, and active texture unit selection also
delegate to `pp_renderer_gl`; text mesh buffer/VAO creation, deferred index
and vertex uploads, indexed draw calls, and text draw texture-unit activation
now execute through the same tested dispatch contracts used by `Shape` through
`legacy_gl_mesh_dispatch`.
Canvas undo/redo dirty-region texture updates and readbacks now also execute
through retained `RTT` helpers backed by `pp_renderer_gl`, including 2D texture
target, dirty-region offsets, RGBA pixel format, and unsigned-byte component
type mapping. Canvas stroke commit, thumbnail generation, and object-draw
history paths now query saved blend state through the same tested capability
state dispatch before restoring it.
`NodeViewport` preview rendering now also delegates viewport query,
clear-color query, color-buffer clear mask, viewport execution, color clear,
clear-color restore, and blend-state execution through the shared
`legacy_ui_gl_dispatch` adapter and `pp_renderer_gl` mappings.
`NodeImageTexture` preview drawing now delegates its fallback 2D texture bind
and blend-state execution through the shared UI GL adapter.
`NodeImage` drawing and remote-image texture creation now delegate mipmapped
sampler filters, blend-state execution, and RGBA8/RGBA texture format mapping
to `pp_renderer_gl`.
`NodeColorWheel` triangle-buffer setup and draw-state handling now delegate
array-buffer, static-upload, vertex-attribute, primitive-mode, and blend-state
execution to `pp_renderer_gl`.
Simple UI text, text-input, border, scroll, and animation timeline draw paths
now also execute blend-state changes through the shared UI GL adapter.
Canvas layer cube/equirect generation, clear, restore, and snapshot paths now
also delegate cube/2D texture targets, active texture units, blend/clear state,
viewport execution, cube texture binding, color-buffer clears, clear-color
query/restore, and RGBA8 read/write pixel mapping to `pp_renderer_gl`. Its
active-texture, cube-texture binding, viewport, blend capability, clear-color,
and color-buffer clear adapter endpoints now share `legacy_ui_gl_dispatch`;
the cube-face framebuffer-to-texture copy now uses the shared retained
target-aware utility bridge and remains tracked under DEBT-0036 until a
renderer-owned cube copy command replaces it.
`NodePanelGrid` heightmap preview and lightmap baking now delegate texture
readback formats, sampler filters, depth/blend state, depth clears, viewport
queries, color-mask booleans, active texture units, and float render-target
formats to `pp_renderer_gl`, and its CPU lightmap row dispatch now uses the
shared legacy `parallel_for` helper rather than platform-specific worker APIs.
Its live heightmap draw and bake paths now execute depth/blend state changes,
depth clears, color-write-mask toggles, active texture selection, and bake
viewport changes through tested `pp_renderer_gl` dispatch adapters. Its desktop
texture-resize path now reuses `Texture2D::get_image()`, so grid texture
readback also goes through the tested framebuffer-backed texture readback
dispatch instead of direct `glGetTexImage`. Grid depth-state snapshots and sun
overlay viewport queries now also use tested backend query dispatch instead of
direct state reads.
Legacy `util.cpp` OpenGL error naming, framebuffer-to-texture copy helper, and
`gl_state` save/restore now delegate error codes, state queries, framebuffer
targets, texture binding targets, active texture units, shader program use, and
sampler binding to `pp_renderer_gl` through the shared retained bridge headers
instead of owning local raw OpenGL callbacks.
`NodeStrokePreview` brush preview rendering now delegates depth/scissor/blend
state, tested viewport/clear-color query dispatch, clear-color restore, active
texture unit execution, fallback 2D texture unbinds, 2D texture targets, copy
targets, sampler filters/wraps, and destination-feedback copy/fetch decisions
to `pp_renderer_gl` and `pp_paint_renderer`. Its live stroke-mixer and
brush-preview viewport, scissor, and depth/blend state changes now also
execute through tested `pp_renderer_gl` dispatch via the shared
`legacy_ui_gl_dispatch` bridge.
Retained `Canvas` stroke/thumbnail/object/export paths and `NodeCanvas`
panorama rendering use the same tested active-texture dispatch for their
texture-unit switches, and their live viewport, scissor, and generic
depth/blend/scissor capability changes and `NodeCanvas` capability-state
snapshots now route through the same backend dispatch contracts. Retained
`Canvas` stroke draw/commit, thumbnail
generation, object drawing, and `LayerFrame::clear` saved viewport/clear-color
query plus clear-color restore paths also use tested `pp_renderer_gl` dispatch
helpers. The retained `Canvas` active-texture, fallback texture unbind,
viewport/scissor execution, viewport and clear-color query, clear-color
restore, and capability query/apply adapter endpoints now share
`legacy_ui_gl_dispatch`; Canvas and RTT depth renderbuffer
allocation/attachment/delete now share `legacy_gl_renderbuffer_dispatch` while
resource lifetime ownership remains retained under DEBT-0036.
`NodeCanvas` saved viewport/clear-color query, density target color clear, and
clear-color restore paths use the same helpers. `NodeCanvas` and
`NodeStrokePreview` now share that retained UI GL dispatch bridge for
active-texture, fallback texture unbind, viewport/scissor, clear-color,
color-buffer clear, and capability query/apply adapter endpoints.
Retained `CanvasMode` overlay, mask, transform, and canvas-tip pick paths now
also use the same bridge for active-texture, capability query/apply, viewport,
read-framebuffer query, and RGBA8 pixel readback adapter endpoints while their
mode logic remains in the legacy UI implementation.
`NodePanelGrid` heightmap draw and bake setup now also share that bridge for
active-texture selection, depth/blend capability query/apply, viewport
query/execution, depth clears, and color-write-mask adapter endpoints.
Desktop HMD eye rendering now routes eye framebuffer viewport changes through
the tested `pp_renderer_gl` viewport dispatch while platform VR SDK bridges
remain isolated for later platform-shell extraction.
Legacy `Texture2D`, `TextureManager`, `Sampler`, and `RTT` public headers no
longer expose raw OpenGL enum defaults; default texture formats, sampler
filters/wraps, and render-target formats are resolved through backend-owned
overloads.
The Windows entrypoint now delegates generic OpenGL error-code/info-string
tokens, runtime string query ordering, and WGL core-context/pixel-format
attribute catalogs to `pp_renderer_gl`.
The headless OpenGL command planner now consumes `pp_renderer_api` recorded
commands and maps render-pass clear masks/values, viewport/scissor state,
blend/depth/sampler state, texture formats, primitive modes, draw counts, and
blit filters into GL-facing planned command data with explicit unsupported-token
rejection before a runtime GL context is needed. It also plans whole recorded
command streams, preserving per-command planned data while counting render
passes, draws, shader binds, shader uniforms, texture/sampler binds, texture
uploads, mipmap generation, texture transitions, texture copies, texture
readbacks, frame captures, passthrough commands, trace commands, unsupported
commands, and render-pass ordering errors such as state changes outside a pass,
nested passes, texture I/O or blits inside a pass, and unclosed passes. It
also validates executable command dependencies, including shader-before-uniform
and shader-plus-mesh before draw within each render pass, and rejects invalid
texture/sampler bind slots in malformed recorded streams.
The renderer-neutral API now also plans complex paint feedback strategies for
future stroke/layer compositing work: framebuffer-fetch-capable backends can
read destination color directly, while other backends must use ping-pong render
targets backed by texture copy or render-target blit support. This is exposed
through `pano_cli plan-paint-feedback` and tracked by DEBT-0036 until the live
paint renderer consumes the plan.
`pp_paint_renderer` now consumes that lower-level feedback planner through a
stroke composite plan that decides whether a stroke/layer blend can use
fixed-function blending or needs framebuffer-fetch/ping-pong destination
feedback. `pano_cli plan-stroke-composite` exposes the same decision for
automation, including layer blend, stroke blend, dual-brush, and pattern-blend
inputs. Live `Canvas::draw_merge` now uses this planner for its existing
shader-blend gate for layer and primary-brush blend modes while preserving the
legacy trigger policy; actual canvas stroke execution, dual-brush feedback, and
pattern feedback are still legacy OpenGL and remain tracked by DEBT-0036 until
the app calls through renderer services for the whole compositing path.
`pp_paint_renderer::plan_canvas_blend_gate` now also owns the compatibility
mapping from persisted layer and brush blend indices to that planner, including
fallback behavior for unknown nonzero indices. Both `Canvas::draw_merge` and
`NodeCanvas` panorama rendering consume that shared gate, so the live app no
longer has duplicate local blend-trigger logic or duplicate destination-copy
versus framebuffer-fetch decisions in those paths.
The OpenGL shader initialization path now stores a renderer-neutral
`RenderDeviceFeatures` snapshot converted by `pp_renderer_gl`, and those live
canvas gates consume that snapshot instead of rebuilding feature flags from
individual `ShaderManager` extension booleans.
`RenderDeviceFeatures` now carries the float32-linear-filtering bit as well,
so the canvas stroke texture format decision, renderer diagnostics, and grid
lightmap/bake target selection all consume the renderer-neutral feature
snapshot instead of reading `ShaderManager::ext_*` flags directly. The retained
extension booleans are now limited to the shader-manager compatibility adapter
and legacy logging.
`pp_paint_renderer::plan_canvas_stroke_feedback` now models the current stroke
shader's required destination feedback without changing the legacy shader math.
Live `Canvas::stroke_draw` consumes that plan for main-brush, dual-brush, and
stroke-pad destination-copy versus framebuffer-fetch decisions. Thumbnail layer
blending now consumes the same canvas destination-feedback decision for its
legacy `TextureBlend` path. `NodeStrokePreview` uses the same destination
feedback plan for its live brush-preview copy/fetch decision. The full
thumbnail and brush-preview compositing execution remains legacy OpenGL until a
fuller live paint-renderer boundary can take over.
The existing renderer classes are not yet fully
behind the renderer interfaces.

Implementation tasks:

- Introduce renderer interfaces:
  - `IRenderDevice`
  - `ITexture2D`
  - `IRenderTarget`
  - `IShaderProgram`
  - `IMesh`
  - `ICommandContext`
  - `IReadbackBuffer`
  - `IRenderTrace`
- Port current renderer classes behind OpenGL backend types:
  - `RTT`
  - `Texture2D`
  - `Sampler`
  - `ShaderManager`
  - `Shape`
- Keep OpenGL runtime capability decisions in `pp_renderer_gl` with headless
  tests before moving GL object lifetimes behind backend types.
- Preserve current shader behavior and asset paths.
- Add deterministic GPU tests:
  - clear
  - blit
  - texture upload/download
  - stroke composite
  - erase
  - layer blend
  - equirect export
  - readback bounds
- Add CPU reference tests for final RGBA and stroke-alpha blend modes.
- Compare GPU output to golden/reference data with explicit tolerances.

Gate:

- OpenGL readbacks match golden data on Windows and Linux.
- Mobile/WebGL compile gates remain green.

## Phase 6: Platform Alignment

Goal: every supported platform consumes the same component targets.

Status: started. Root CMake configure presets now have matching build presets
for Windows VS 2026/default, Windows clang-cl ASan, Linux clang, Android
standard x64/arm64, Android Quest arm64, Android Focus/Wave arm64,
Emscripten/WebGL, macOS, iOS device, and iOS simulator. `platform-build`
automation now builds the current headless component matrix, including
`pp_platform_api`, `pp_app_core`, platform API tests, brush-package tests, and
the current app-core startup/frame/shutdown/file/document/brush/canvas/history/grid/toolbar/
tools/about/preferences/status automation tests. The PowerShell wrapper also
normalizes comma-separated `-Presets` and `-Targets` values for reliable
machine-driven partial matrix checks. `panopainter_platform_build_target_matrix_self_test`
keeps the PowerShell and shell wrapper defaults aligned with every current
CMake test executable plus required component targets, and now verifies the
default Android preset set covers standard arm64/x64, Quest arm64, and
Focus/Wave arm64. The shell wrapper now mirrors the PowerShell wrapper's
multi-preset behavior and reports one structured result array. Root CMake now
also exposes non-default platform validation targets:
`panopainter_platform_build_headless`,
`panopainter_platform_build_android_assets`,
`panopainter_platform_build_vcpkg_ui_core`, and
`panopainter_platform_build_apple_remote`; the platform-build self-test guards
those target names and the wrapper matrix now includes
`pp_app_core_app_dialog_tests` plus the current
`pp_paint_renderer_stroke_execution_tests` and
`pp_renderer_gl_gpu_readback_tests` executables with the rest of the CMake
test targets. The `panopainter_package_smoke_readiness_self_test` now keeps the
package-smoke readiness wrapper aligned with the current package matrix while
the remaining platform shell gaps stay debt-tracked.
`package-smoke` now emits a structured package readiness matrix for Windows
AppX, Android standard/Quest/Focus APKs, Apple bundles, Linux app output, and
WebGL output, with blocked prerequisites tied to DEBT-0011. It also has a
readiness-only mode for cheap package blocker inventory without building an app
artifact, and `panopainter_package_smoke_readiness_self_test` keeps the
PowerShell and shell readiness matrices aligned, including retained Linux/WebGL
CMake baseline metadata. The PowerShell wrapper can also run the retained
Android native package checks through `-AndroidNativeChecks`, reporting the
standard `native-lib` build plus Quest/Focus configure checks next to the APK
blocker matrix. Root CMake now exposes non-default package validation targets
for package readiness, Windows app artifact smoke, and retained Android native
checks: `panopainter_package_readiness`,
`panopainter_windows_app_package_smoke`,
`panopainter_android_standard_native_package`,
`panopainter_android_vr_native_package_configure`, and
`panopainter_android_native_package_smoke`, plus the retained Linux/WebGL
blocker target `panopainter_linux_webgl_package_readiness`. App/package
entrypoints still need to consume shared targets and remain covered by debt
until package validation is migrated from legacy package projects to root
CMake.
Retained Linux and WebGL app CMake entrypoints now match the interim platform
baseline used by Android package paths: CMake 3.10 plus target-level
`cxx_std_23`, with `panopainter_retained_platform_cmake_self_test` guarding
against regressions while those entrypoints wait behind root CMake package/app
target migration.
Apple compile validation now runs on the local Mac mini SSH host
`panopainter-mac` through `scripts/automation/apple-remote-build.ps1`. The host
uses Homebrew CMake/Ninja/Git plus full Xcode via `DEVELOPER_DIR`, pulls the
modernization branch from Gitea, initializes the source submodules needed by the
current headless matrix, and builds `macos`, `ios-simulator`, and `ios-device`.
The iOS device compile gate assigns generated bundle identifiers and disables
code signing for test/tool executables under DEBT-0059; signed Apple app bundle
and package-smoke migration remains open under DEBT-0011.
`pp_platform_api` now also owns a tested platform-family policy catalog used by
both `WindowsPlatformServices` and the retained non-Windows fallback for
exported-image publishing, persistent-storage flushing, document browse roots,
working-directory picker availability, prepared-file target planning,
work-directory collection export policy, PPBR data-directory override policy,
SonarPen availability, native UI/window state saving, live asset reload policy,
layout XML file mtime reload policy, recording cleanup policy, default canvas
resolution, and canvas tip visibility. Platform SDK calls and filesystem probes
remain in the platform shells or thin runtime wrappers while those decisions are
headless-testable.
The retained Android standard/Quest/Focus package CMake files now use CMake
3.10, request C++23 through target compile features, include the extracted
modern component/service source set that the legacy package still links
monolithically, and share a generated `nanort` compatibility overlay from
`android/cmake/PanoPainterAndroidLegacyCompat.cmake` instead of dirtying the
vendor submodule. The standard package `native-lib` arm64 target now compiles
and links with the current NDK; Quest and Focus configure with the aligned Yoga
source list and their SDK imported-library paths. Android automation now uses
`sdkmanager` to compare the newest available SDK-managed NDK and CMake packages,
installs newer or missing packages when needed, and selects those versions
before configuring Android presets or retained package paths; on the current
Windows host both NDK `30.0.14904198` and CMake `4.1.2` report
`already-latest-available`.

Implementation tasks:

- Convert these builds to shared component targets:
  - Windows desktop
  - Windows AppX
  - macOS
  - iOS
  - Android standard
  - Android Quest
  - Android Focus/Wave
  - Linux
  - WebGL/Emscripten
- Keep platform entrypoints thin:
  - window lifecycle
  - input dispatch
  - clipboard
  - file picker/share
  - GL context creation
  - VR SDK bridge
  - packaging only
- Add or refine CMake toolchain/preset support for:
  - Android NDK ABIs
  - iOS device
  - iOS simulator
  - macOS
  - Emscripten
- Keep SDK-only imported libraries documented until vcpkg triplets are proven.

Gate:

- Every platform has a named configure/build command.
- Missing local prerequisites are documented.
- Each platform has at least compile or package validation.

## Phase 7: Hardening, Coverage, And Breaking-Point Tests

Status: started. The first opt-in desktop GPU golden/readback gate now lives in
`pp_renderer_gl_gpu_readback_tests` and is selected by the existing
`desktop-gpu` preset. It creates a tiny desktop OpenGL context, clears to a
deterministic 1x1 red fixture, reads back exact RGBA bytes, and skips with a
clear message when no GPU/context helper is available. The first helper is
Windows/WGL-only; macOS/Linux helpers and broader render golden coverage remain
tracked under `DEBT-0036`.

Goal: tests should try to break components, not only confirm current happy
paths.

Implementation tasks:

- Add property/fuzz tests for:
  - binary streams
  - serializers
  - PPI parsing
  - ABR parsing
  - layout XML parsing
  - image metadata parsing
  - brush parameter extremes
  - layer/frame operations
  - undo/redo invariants
- Add stress tests for:
  - thousands of stroke samples
  - extreme resolutions guarded by memory limits
  - rapid layer/frame edits
  - corrupt assets
  - cancellation during export
  - concurrent render/UI task scheduling
- Add coverage for headless libraries on Clang/GCC.
- Require coverage reports for changed components first; do not set a global
  threshold until the baseline is meaningful.
- Add tracing spans around:
  - project load/save
  - render passes
  - stroke commit
  - readback
  - export
  - UI layout
  - platform I/O
- Logs must include component, thread, frame/stroke id, and timing.

Gate:

- No shortcut remains undocumented.
- Every component has unit tests and at least one failure or edge test.

## Phase 8: Future Backend Readiness

Goal: prepare Vulkan and Metal without destabilizing the OpenGL parity path.

Status: in progress. Do not start production Vulkan or Metal work until
`RND-007` and `RND-008` freeze and validate the renderer API backend contract.
`RND-009` and `RND-010` are opt-in lab targets only and must stay outside the
production app path.

Implementation tasks:

- Create non-default targets only after OpenGL backend parity:
  - `pp_renderer_vulkan_lab`
  - `pp_renderer_metal_lab`
- Use `D:\Dev\vkpaint` as reference material for Vulkan painting experiments,
  not as direct production code.
- Record and enforce the renderer API contract surface in
  `docs/modernization/renderer_api_contract.md` and keep
  `panopainter_renderer_api_contract_self_test` as a precondition for
  `RND-007` closeout.
- Add and keep a renderer-conformance matrix fixture profile using
  `renderer-conformance` labels plus `ctest --preset renderer-conformance` and
  `ctest --preset desktop-gpu`, enforced by
  `panopainter_renderer_conformance_matrix_self_test` before `RND-008` closeout.
- Before integration, prove:
  - ping-pong compositing path
  - input-attachment/subpass path where applicable
  - feedback-loop or framebuffer-fetch-style path where supported
  - synchronization and layout correctness under validation layers
- Keep WebGPU as an optional future portability backend, not the core renderer
  contract.

Gate:

- Vulkan/Metal lab targets are opt-in.
- OpenGL production backend remains stable.

## Test Matrix

| Preset/Label | Purpose | Requires |
| --- | --- | --- |
| `desktop-fast` | Pure component unit tests | No GPU/window |
| `desktop-gpu` | OpenGL backend golden/readback tests | GPU/GL context |
| `fuzz` | Deterministic parser/serializer edge corpus; future libFuzzer entrypoint | No GPU/window today |
| `stress` | Large and adversarial scenarios | Longer runtime |
| `platform-build` | Configure/build each supported platform | Local toolchains |
| `package-smoke` | AppX/APK/Apple/WebGL package smoke | Platform SDKs |

Acceptance for each phase:

- Previous phase tests still pass.
- New component has its own tests.
- No undocumented stubs.
- No skipped platform without a debt entry.
- Automation command is recorded in this roadmap or linked docs.

## Verified Commands

Last verified on 2026-06-02:

```powershell
cmake --preset windows-msvc-default
cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_event_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_image_metadata_tests pp_assets_image_pixels_tests pp_assets_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_document_ppi_import_tests pp_document_ppi_export_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_color_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests pano_cli PanoPainter
ctest --preset desktop-fast --build-config Debug
ctest --preset fuzz --build-config Debug
ctest --preset stress --build-config Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\quiet-validate.ps1 -BuildTargets PanoPainter,pano_cli -TestRegex "pp_app_core|pano_cli_plan"
powershell -ExecutionPolicy Bypass -File scripts\automation\test.ps1 -Preset desktop-fast -Configuration Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\build.ps1 -Preset windows-msvc-default -Configuration Debug -Target pano_cli
cmake --build --preset windows-msvc-default --target panopainter_validate_shaders
powershell -ExecutionPolicy Bypass -File scripts\automation\analyze.ps1 -Preset windows-msvc-default -NoApp
set VCPKG_ROOT=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\vcpkg
cmake --preset windows-msvc-vcpkg-headless
powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets windows-msvc-vcpkg-headless
ctest --preset desktop-fast-vcpkg --build-config Debug
cmake --preset android-arm64
powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64
powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -ReadinessOnly
cmake --fresh --preset windows-clangcl-asan
powershell -ExecutionPolicy Bypass -File scripts\automation\apple-remote-build.ps1 -Presets macos,ios-simulator,ios-device
```

Results:

- `pp_foundation_binary_stream_tests` passed.
- `pp_foundation_event_tests` passed.
- `pp_foundation_log_tests` passed.
- `pp_foundation_parse_tests` passed.
- `pp_foundation_task_queue_tests` passed.
- `pp_foundation_trace_tests` passed.
- `pp_assets_image_format_tests` passed.
- `pp_assets_image_metadata_tests` passed.
- `pp_assets_image_pixels_tests` passed, including RGBA8 PNG decode and corrupt
  payload rejection.
- `pp_assets_ppi_header_tests` passed, including PPI thumbnail/body layout,
  body summary validation, layer/frame indexing, explicit per-layer metadata
  and per-layer frame duration writing, dirty-face PNG payload metadata
  validation, targeted layer/frame dirty-face writing, and decoded dirty-face
  payload coverage.
- `pp_assets_settings_document_tests` passed.
- `pp_paint_brush_tests` passed.
- `pp_paint_blend_tests` passed.
- `pp_paint_stroke_tests` passed.
- `pp_paint_stroke_script_tests` passed.
- `pp_document_tests` passed, including snapshot construction, alpha-lock
  metadata, per-layer frame metadata, frame move, duration, face-pixel payload
  storage/replacement/rejection, snapshot-embedded face-payload rejection, and
  history invariants.
- `pp_document_ppi_import_tests` passed, including decoded PPI dirty-face
  payload attachment to `pp_document` layer/frame storage and out-of-range
  payload rejection.
- `pp_document_ppi_export_tests` passed, including pure document metadata,
  per-layer frame duration, and PNG-encoded face-payload export to PPI bytes,
  plus malformed payload rejection at the export boundary.
- `pp_renderer_api_tests` passed, including shader descriptor validation,
  PanoPainter shader catalog validation, explicit texture usage validation,
  texture mip-level validation, resource debug-label validation, readback
  byte-size and command-order validation, texture-upload byte-count validation,
  mipmap-generation command validation, trace marker/scope validation,
  frame-capture byte-size and command-order validation,
  render-target blit validation, texture-slot binding validation, blend-state
  validation, scissor-state validation, render-pass color/depth/stencil clear
  validation, shader-uniform write validation, draw descriptor/range
  validation, backend-neutral resource factory validation, texture-copy
  validation, recording
  render-pass clear/scissor/depth/blend/shader-uniform/texture/sampler-bind/
  upload/texture-copy/readback/frame-capture/blit command capture, draw
  mesh-input capture, explicit draw-range capture, and invalid catalog
  rejection. The same suite now covers complex paint feedback planning for
  framebuffer-fetch backends, ping-pong texture-copy/blit fallbacks, simple
  no-feedback blends, invalid render-target usage, unsupported backends, and
  depth-target rejection.
- `pp_paint_renderer_compositor_tests` passed, including pure
  `pp_document` face and six-face frame compositing over per-layer dirty face
  payloads plus renderer-neutral six-face texture upload through the recording
  backend.
  The suite now covers fixed-function stroke composite planning,
  framebuffer-fetch planning, ping-pong texture-copy/blit fallback planning,
  dual/pattern blend feedback detection, invalid blend mode rejection,
  unsupported backend rejection, and invalid render-target rejection.
- `pp_ui_core_color_tests` passed.
- `pp_ui_core_layout_value_tests` passed.
- `pp_ui_core_layout_xml_tests` passed.
- `pano_cli_create_document_smoke` passed.
- `pano_cli_create_animation_document_smoke` passed and reports animation
  duration JSON.
- `pano_cli_simulate_document_edits_smoke` passed and reports pure
  `pp_document` layer metadata, frame order, active indices, and face-payload
  state as JSON.
- `pano_cli_simulate_document_history_smoke` passed and reports real
  `pp_document::DocumentHistory` apply/undo/redo state as JSON.
- `pano_cli_simulate_document_export_smoke` passed and reports pure
  `pp_document` export to PPI bytes, asset-level decode, and document reimport
  round-trip state as JSON.
- `pano_cli_simulate_document_render_smoke` passed and reports pure
  `pp_document` to `pp_paint_renderer` six-face frame compositing and
  renderer texture-upload command summaries plus OpenGL command-planner
  support counts as JSON.
- `pano_cli_simulate_image_import_smoke` passed and reports embedded PNG decode
  plus `pp_document` face-payload attachment state as JSON.
- `pano_cli_inspect_image_rejects_unsupported` passed as an expected failure
  test.
- `pano_cli_inspect_png_metadata_smoke` passed and reports PNG metadata JSON
  for the tiny IHDR fixture.
- `pano_cli_import_image_rejects_truncated_png` passed as an expected failure
  test, proving the file-driven image import command rejects a metadata-valid
  but undecodable PNG payload.
- `pano_cli_inspect_project_layout_smoke` passed and reports PPI
  thumbnail/body byte layout, body summary, layer/frame descriptors, and
  dirty-face PNG payload metadata JSON.
- `pano_cli_load_project_metadata_smoke` passed and reports a `pp_document`
  projection with per-layer frame counts, durations, and zero loaded face
  payloads for the minimal PPI fixture.
- `pano_cli_save_project_roundtrip_smoke` passed and proves the metadata-only
  `pp_assets` PPI writer can save a generated multi-frame PPI and reload it
  through `pano_cli load-project`.
- `pano_cli_save_project_payload_roundtrip_smoke` passed and proves the
  `pp_assets` PPI writer can save a compressed RGBA PNG dirty-face payload to
  an explicit layer/frame slot, inspect the serialized descriptor, and reload
  it as decoded `pp_document` face-pixel data.
- `pano_cli_save_document_project_roundtrip_smoke` passed and proves a pure
  `pp_document` export can be written to a PPI file, inspected for layer/frame
  dirty-face descriptors, and loaded back through the PPI import path.
- `pano_cli_apply_stroke_script_roundtrip_smoke` passed and proves a checked-in
  stroke script can be parsed, sampled, applied to a pure `pp_document` face
  payload, written to PPI, inspected for the expected dirty-face box, and loaded
  back as decoded document pixel data.
- `pano_cli_apply_stroke_script_rejects_tiny_canvas` passed as an expected
  failure test, proving the stroke-script document command rejects dimensions
  outside its bounded automation range before payload allocation.
- `pano_cli_parse_layout_smoke` passed.
- `pano_cli_simulate_stroke_smoke` passed and reports deterministic stroke
  sample counts/distances.
- `pano_cli_simulate_stroke_script_smoke` passed and reports deterministic
  aggregate stroke-script counts/distances.
- `pp_app_core_document_cloud_tests` passed, covering cloud upload no-canvas,
  new-document warning, clean publish prompt, and dirty save-before-upload
  decisions, plus cloud browse no-canvas/show-browser and selected-download
  decisions, plus bulk upload progress visibility, zero-file, and clamped
  progress-total decisions.
- `pano_cli_plan_cloud_upload_clean_smoke`,
  `pano_cli_plan_cloud_upload_unsaved_smoke`,
  `pano_cli_plan_cloud_upload_new_document_smoke`, and
  `pano_cli_plan_cloud_upload_no_canvas_smoke` passed and expose those app-core
  cloud upload decisions as JSON.
- `pano_cli_plan_cloud_upload_all_progress_smoke` and
  `pano_cli_plan_cloud_upload_all_headless_smoke` passed and expose app-core
  bulk upload progress decisions as JSON.
- `pano_cli_plan_cloud_browse_waiting_smoke`,
  `pano_cli_plan_cloud_browse_selected_smoke`, and
  `pano_cli_plan_cloud_browse_no_canvas_smoke` passed and expose app-core cloud
  browse/download-selection decisions as JSON.
- `pp_app_core_document_cloud_tests` now also covers cloud transfer request
  validation, progress-callback enablement, TLS-verification policy, and
  zero/negative/overrun transfer-progress guards.
- `pano_cli_plan_cloud_transfer_download_smoke`,
  `pano_cli_plan_cloud_transfer_upload_smoke`,
  `pano_cli_plan_cloud_transfer_rejects_missing_destination`, and
  `pano_cli_plan_cloud_transfer_zero_total_smoke` passed and expose the
  app-core cloud transfer path as JSON.
- `PanoPainter`, `pp_app_core_document_cloud_tests`, and `pano_cli` built after
  live `App::download` and `App::upload` started consuming the transfer plans
  before retained CURL setup.
- Android arm64 headless `pp_app_core`, `pano_cli`, and
  `pp_app_core_document_cloud_tests` built after the cloud transfer slice.
- `PanoPainter`, `pp_app_core_document_cloud_tests`, and `pano_cli` built after
  cloud upload warning/publish/success prompts, bulk upload progress dialogs,
  and download-progress messages moved to tested `pp_app_core` metadata plans.
- Focused cloud metadata CTest coverage passed for
  `pp_app_core_document_cloud_tests` and representative
  `pano_cli_plan_cloud_*` smoke tests, including prompt titles/captions,
  progress-dialog titles, and formatted download progress messages.
- Android arm64 headless `pp_app_core`, `pano_cli`, and
  `pp_app_core_document_cloud_tests` built after the cloud metadata slice.
- `PanoPainter`, `pp_app_core_document_cloud_tests`, and `pano_cli` built after
  live cloud upload, bulk upload, and browse/download execution moved behind
  the `CloudServices` boundary and `src/legacy_cloud_services.*`.
- Focused cloud CTest coverage passed for `pp_app_core_document_cloud_tests`
  and all `pano_cli_plan_cloud_*` smoke tests after the live bridge split.
- `ctest --preset desktop-fast --build-config Debug` passed with 243 tests
  after the cloud bridge split.
- `scripts/automation/package-smoke.ps1 -Preset windows-msvc-default
  -Configuration Debug` passed executable/data checks after the cloud bridge
  split; package target migration blockers remain under `DEBT-0011`.
- `PanoPainter`, `pp_app_core_document_cloud_tests`,
  `pp_app_core_app_dialog_tests`, and `pano_cli` built after cloud
  download-progress prompt creation moved onto
  `src/legacy_app_dialog_services.*`.
- Focused cloud/app-dialog CTest coverage passed for
  `pp_app_core_document_cloud_tests`, `pp_app_core_app_dialog_tests`, all
  `pano_cli_plan_cloud_*` smoke tests, and `pano_cli_plan_app_dialog_*` after
  the cloud prompt bridge split.
- Android arm64 headless `pp_app_core`, `pano_cli`,
  `pp_app_core_document_cloud_tests`, and `pp_app_core_app_dialog_tests` built
  after the cloud prompt bridge split.
- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built
  after `App::open_document` moved live execution behind the document-open
  services bridge. A clean rebuild was required once because MSVC reported the
  known Debug PDB `LNK1103` corruption, after which the build passed.
- Focused document-open CTest coverage passed for
  `pp_app_core_document_route_tests`, `pp_app_core_document_session_tests`, and
  the `pano_cli_plan_open_route_*` smoke tests after the live bridge split.
- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built
  after close request, document save, and dirty-workflow continuation execution
  moved behind document-session services. A clean rebuild was required once
  because MSVC reported the known Debug PDB `LNK1103` corruption, after which
  the build passed.
- Focused document-session CTest coverage passed for
  `pp_app_core_document_session_tests`, `pano_cli_simulate_app_session_*`, and
  `pano_cli_plan_document_file/version_*` smoke tests after the live bridge
  split.
- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built
  after accepted new-document execution moved behind the new-document services
  bridge. A clean rebuild was required once because MSVC reported the known
  Debug PDB `LNK1103` corruption, after which the build passed.
- Focused new-document/session CTest coverage passed for
  `pp_app_core_document_session_tests`, `pano_cli_plan_new_document_*`, and
  `pano_cli_simulate_app_session_*` smoke tests after the live bridge split.
- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built
  after accepted Save As and Save Version execution moved behind document
  file/version save services. A clean rebuild was required once because MSVC
  reported the known Debug PDB `LNK1103` corruption, after which the build
  passed.
- Focused Save As/Version/session CTest coverage passed for
  `pp_app_core_document_session_tests`, `pano_cli_plan_document_file_*`,
  `pano_cli_plan_document_version_*`, and `pano_cli_simulate_app_session_*`
  smoke tests after the live bridge split.
- `PanoPainter`, `pano_cli`, `pp_app_core_app_dialog_tests`, and
  `pp_app_core_document_session_tests` built after close/save/workflow,
  new-document overwrite, Save As overwrite, and save-error prompt metadata
  moved into the pure document-session prompt catalog. A clean rebuild was
  required once because MSVC reported the known Debug PDB `LNK1103`
  corruption, after which the build passed.
- Focused document-session prompt CTest coverage passed for
  `pp_app_core_app_dialog_tests`, `pp_app_core_document_session_tests`,
  `pano_cli_plan_document_session_prompt_*`,
  `pano_cli_plan_document_file_*`, `pano_cli_plan_new_document_*`, and
  representative `pano_cli_simulate_app_session_*` smoke tests.
- Android arm64 headless `pp_app_core`, `pano_cli`,
  `pp_app_core_app_dialog_tests`, and `pp_app_core_document_session_tests`
  built after the same prompt-catalog change.
- `PanoPainter`, `pp_app_core_document_export_tests`, and `pano_cli` built
  after equirectangular, layers, animation-frame, depth, and cube-face export
  execution moved behind document export services. A clean rebuild was required
  once because MSVC reported the known Debug PDB `LNK1103` corruption, after
  which the build passed.
- Focused export CTest coverage passed for `pp_app_core_document_export_tests`,
  `pano_cli_plan_export_start/menu/target_*`, and
  `pano_cli_simulate_document_export_smoke` after the live bridge split.
- `PanoPainter`, `pp_app_core_document_export_tests`, and `pano_cli` built
  after timelapse and animation MP4 export execution moved behind document
  video export services. A clean rebuild was required once because MSVC
  reported the known Debug PDB `LNK1103` corruption, after which the app,
  export tests, and `pano_cli` targets built cleanly.
- Focused video export CTest coverage passed for
  `pp_app_core_document_export_tests`, `pano_cli_plan_export_menu_*`,
  `pano_cli_plan_export_target_name_smoke`, and
  `pano_cli_simulate_document_export_smoke`.
- `PanoPainter`, `pp_app_core_app_preferences_tests`, and `pano_cli` built
  after options-menu preference execution moved behind app preference services.
- Focused preference CTest coverage passed for
  `pp_app_core_app_preferences_tests` and the app-preferences CLI smoke tests
  after the live bridge split, including VR mode failed-start status coverage.
- `PanoPainter`, `pp_app_core_app_dialog_tests`, and `pano_cli` built after
  progress/message/input dialog metadata moved into `pp_app_core` while live
  `App` factories kept retained `Node*` creation.
- Focused app-dialog CTest coverage passed for
  `pp_app_core_app_dialog_tests` and the `pano_cli_plan_app_dialog_*` smoke
  tests, including negative progress-total clamping and rejected empty
  input-dialog OK captions.
- Android arm64 headless `pp_app_core`, `pano_cli`, and
  `pp_app_core_app_dialog_tests` built after the app-dialog planning slice.
- `PanoPainter`, `pp_app_core_app_dialog_tests`, and `pano_cli` built after
  retained progress/message/input `Node*` creation moved into
  `src/legacy_app_dialog_services.*`.
- Focused app-dialog CTest coverage passed again for
  `pp_app_core_app_dialog_tests` and the `pano_cli_plan_app_dialog_*` smoke
  tests after the legacy bridge split.
- Android arm64 headless `pp_app_core`, `pano_cli`, and
  `pp_app_core_app_dialog_tests` built after the app-dialog bridge split.
- `PanoPainter`, `pp_app_core_document_session_tests`,
  `pp_app_core_app_dialog_tests`, and `pano_cli` built after document-session
  prompts moved onto `src/legacy_app_dialog_services.*`.
- Focused document-session/app-dialog CTest coverage passed for
  `pp_app_core_document_session_tests`, `pp_app_core_app_dialog_tests`,
  `pano_cli_plan_document_session_prompt_*`, `pano_cli_plan_app_dialog_*`, and
  `pano_cli_simulate_app_session_*` after the document-session prompt bridge
  split.
- Android arm64 headless `pp_app_core`, `pano_cli`,
  `pp_app_core_document_session_tests`, and `pp_app_core_app_dialog_tests`
  built after the document-session prompt bridge split.
- `PanoPainter`, `pp_app_core_app_startup_tests`, and `pano_cli` built after
  startup preference/runtime execution and startup resource sequencing moved
  behind app startup services.
- Focused startup CTest coverage passed for `pp_app_core_app_startup_tests`,
  `pano_cli_plan_app_startup_smoke`, and
  `pano_cli_plan_app_startup_rejects_negative_counter`, with startup resource
  sequencing also covered by `pano_cli_plan_app_startup_resources_smoke` and
  `pano_cli_plan_app_startup_resources_rejects_bad_size`.
- `PanoPainter`, `pp_app_core_app_frame_tests`, and `pano_cli` built after
  app frame surface/update/tick/resize/draw-pass decisions moved into
  `pp_app_core`.
- Focused frame CTest coverage passed for `pp_app_core_app_frame_tests`,
  `pano_cli_plan_app_frame_vr_smoke`, and
  `pano_cli_plan_app_frame_idle_missing_canvas_smoke`, with resize automation
  covered by `pano_cli_plan_app_frame_resize_smoke` and
  `pano_cli_plan_app_frame_rejects_bad_resize`. On 2026-06-05, UI observer
  clipping/on-screen/scissor projection coverage was added through
  `pp_app_core_app_frame_tests`, `pano_cli_plan_app_frame_observer_smoke`,
  `pano_cli_plan_app_frame_observer_clipped_smoke`, and
  `pano_cli_plan_app_frame_rejects_bad_observer`.
- `PanoPainter`, `pp_app_core_app_input_tests`, and `pano_cli` built after
  app input routing and normalization moved into `pp_app_core`.
- Focused app-input CTest coverage passed for `pp_app_core_app_input_tests`,
  `pano_cli_plan_app_input_pointer_smoke`,
  `pano_cli_plan_app_input_gesture_smoke`,
  `pano_cli_plan_app_input_key_vr_smoke`,
  `pano_cli_plan_app_input_ui_toggle_smoke`,
  `pano_cli_plan_app_input_stylus_smoke`,
  `pano_cli_plan_app_input_rejects_bad_float`, and
  `pano_cli_plan_app_input_rejects_missing_ui_panel`.
- `PanoPainter`, `pp_app_core_app_thread_tests`, and `pano_cli` built after
  render/UI task dispatch, queue draining, UI-loop timer cadence, async redraw,
  and start/stop decisions moved into `pp_app_core`.
- Focused app-thread CTest coverage passed for `pp_app_core_app_thread_tests`,
  `pano_cli_plan_app_thread_dispatch_smoke`,
  `pano_cli_plan_app_thread_ui_loop_smoke`,
  `pano_cli_plan_app_thread_stop_smoke`, and
  `pano_cli_plan_app_thread_rejects_bad_timer`.
- `PanoPainter`, `pp_app_core_app_shutdown_tests`, and `pano_cli` built after
  shutdown cleanup staging moved into `pp_app_core`.
- Focused shutdown CTest coverage passed for `pp_app_core_app_shutdown_tests`,
  `pano_cli_plan_app_shutdown_smoke`, and
  `pano_cli_plan_app_shutdown_rejects_unknown_option`.
- `PanoPainter`, `pp_app_core_command_convert_tests`, and `pano_cli` built
  after command-line panorama conversion planning moved into `pp_app_core`.
- Focused command-convert CTest coverage passed for
  `pp_app_core_command_convert_tests`,
  `pano_cli_plan_command_convert_smoke`,
  `pano_cli_plan_command_convert_rejects_empty_project`, and
  `pano_cli_plan_command_convert_rejects_bad_resolution`.
- `PanoPainter`, `pp_app_core_brush_package_export_tests`, and `pano_cli` built
  after PPBR brush package export request validation and dispatch moved behind
  app-core brush package services.
- Focused PPBR export CTest coverage passed for
  `pp_app_core_brush_package_export_tests`,
  `pano_cli_plan_brush_package_export_smoke`,
  `pano_cli_plan_brush_package_export_rejects_empty_path`, and
  `pano_cli_plan_brush_package_export_dest_without_data_smoke`.
- `PanoPainter`, `pp_app_core_brush_package_import_tests`, and `pano_cli` built
  after ABR/PPBR brush package import execution moved behind app-core brush
  import services.
- Focused brush import CTest coverage passed for
  `pp_app_core_brush_package_import_tests`,
  `pano_cli_plan_brush_package_import_ppbr_smoke`,
  `pano_cli_plan_brush_package_import_abr_smoke`,
  `pano_cli_plan_brush_package_import_rejects_empty_path`, and
  `pano_cli_plan_brush_package_import_rejects_unknown_kind`.
- `PanoPainter`, `pp_assets_brush_package_tests`,
  `pp_app_core_brush_package_export_tests`, and `pano_cli` built after PPBR
  header validation and export path/data-directory planning moved into
  `pp_assets`.
- Focused PPBR asset CTest coverage passed for `pp_assets_brush_package_tests`
  and the brush package export CLI tests, including path-without-directory
  rejection and legacy no-export-data data-directory planning.
- `PanoPainter`, `pp_assets_brush_package_tests`,
  `pp_app_core_brush_package_import_tests`, and `pano_cli` built after ABR and
  PPBR imported brush tip/pattern target paths moved into `pp_assets`.
- Focused brush import storage CTest coverage passed for
  `pp_assets_brush_package_tests` and the brush package import/export CLI
  smoke/failure tests.
- `PanoPainter`, `pp_app_core_brush_ui_tests`, and `pano_cli` built after brush
  preset-list add/select/move/remove/clear planning moved into `pp_app_core`.
- Focused brush preset-list CTest coverage passed for
  `pp_app_core_brush_ui_tests` and `pano_cli_plan_brush_preset_list_*` smoke
  tests.
- `pp_app_core_document_recording_tests` passed, covering recording start/stop,
  clear, platform recorded-file cleanup, frame-count reset, export progress
  totals, oversized progress-total clamping, and recording-worker encode-wake
  eligibility.
- `pano_cli_plan_recording_session_stopped_smoke`,
  `pano_cli_plan_recording_session_running_smoke`, and
  `pano_cli_plan_recording_session_platform_cleanup_smoke` passed and expose
  app-core recording lifecycle/export decisions as JSON. On 2026-06-05,
  `pano_cli_plan_recording_session_missing_encoder_smoke` was added for the
  worker no-encode path.
- `PanoPainter`, `pp_app_core_document_recording_tests`,
  `pp_app_core_app_dialog_tests`, and `pano_cli` built after MP4 recording
  export progress-dialog metadata moved into `pp_app_core` and retained
  progress bar creation routed through `src/legacy_app_dialog_services.*`.
- Focused recording/app-dialog CTest coverage passed for
  `pp_app_core_document_recording_tests`, `pp_app_core_app_dialog_tests`,
  `pano_cli_plan_recording_session_*`, and `pano_cli_plan_app_dialog_*`,
  including the MP4 export progress-dialog metadata smoke.
- Android arm64 headless `pp_app_core`, `pano_cli`,
  `pp_app_core_document_recording_tests`, and `pp_app_core_app_dialog_tests`
  built after the recording progress bridge split.
- `pp_app_core_document_resize_tests` passed, covering resize dialog state,
  unknown current-resolution labeling, selected-resolution mapping, square
  canvas sizing, history-clearing intent, invalid selection rejection, service
  dispatch order, optional history clearing, and invalid-dimension rejection.
- `pano_cli_plan_document_resize_smoke` and
  `pano_cli_plan_document_resize_rejects_invalid_selection` passed and expose
  live document-resize planning as JSON automation.
- `pp_app_core_document_layer_tests` passed, covering changed layer rename,
  unchanged no-op rename, empty-name rejection, overlong-name rejection, rename
  executor dispatch, no-op rename dialog finish, malformed rename-plan
  rejection, layer add/duplicate/select/reorder/remove planning, metadata
  planning, bad-index rejection, bad-opacity rejection, bad-blend-mode
  rejection, transient highlight behavior, layer operation executor dispatch,
  layer operation side-effect dispatch, no-op operation preservation,
  malformed operation rejection, Layer menu labels/actions, merge-down routing,
  animated merge blocking, missing selection handling, bad Layer menu state
  rejection, Layer menu executor dispatch, no-op menu execution preservation,
  merge-plan validation, unsupported animated merge rejection, merge executor
  dispatch, and malformed merge-plan rejection.
- `pano_cli_plan_layer_rename_smoke`,
  `pano_cli_plan_layer_rename_no_op_smoke`, and
  `pano_cli_plan_layer_rename_rejects_empty_name` passed and expose live
  layer-rename planning as JSON automation.
- `pano_cli_plan_layer_menu_merge_smoke`,
  `pano_cli_plan_layer_menu_clear_smoke`,
  `pano_cli_plan_layer_menu_merge_animated_blocked_smoke`,
  `pano_cli_plan_layer_menu_missing_selection_smoke`, and
  `pano_cli_plan_layer_menu_rejects_bad_state` passed and expose live Layer
  menu planning as JSON automation.
- `pano_cli_plan_layer_merge_smoke` and
  `pano_cli_plan_layer_merge_animated_rejected` passed and expose live
  merge execution planning as JSON automation.
- `pano_cli_plan_layer_operation_add_smoke`,
  `pano_cli_plan_layer_operation_reorder_no_op_smoke`,
  `pano_cli_plan_layer_operation_highlight_smoke`, and
  `pano_cli_plan_layer_operation_rejects_bad_opacity` passed and expose live
  layer-panel operation planning as JSON automation.
- `pp_app_core_document_animation_tests` passed, covering animation frame
  add/duplicate/remove planning, selected-frame rejection, last-frame remove
  rejection, duration floor/overflow handling, timeline move edge behavior,
  goto/next/previous wrapping, onion-size rejection, service dispatch ordering,
  frame-click selection planning, no-reload playback step planning,
  playback toggle start/stop planning, animation panel action planning,
  invalid panel timeline state rejection, non-mutating duration no-ops, tested
  onion-skin frame range/alpha falloff planning consumed by live `NodeCanvas`
  panorama drawing, tested timeline mouse-scrub cursor-to-frame planning
  consumed by live `NodeAnimationTimeline`, tested animation panel layer/frame
  view-model projection consumed by live `NodePanelAnimation`, stale selected
  frame preservation, and malformed execution payload rejection.
- `pano_cli_plan_animation_operation_add_smoke`,
  `pano_cli_plan_animation_operation_duration_floor_smoke`,
  `pano_cli_plan_animation_operation_next_wrap_smoke`,
  `pano_cli_plan_animation_operation_select_smoke`,
  `pano_cli_plan_animation_operation_playback_smoke`,
  `pano_cli_plan_animation_operation_toggle_playback_start_smoke`,
  `pano_cli_plan_animation_operation_toggle_playback_stop_smoke`,
  `pano_cli_plan_animation_panel_action_next_smoke`,
  `pano_cli_plan_animation_panel_action_toggle_stop_smoke`,
  `pano_cli_plan_animation_panel_action_rejects_bad_timeline`,
  `pano_cli_plan_animation_panel_view_smoke`,
  `pano_cli_plan_animation_panel_view_allows_stale_selection`,
  `pano_cli_plan_animation_panel_view_rejects_empty_frames`,
  `pano_cli_plan_animation_timeline_scrub_smoke`,
  `pano_cli_plan_animation_timeline_scrub_clamps_left`,
  `pano_cli_plan_animation_timeline_scrub_rejects_bad_duration`,
  `pano_cli_plan_animation_operation_rejects_remove_last_frame`, and
  `pano_cli_plan_animation_operation_rejects_bad_selection` passed and expose
  live animation-panel planning as JSON automation.
- `pp_app_core_brush_ui_tests` passed, covering brush color channel validation,
  invalid color rejection, texture-path validation, preset-brush availability,
  preserve-current-color intent, stroke-settings refresh intent, texture-list
  add target path planning, user-texture removal intent, clamped reorder intent,
  stroke-control slider/toggle/blend/reset planning, service dispatch ordering,
  texture/preset/list/stroke-control execution payloads, execution failure
  preservation, and invalid execution payload rejection.
- `pano_cli_plan_brush_operation_color_smoke`,
  `pano_cli_plan_brush_operation_texture_smoke`,
  `pano_cli_plan_brush_operation_preset_smoke`,
  `pano_cli_plan_brush_operation_rejects_bad_color`, and
  `pano_cli_plan_brush_operation_rejects_empty_texture` passed and expose live
  brush/color/preset UI planning as JSON automation.
- `pano_cli_plan_brush_texture_list_add_smoke`,
  `pano_cli_plan_brush_texture_list_remove_user_smoke`,
  `pano_cli_plan_brush_texture_list_move_edge_smoke`, and
  `pano_cli_plan_brush_texture_list_rejects_bad_source` passed and expose live
  brush/pattern texture-list planning as JSON automation.
- `pano_cli_plan_brush_stroke_control_float_smoke`,
  `pano_cli_plan_brush_stroke_control_toggle_smoke`,
  `pano_cli_plan_brush_stroke_control_blend_smoke`,
  `pano_cli_plan_brush_stroke_control_reset_smoke`,
  `pano_cli_plan_brush_stroke_control_rejects_bad_setting`, and
  `pano_cli_plan_brush_stroke_control_rejects_bad_blend` passed and expose live
  stroke-panel slider/toggle/blend/reset planning as JSON automation.
- `pano_cli_plan_brush_stroke_panel_view_smoke`,
  `pano_cli_plan_brush_stroke_panel_view_rejects_bad_float`, and
  `pano_cli_plan_brush_stroke_panel_view_rejects_bad_blend` passed and expose
  live stroke-panel state projection as JSON automation.
- `pp_app_core_grid_ui_tests` passed, covering heightmap pick/load/reload/clear
  planning, lightmap capability and limit checks, missing-heightmap no-op
  behavior, and commit canvas gating.
- `pano_cli_plan_grid_operation_pick_smoke`,
  `pano_cli_plan_grid_operation_load_smoke`,
  `pano_cli_plan_grid_operation_render_supported_smoke`,
  `pano_cli_plan_grid_operation_render_unsupported_smoke`,
  `pano_cli_plan_grid_operation_rejects_empty_reload`, and
  `pano_cli_plan_grid_operation_rejects_bad_samples` passed and expose live
  grid/heightmap/lightmap planning as JSON automation.
- `pp_app_core_canvas_tool_ui_tests` passed, covering toolbar mode selection,
  copy/cut transform action planning, pick no-op outside draw mode, and
  touch-lock toggling, full draw-toolbar binding projection, binding-to-action
  conversion, plus toolbar active-state derivation for draw, copy, and bucket
  modes, service dispatch ordering, pick no-op execution, and malformed
  execution payload rejection.
- `pp_app_core_canvas_hotkey_tests` passed, covering E draw/erase toggles,
  Ctrl+Z/Ctrl+Shift+Z history planning, Ctrl+S/Ctrl+Shift+S document save
  intents, Tab UI toggles, brush-size brackets, Android back and two-finger
  undo, no-op Ctrl-less Z, bad-count rejection, executor dispatch, and
  malformed brush-size execution rejection.
- `pano_cli_plan_canvas_hotkey_ctrl_z_smoke`,
  `pano_cli_plan_canvas_hotkey_save_dirty_version_smoke`,
  `pano_cli_plan_canvas_hotkey_erase_smoke`,
  `pano_cli_plan_canvas_hotkey_two_finger_undo_smoke`, and
  `pano_cli_plan_canvas_hotkey_rejects_bad_count` passed and expose live
  canvas keyboard/touch command planning as JSON automation.
- `pano_cli_plan_canvas_tool_draw_smoke`,
  `pano_cli_plan_canvas_tool_copy_smoke`,
  `pano_cli_plan_canvas_tool_pick_noop_smoke`,
  `pano_cli_plan_canvas_tool_touch_lock_smoke`, and
  `pano_cli_plan_canvas_tool_rejects_unknown` passed and expose live draw
  toolbar planning as JSON automation.
- `pano_cli_plan_canvas_tool_toolbar_smoke` and
  `pano_cli_plan_canvas_tool_toolbar_rejects_unknown` passed and expose the
  full live draw-toolbar binding set as JSON automation.
- `pano_cli_plan_canvas_tool_state_draw_smoke`,
  `pano_cli_plan_canvas_tool_state_copy_smoke`, and
  `pano_cli_plan_canvas_tool_state_rejects_unknown` passed and expose draw
  toolbar active-state refresh as JSON automation.
- `PanoPainter`, `pano_cli`, and `pp_app_core_canvas_tool_ui_tests` built on
  Windows after `App::init_toolbar_draw()` moved to the app-core toolbar
  binding plan; the build required the documented clean after a stale
  debug-info `LNK1103`.
- Android arm64 headless `pp_app_core`, `pano_cli`, and
  `pp_app_core_canvas_tool_ui_tests` built after the same toolbar binding
  planner change.
- `pp_app_core_document_canvas_tests` passed, covering clear-current-layer
  undo/dirty intent, no-canvas no-op behavior, and invalid clear color
  rejection, service dispatch color forwarding, no-op execution preservation,
  invalid execution color rejection, and canvas-to-`pp_document`
  snapshot projection with layer visibility, opacity, alpha-lock, blend mode,
  frame duration, active layer/frame, captured RGBA8 face payload attachment,
  default-name, no-canvas, bad blend, bad payload, and bad duration coverage.
- `pano_cli_plan_canvas_clear_smoke`,
  `pano_cli_plan_canvas_clear_no_canvas_smoke`, and
  `pano_cli_plan_canvas_clear_rejects_bad_color` passed and expose toolbar
  canvas clear planning as JSON automation.
- `pano_cli_plan_canvas_document_snapshot_smoke` and
  `pano_cli_plan_canvas_document_snapshot_payload_smoke` plus the no-canvas
  rejection smoke passed and expose live-canvas-to-`pp_document` projection,
  including captured-versus-pending renderer payload-readback counts, as JSON
  automation.
- Live Save, Save As, Save Version, and save-before-workflow execution now
  prepare a payload-bearing canvas document snapshot and save-readiness report
  through `src/legacy_document_session_services.*`; payload-complete snapshots
  now run the pure PPI exporter and log the app-core save-writer route/byte
  count before delegating to the retained `Canvas::project_save` writer,
  keeping behavior stable while moving the app path onto the document/canvas
  boundary.
- `pano_cli plan-canvas-document-snapshot` now emits the same save-readiness
  report (`payloadComplete` and `canExportPpi`) used by the live save bridge,
  the same save-writer route, and payload-complete snapshots now run the pure
  `pp_document` PPI exporter and decoded-project summary before emitting
  `ppiExport` JSON.
- `pano_cli plan-canvas-project-save-target` now exposes the app-core planner
  for retained project-save target paths, including the target PPI path,
  temporary `.tmp.ppi` path, and timelapse `.pptl` sidecar. The live
  `Canvas::project_save_thread` consumes the same planner before retained
  serialization, reducing inline path compatibility logic in the legacy writer.
  It also reports the app-core write-mode plan for direct first saves versus
  existing-target temporary writes, including the retained fallback to direct
  target writes when the temporary file cannot be opened.
  The command now reports the app-core commit plan for direct saves, successful
  temporary swaps, target-remove failures, and rename-after-remove failures,
  including whether the target may be missing after a failed swap.
  It also reports the app-core post-commit side-effect plan, including
  clean/new-document metadata updates, timelapse sidecar gating, platform
  flush, progress cleanup, and title refresh.
- The same payload-complete snapshot automation now uploads the active document
  frame through `pp_paint_renderer::upload_document_frame_faces` and the
  `RecordingRenderDevice`, emitting `rendererUpload` JSON with texture,
  transition, command, byte, and active-frame payload counts.
- Live equirectangular, layer, animation-frame, and cube-face export bridges now
  capture the payload-bearing canvas document snapshot and run the
  renderer-neutral upload report before retained `Canvas` export execution;
  failures are logged and retained export still continues to preserve behavior.
- `pp_app_core` now owns the document-snapshot export route decision for
  equirectangular, layer, animation-frame, and cube-face writers, including
  PNG/JPEG target support, collection/cube target support, platform support,
  and incomplete renderer-payload fallback reasons. `pano_cli
  plan-export-snapshot-route` exposes the same policy as JSON, and the live
  retained export bridge consumes the app-core current-platform support helper
  instead of local Web writer gates.
- `pp_app_core_document_import_tests` passed, covering wide equirectangular,
  legacy vertical cube strip, regular transform-placement, and invalid-dimension
  import route decisions, equirectangular service dispatch, transform import
  dispatch, empty-path rejection, and invalid execution dimension rejection.
- `pano_cli_plan_image_import_wide_equirect_smoke`,
  `pano_cli_plan_image_import_transform_smoke`, and
  `pano_cli_plan_image_import_rejects_invalid_dimensions` passed and expose File
  > Import route planning as JSON automation.
- `pp_app_core_file_menu_tests` passed, covering top-level File menu routing for
  creation/open/import, save intents, export/submenu/cloud actions, and unknown
  command rejection, plus executor dispatch for dialog, picker, save, export,
  share, resize, and cloud actions.
- `pano_cli_plan_file_menu_import_smoke`,
  `pano_cli_plan_file_menu_save_as_smoke`,
  `pano_cli_plan_file_menu_export_smoke`,
  `pano_cli_plan_file_menu_cloud_upload_smoke`, and
  `pano_cli_plan_file_menu_rejects_unknown` passed and expose top-level File
  menu routing as JSON automation.
- `pp_app_core_document_export_tests` passed, now also covering export menu
  dialog routing, demo-mode MP4/timelapse license gating, and missing-canvas
  handling, plus export menu executor dispatch for all dialog, blocked, and
  unavailable actions before legacy export dialogs continue.
- `pano_cli_plan_export_menu_png_smoke`,
  `pano_cli_plan_export_menu_mp4_demo_blocked_smoke`,
  `pano_cli_plan_export_menu_no_canvas_smoke`, and
  `pano_cli_plan_export_menu_rejects_unknown` passed and expose File menu export
  routing as JSON automation.
- `pp_app_core_history_ui_tests` passed, covering undo/redo availability,
  no-op history commands, clear-history stack/memory state, memory-only clear,
  negative metric rejection, service dispatch order, empty-history no-op
  execution, and invalid execution metric rejection.
- `pano_cli_plan_history_operation_undo_smoke`,
  `pano_cli_plan_history_operation_redo_empty_smoke`,
  `pano_cli_plan_history_operation_clear_smoke`, and
  `pano_cli_plan_history_operation_rejects_negative_count` passed and expose
  toolbar/canvas history planning as JSON automation.
- `pp_app_core_quick_ui_tests` passed, covering quick brush/color slot
  selection, active-slot popup decisions, invalid slot rejection, restore-state
  validation, reset-state validation, service dispatch order, explicit
  brush/color restore indices, and malformed execution payload rejection.
- `pano_cli_plan_quick_operation_select_brush_smoke`,
  `pano_cli_plan_quick_operation_open_color_smoke`,
  `pano_cli_plan_quick_operation_restore_smoke`,
  `pano_cli_plan_quick_operation_reset_smoke`,
  `pano_cli_plan_quick_operation_rejects_bad_slot`, and
  `pano_cli_plan_quick_operation_rejects_bad_restore` passed and expose live
  quick-panel planning as JSON automation.
- `pp_app_core_tools_menu_tests` passed, covering Tools submenu routing,
  root-closing commands, platform-only SonarPen gating, executor dispatch,
  unavailable no-op actions, floating panel chrome metadata, already-visible
  panel no-ops, and animation panel non-droppable state.
- `pano_cli_plan_tools_menu_shortcuts_smoke`,
  `pano_cli_plan_tools_menu_sonarpen_unavailable_smoke`,
  `pano_cli_plan_tools_panel_layers_smoke`,
  `pano_cli_plan_tools_panel_visible_noop_smoke`, and
  `pano_cli_plan_tools_panel_rejects_unknown` passed and expose live Tools
  menu/panel planning as JSON automation.
- `pp_app_core_about_menu_tests` passed, covering About/help/what's-new dialog
  routing, versioned what's-new labels, crash diagnostic gating, performance
  workload metadata, no-canvas performance-test blocking, dispatch through the
  `AboutMenuServices` executor boundary, and no-op unavailable actions.
- `pano_cli_plan_about_menu_news_smoke`,
  `pano_cli_plan_about_menu_performance_no_canvas_smoke`,
  `pano_cli_plan_about_menu_crash_disabled_smoke`, and
  `pano_cli_plan_about_menu_rejects_unknown` passed and expose live About menu
  planning as JSON automation.
- `pp_app_core_main_toolbar_tests` passed, covering live toolbar/status direct
  dialog routing, undo/redo availability, clear-history availability, no-canvas
  clear blocking, negative history metric rejection, and dispatch through the
  `MainToolbarServices` executor boundary without invoking no-op actions.
- `pano_cli_plan_main_toolbar_undo_smoke`,
  `pano_cli_plan_main_toolbar_redo_empty_smoke`,
  `pano_cli_plan_main_toolbar_clear_canvas_no_canvas_smoke`, and
  `pano_cli_plan_main_toolbar_rejects_negative_count` passed and expose live
  toolbar/status planning as JSON automation.
- `PanoPainter`, `pp_app_core_main_toolbar_tests`,
  `pp_app_core_app_dialog_tests`, and `pano_cli` built after toolbar
  test-message dialog metadata moved into `pp_app_core` and live message-box
  creation routed through `src/legacy_app_dialog_services.*`.
- Focused main-toolbar/app-dialog CTest coverage passed for
  `pp_app_core_main_toolbar_tests`, `pp_app_core_app_dialog_tests`,
  `pano_cli_plan_main_toolbar_*`, and `pano_cli_plan_app_dialog_*`, including
  the toolbar message-box dialog metadata smoke.
- Android arm64 headless `pp_app_core`, `pano_cli`,
  `pp_app_core_main_toolbar_tests`, and `pp_app_core_app_dialog_tests` built
  after the toolbar message-box bridge split.
- `pp_app_core_document_sharing_tests` passed, covering saved-path gating before
  platform share execution.
- `pano_cli_plan_share_file_unsaved_smoke` and
  `pano_cli_plan_share_file_saved_smoke` passed and expose app-core share
  decisions as JSON.
- `pp_app_core_document_platform_io_tests` passed, covering empty selected-path
  filtering and non-empty picked-path callback planning before platform picker
  callbacks, plus empty/non-empty display-file planning before platform
  display callbacks, plus virtual keyboard show/hide planning before platform
  keyboard callbacks, plus cursor visibility planning before platform cursor
  callbacks, plus clipboard read/write planning before platform clipboard
  callbacks.
- `pano_cli_plan_picked_path_empty_smoke` and
  `pano_cli_plan_picked_path_selected_smoke` passed and expose app-core picker
  selected-path decisions as JSON.
- `pano_cli_plan_display_file_empty_smoke` and
  `pano_cli_plan_display_file_selected_smoke` passed and expose app-core
  display-file decisions as JSON.
- `pano_cli_plan_keyboard_visibility_hidden_smoke` and
  `pano_cli_plan_keyboard_visibility_visible_smoke` passed and expose app-core
  virtual keyboard decisions as JSON.
- `pano_cli_plan_cursor_visibility_hidden_smoke` and
  `pano_cli_plan_cursor_visibility_visible_smoke` passed and expose app-core
  cursor visibility decisions as JSON.
- `pano_cli_plan_clipboard_read_smoke`,
  `pano_cli_plan_clipboard_write_smoke`, and
  `pano_cli_plan_clipboard_write_empty_smoke` passed and expose app-core
  clipboard decisions as JSON, including empty write text.
- `pp_platform_api_tests` passed, covering the SDK-free `PlatformServices`
  interface for startup storage path preparation, clipboard read/write, empty
  clipboard writes, cursor visibility dispatch, virtual-keyboard visibility
  dispatch, external file display dispatch, file sharing dispatch, native
  app/window close dispatch, UI-thread lifecycle dispatch, render-context
  lifecycle dispatch, render-target binding dispatch, render platform hint
  dispatch, render debug callback dispatch, render-capture frame hook dispatch,
  recording cleanup dispatch, exported-image publish dispatch, persistent
  storage flush dispatch, document browse-root dispatch,
  working-directory picker policy and display-path formatting dispatch,
  canvas input tip visibility and pressure remap dispatch,
  native UI/window state save dispatch, prepared-file writable target dispatch,
  prepared-file export-dialog policy dispatch, work-directory document export
  collection policy dispatch, network TLS verification policy dispatch,
  default network TLS policy coverage, PPBR export data-directory policy
  dispatch, SonarPen availability/startup dispatch, VR lifecycle dispatch,
  layout/asset file load policy coverage,
  live asset/layout reload policy dispatch,
  diagnostic hook dispatch, per-frame platform hook dispatch, picker callback
  dispatch, and prepared-file save/download callback dispatch. The live Windows
  app now
  consumes this interface through an injected
  `WindowsPlatformServices` instance isolated in
  `src/platform_windows/windows_platform_services.*`; other platforms still
  use the legacy fallback adapter, now isolated in
  `src/platform_legacy/legacy_platform_services.*` instead of being owned by
  `app_events.cpp`.
- `panopainter_validate_shaders` passed, validating 25 shader programs and 7
  shader includes for stage markers and include graph integrity.
- `pp_renderer_gl_capabilities_tests` passed on default MSVC, vcpkg-headless,
  and Android arm64 configure/build, covering framebuffer fetch, map-buffer
  alignment, desktop GL core float support, GLES float/half-float extensions,
  WebGL exclusion behavior, upload types for RGBA8/RGBA16F/RGBA32F internal
  formats, image channel-count format mapping including invalid counts, and
  RGBA8/RGBA32F readback format and byte-count mapping, PBO pixel-buffer
  target/usage/access mapping, framebuffer status names, framebuffer blit color
  mask and linear/nearest filters, plus Shape index-type and fill/stroke primitive mode mapping,
  PanoPainter cube-face texture-target order, and the linear clamp-to-edge
  render-target texture parameter set used by `RTT::create`.
  Sampler parameter validation covers wrap S/T/R plus min/mag filter ordering
  used by legacy `Sampler::set` and `Sampler::set_filter`, plus the desktop
  border-color parameter name used by `Sampler::set_border`.
  Legacy `TextureCube` allocation/bind/delete and `Sampler`
  create/configure/border/bind/unbind paths now execute through tested
  `pp_renderer_gl` dispatch contracts, keeping cube-map and sampler resource
  lifecycle reachable without a live GL context.
  Shader attribute binding catalog validation covers the current `pos`, `uvs`,
  `uvs2`, `col`, and `nor` bindings and rejects empty, unnamed, null-name, and
  duplicate-name catalogs while preserving legacy shared locations. Shader
  uniform catalog validation covers the 43 legacy uniform
  names used by `Shader`, preserves the legacy hash ids, and rejects empty,
  unnamed, null-name, mismatched-hash, and duplicate-name catalogs.
  Legacy `Shader` program use/delete, uniform writes, attribute-location
  lookups, shader source compilation, shader deletion, program attach/link,
  attribute rebinding, active-uniform count/enumeration, and uniform-location
  discovery now execute through tested `pp_renderer_gl` dispatch contracts via
  `legacy_gl_shader_dispatch`.
  Retained `Shape`, `TextMesh`, and `NodeColorWheel` mesh buffer/VAO creation,
  zero-byte dynamic-buffer creation, dynamic buffer uploads, indexed and
  non-indexed draws, and resource deletion now execute through tested
  `pp_renderer_gl` dispatch contracts via `legacy_gl_mesh_dispatch`.
- `pp_renderer_gl_command_plan_tests` covers the headless OpenGL command
  planner for recorded render-pass clear masks/values, viewport/scissor state,
  blend/depth/sampler state, texture format mapping, mesh/draw primitive modes,
  draw counts, shader bind/uniform names and byte counts, texture
  upload/mipmap/transition/copy/readback/capture metadata, blit filters and
  byte totals, planned command names, unsupported enum/state rejection, whole
  recorded stream planning, valid trace/render/shader/draw/blit ordering, typed
  texture-command counts, broken render-pass order detection, and executable
  draw/uniform dependency failures.
- PowerShell analyze automation returns JSON summaries and includes the shader
  validation target and renderer-boundary guard.
- `windows-msvc-vcpkg-headless` configured through the Visual Studio bundled
  vcpkg root, installed the manifest dependencies, built the headless component
  matrix, and passed `desktop-fast-vcpkg`.
- `pp_ui_core` built and tested against vcpkg tinyxml2 on
  `windows-msvc-vcpkg-headless` and against the vendored fallback on
  `windows-msvc-default` and `android-arm64`.
- `windows-clangcl-asan` configures headlessly with clang-cl 18.1.8 and
  release MSVC runtime selection; build remains blocked and debt-tracked in
  DEBT-0014 because the selected VS 2026-preview STL requires Clang 20 or
  newer.
- `PanoPainter.exe` built through CMake at
  `out/build/windows-msvc-default/Debug/PanoPainter.exe`.
- PowerShell build/test automation wrappers return JSON summaries and passed
  local smoke checks.
- Renderer-boundary automation fails if active non-backend source code
  reintroduces raw `GL_*`/`WGL_*` constants outside the allowed legacy OpenGL
  implementation files.
- `pp_renderer_api` now includes a headless `RecordingRenderDevice` with strict
  renderer feature flags, renderer-owned resource factory and
  command-order/render-pass-clear/scissor-state/depth-state/blend-state/
  texture-usage/texture-bind/sampler-bind/shader-uniform/texture-upload/
  mipmap-generation/texture-transition/readback/frame-capture/blit validation plus explicit draw
  descriptor and texture-copy validation; it creates validated textures,
  render targets, shaders, meshes, and readback buffers with validated debug
  labels, then records commands, trace markers/scopes, render-pass
  color/depth/stencil clear intent, scissor state, depth state, blend state,
  shader uniform writes, texture/sampler binds, draw mesh inputs, explicit draw
  ranges, texture uploads/mipmap generations/state transitions/copies/readbacks, frame captures,
  and render-target blits, giving automation a backend-neutral render path that
  does not require a window or GL context. Clearing the recording device now
  resets active render-pass and trace-scope state so interrupted automation can
  reuse a recorder without carrying stale frame state forward.
- `pano_cli record-render` exercises that headless recording renderer and emits
  JSON command counts, backend feature flags, resource creation counts, target dimensions, backend
  name, trace marker/scope and draw summary, labeled descriptor counts,
  render-pass/depth-clear counts, and draw
  descriptor vertex/index totals, scissor/depth/blend-state plus
  shader-uniform/texture/sampler-bind/upload/mipmap-generation/texture-transition/texture-copy/readback/
  frame-capture/blit command/byte totals for agent automation. When
  `pp_renderer_gl` is available, it also emits an `openGlPlan` JSON object with
  planned command count, support status, render-pass/draw/shader-bind/uniform/
  texture-upload/mipmap/transition/copy/readback/capture/passthrough/trace
  counts, unsupported command count, render-pass order error count, dependency
  error count, and unclosed-pass state. The
  `--exercise-clear` mode deliberately clears an interrupted trace/render pass,
  verifies stale trace-scope state is rejected, verifies the render context can
  be reused, and then emits that reset status in JSON. It also has an
  expected-failure smoke for oversized render/readback targets.
- `pano_cli simulate-document-history` exercises pure document history
  apply/undo/redo behavior and emits JSON layer/frame/history state for agent
  automation.
- `pano_cli simulate-document-edits` exercises pure document layer/frame edit
  operations and emits JSON metadata, frame order, face-payload state, and
  selection-mask state for agent automation.
- `pano_cli simulate-image-import` exercises embedded PNG decode through
  `pp_assets` and `pp_document` face-payload attachment through JSON
  automation.
- `pano_cli import-image` accepts a PNG file path, decodes RGBA8 pixels through
  `pp_assets`, attaches them to a pure `pp_document` face payload, and has
  checked-in decodable-PNG plus truncated-PNG rejection smoke tests.
- `pano_cli export-image` writes deterministic RGBA8 PNGs through `pp_assets`
  and has a save/import round-trip smoke test. Full legacy canvas export
  remains a future `pano_cli` task.
- `pano_cli save-project` exposes generated multi-layer, multi-frame PPI
  writing with layer metadata and targeted dirty-face layer/frame payloads
  through JSON automation and is covered by metadata-only and
  dirty-face-payload save/load round-trip smoke tests. Full legacy canvas save
  parity remains tracked by DEBT-0013.
- `pp_assets::create_ppi_project` exposes the underlying generated PPI writer
  for non-uniform layer metadata and frame-duration extraction work.
- `pp_document::export_ppi_project_document` exposes pure document-to-PPI byte
  export through CTest coverage; legacy Canvas save integration remains tracked
  by DEBT-0010/DEBT-0013.
- `pano_cli simulate-document-export` exposes the same export path through JSON
  automation for agents.
- `pano_cli save-document-project` exposes file-writing document export
  automation for inspect/load round trips.
- `pano_cli apply-stroke-script` exposes file-driven stroke-script application
  to a pure document face payload and writes a PPI artifact for inspect/load
  round-trip automation.
- Live document save/session paths consume the payload-bearing canvas snapshot
  boundary before retained PPI serialization: existing Save, Save As, Save
  Version, and save-before-workflow now log payload completeness and PPI
  readiness from `pp_app_core` while legacy `Canvas::project_save` still owns
  the actual file write, progress/threading behavior, and compatibility quirks.
- `pp_app_core` now exposes
  `export_document_canvas_save_snapshot_to_ppi`, which refuses snapshots that
  still need renderer payload readback and exports payload-complete or
  metadata-only snapshots through the pure `pp_document` PPI writer. The
  document-canvas tests decode the generated bytes, and
  `pano_cli plan-canvas-document-snapshot` reports `ppiExport` readiness,
  byte count, and dirty-face count for agent automation.
- Payload-complete canvas snapshot automation also crosses the renderer
  boundary now: `pano_cli plan-canvas-document-snapshot` feeds the same snapshot
  through `pp_paint_renderer::prepare_document_frame_export_readiness`, which
  records the renderer-neutral active-frame texture upload stream and encodes
  the composited active-frame cube faces as PNG bytes, so agents validate the
  same document/canvas-to-renderer readiness report consumed by live export. It
  also emits `depthExport` readiness from the paint-renderer depth plan, keeping
  CLI automation aligned with the live depth export adapter while final renderer
  readback remains retained.
- Live image/collection/cube export adapters now prepare and log the same
  document/canvas plus shared renderer-upload and face-PNG export readiness
  reports. Cube-face export now writes the pure document/renderer PNG bytes to
  the `pp_app_core` planned legacy face filenames through a tested
  write/publish service executor before falling back to retained `Canvas`
  execution on failure. PNG equirectangular export now writes a
  `pp_paint_renderer` equirectangular PNG from the same composited document
  frame through the app-core file write/publish executor before falling back to
  retained `Canvas` execution; payload-complete
  layer and animation-frame PNG collections now write pure
  `pp_paint_renderer` equirectangular PNG sequences through a tested app-core
  collection write/publish executor before retained fallback. Depth export now
  prepares the same document/canvas snapshot, logs the shared renderer-upload
  readiness report, and records a tested paint-renderer depth render plan before
  retained `Canvas` execution. JPEG equirectangular export now writes a
  pure `pp_paint_renderer`/`pp_assets` JPEG with GPano XMP metadata through the
  same app-core file write/publish executor before retained fallback.
  Web prepared-file handoff, video, and incomplete-readback
  collection cases remain on their prior retained writer paths. Actual broader
  writer replacement remains tracked under export debt.
- Snapshot creation now rejects invalid embedded RGBA8 face payloads before
  document export or history can persist malformed state.
- Package-smoke wrappers validate the Windows CMake app executable/runtime
  `data/` copy and report structured package readiness for AppX, Android
  standard/Quest/Focus APKs, Apple bundles, Linux app output, and WebGL
  outputs. Actual package building remains blocked by DEBT-0011 until those
  targets are migrated to root CMake. Readiness-only mode now reports the same
  matrix without building the app first, and the package readiness self-test
  keeps wrapper package kinds, retained Linux/WebGL CMake metadata, and blocker
  metadata aligned. Root CMake target
  `panopainter_linux_webgl_package_readiness` now exposes the filtered retained
  Linux/WebGL readiness matrix from the CMake target graph.
- Android standard arm64/x64, Quest arm64, and Focus/Wave arm64 configure
  through the platform-build wrapper by default. Focused validation compiled
  representative headless component/tool targets across all four presets, and
  the full refreshed component/test matrix remains the default gate for local
  platform sweeps. The retained Android standard package CMake path also now
  configures/builds `native-lib` directly for arm64 using C++23 and the shared
  modern component source set, while Quest and Focus package CMake paths
  configure with the same compatibility helper and current Yoga source list.
  The Android platform-build wrapper and retained package helper now query
  `sdkmanager`, install newer/missing SDK Manager NDK/CMake packages when
  needed, select that pair automatically, and report the selected versions plus
  update decisions in their structured output. `package-smoke.ps1
  -ReadinessOnly -AndroidNativeChecks -PackageKinds
  android-standard-apk,android-quest-apk,android-focus-apk` now runs those
  retained native checks from the package-smoke surface while keeping APK
  readiness blocked on root CMake package-target migration. Root CMake now has
  named package validation targets for that same retained Android native gate;
  `cmake --build --preset windows-msvc-default --config Debug --target
  panopainter_android_native_package_smoke` validates the latest SDK-managed
  NDK/CMake pair and reports the still-blocked APK package state.
- The Windows app artifact package smoke is also reachable from root CMake via
  `cmake --build --preset windows-msvc-default --config Debug --target
  panopainter_windows_app_package_smoke`, which builds the CMake `PanoPainter`
  app target, validates the executable/runtime `data/` copy, and reports the
  still-blocked Windows AppX package state.
- Retained Linux and WebGL app CMake files now use CMake 3.10 and target-level
  C++23 instead of global C++14 flags; `python
  scripts/dev/check_retained_platform_cmake.py` and CTest
  `panopainter_retained_platform_cmake_self_test` guard those baselines while
  the actual Linux/WebGL app/package targets remain outside root CMake.
- Root CMake now exposes platform validation targets for the default headless
  platform-build sweep, the Android standard/Quest/Focus root CMake asset
  component sweep, the vcpkg-backed UI core dependency boundary, and the
  remote Apple compile gate. `cmake --build --preset windows-msvc-default
  --config Debug --target panopainter_platform_build_android_assets` validated
  `pp_assets` across Android arm64, Android x64, Quest arm64, and Focus/Wave
  arm64 with the latest SDK-managed NDK/CMake pair; `cmake --build --preset
  windows-msvc-default --config Debug --target
  panopainter_platform_build_vcpkg_ui_core` validated `pp_ui_core` and
  `pp_ui_core_layout_xml_tests` through the vcpkg tinyxml2 preset.
- Desktop VR drawing now routes generic OpenGL scissor/depth/blend state,
  blend/depth state snapshots and restores, depth clears, active texture units,
  and fallback 2D texture unbinds through tested renderer GL backend dispatch;
  platform VR SDK bridges remain isolated for later platform-shell extraction.
  Eye framebuffer viewport execution in the retained HMD path also routes
  through tested `pp_renderer_gl` viewport dispatch.
- Canvas mode overlay, mask, and transform paths now route generic OpenGL
  blend/depth state execution, active texture unit switches, transform/cut
  viewport execution, 2D framebuffer-to-texture copy dispatch, RGBA8 readback
  formats, and RTT-backed transform history region readbacks through the
  renderer GL backend mapping. Canvas-tip pick readback now routes through the
  tested framebuffer readback dispatch using the active read framebuffer, with
  only local OpenGL adapter endpoints retained in `src/canvas_modes.cpp`.
  Paint-mode blend/depth state snapshots also use tested capability-state query
  dispatch.
- `NodeCanvas` panorama UI rendering now routes sampler defaults, saved
  viewport/clear/blend/depth/scissor state, tested viewport and clear-color
  query dispatch, color clears, clear-color restore, active texture units,
  fallback 2D texture unbinds, 2D framebuffer-to-texture copy dispatch, and
  RGBA8 render-target formats through the renderer GL backend mapping.
  Its live viewport, generic blend/depth/scissor capability changes, and
  density/offscreen color-buffer clears now execute through tested
  `pp_renderer_gl` dispatch adapters, and its saved blend/depth/scissor state
  queries now use tested capability-state query dispatch.
- Canvas resource setup now routes stroke-buffer RGBA8/RGBA16F/RGBA32F
  formats, flood-fill texture upload format/type, brush/stencil/mix sampler
  filters and wraps, and cube-strip import channel formats through the renderer
  GL backend mapping. The clamp-to-border sampler wrap is now cataloged and
  tested in `pp_renderer_gl`.
- Early canvas draw helpers now route pick readbacks, stroke mixer depth/scissor
  and blend state, saved viewport/clear-state queries, active texture units,
  fallback 2D texture unbinds, and stroke background copy targets through the
  renderer GL backend mapping. Stroke mixer viewport/scissor execution also
  routes through the tested backend dispatch contract.
- Canvas stroke commit now routes saved viewport/clear/blend state, history
  readbacks, active texture units, fallback 2D texture unbinds, and layer
  compositing copy targets through the renderer GL backend mapping; the
  RTT-backed dirty-region readbacks now execute through the retained `RTT`
  region-readback helper rather than direct `glReadPixels`, and 2D framebuffer
  copies now execute through the retained utility bridge instead of direct
  `glCopyTexSubImage2D`.
- Canvas layer merge rendering and explicit layer-merge compositing now route
  depth/blend state, active texture units, fallback 2D texture unbinds, and
  merge framebuffer copy targets through the renderer GL backend mapping.
- Canvas layer cube/equirect generation and frame clears now share
  `legacy_ui_gl_dispatch` for active-texture selection, cube texture binding,
  viewport execution, blend capability execution, clear-color query/restore,
  and color-buffer clear adapter endpoints backed by tested renderer GL
  backend dispatch contracts. The cube-face framebuffer-to-texture copy now
  uses the shared retained target-aware utility bridge and remains tracked by
  DEBT-0036 until renderer services own copy execution.
- `NodePanelGrid` live heightmap drawing and bake setup now route depth/blend
  state, depth clears, color-write-mask toggles, active texture selection, and
  bake viewport execution through tested renderer GL backend dispatch
  contracts. Its desktop texture-resize readback now uses the retained
  `Texture2D::get_image()` helper, so it consumes the same tested
  framebuffer-backed texture readback dispatch instead of `glGetTexImage`.
  Grid depth-state and viewport snapshots now also use tested backend query
  dispatch.
- Retained simple UI draw paths now share `legacy_ui_gl_dispatch` for
  blend-state execution, fallback 2D texture unbinds, `NodeViewport` viewport
  query/restore, color-buffer clear, and clear-color restore. This covers
  `NodeBorder`, `NodeImage`, `NodeImageTexture`, `NodeColorWheel`,
  `NodeAnimationTimeline`, `NodeScroll`, `NodeText`, `NodeTextInput`, and
  `NodeViewport` without changing their legacy draw ordering.
- Retained paint UI surface paths now use tested `pp_renderer_gl` viewport
  query, clear-color query, clear-color restore, and color-buffer clear helpers
  in `NodeCanvas` and `NodeStrokePreview`, removing direct query/clear calls
  from those draw bodies while keeping their legacy compositing order. Their
  active-texture, fallback texture unbind, viewport/scissor, clear-color,
  color-buffer clear, and capability query/apply adapter endpoints are now
  centralized in `legacy_ui_gl_dispatch` instead of being duplicated in each
  node implementation.
- Retained Canvas stroke draw/commit, thumbnail, object-render, and
  `LayerFrame::clear` paths now use the same tested backend viewport query,
  clear-color query, and clear-color restore helpers, removing direct
  viewport/clear-state queries from `src/canvas.cpp` and the frame clear path.
  Their active-texture selection, fallback 2D texture unbind, viewport/scissor
  execution, clear-color restore, and capability query/apply adapter endpoints
  now share `legacy_ui_gl_dispatch`.
- Retained Canvas and RTT depth renderbuffer allocation, framebuffer depth
  attachment, and renderbuffer deletion now share
  `legacy_gl_renderbuffer_dispatch`, removing duplicated raw renderbuffer
  callbacks from `src/canvas.cpp` and `src/rtt.cpp` while resource lifetime
  ownership remains open under DEBT-0036.
- Retained `Texture2D`, `TextureCube`, and RTT texture allocation, deletion,
  binding, parameter setup, 2D update, and mipmap dispatch now share
  `legacy_gl_texture_dispatch`, removing duplicated raw texture callbacks from
  `src/texture.cpp` and `src/rtt.cpp` while texture resource ownership remains
  retained under DEBT-0036.
- Retained `Texture2D` readback plus RTT framebuffer allocation, deletion,
  bind/restore, blit, readback, and PBO readback dispatch now share
  `legacy_gl_framebuffer_dispatch`, removing duplicated raw framebuffer and
  readback callbacks from `src/texture.cpp` and `src/rtt.cpp` while
  framebuffer/readback ownership remains retained under DEBT-0036.
- Retained `Sampler` create, parameter, border-color, bind, and unbind dispatch
  now share `legacy_gl_sampler_dispatch`, and retained `PBO` allocation,
  framebuffer readback, map, unmap, and delete dispatch now share
  `legacy_gl_pixel_buffer_dispatch`; this removes another pair of raw resource
  callback clusters from `src/texture.cpp` and `src/rtt.cpp` while sampler and
  pixel-buffer ownership remain retained under DEBT-0036.
- Retained `Shape`, `TextMesh`, and `NodeColorWheel` mesh buffer/VAO creation,
  dynamic vertex/index uploads, fill/stroke/text draws, and buffer/VAO deletion
  now share `legacy_gl_mesh_dispatch`, removing duplicated raw mesh callback
  clusters from `src/shape.cpp`, `src/font.cpp`, and `src/node_colorwheel.cpp`
  while mesh resource ownership remains retained under DEBT-0036.
- Retained shader source compilation/deletion, program attach/link/use/delete,
  attribute rebinding and location lookup, active-uniform enumeration,
  uniform-location discovery, and vec/mat/scalar uniform writes now share
  `legacy_gl_shader_dispatch`, removing duplicated raw shader/program/uniform
  callback ownership from `src/shader.cpp` while shader-program ownership
  remains retained under DEBT-0036.
- Retained `gl_state` save/restore and `copy_framebuffer_to_texture_target`
  now reuse `legacy_ui_gl_dispatch`, `legacy_gl_framebuffer_dispatch`,
  `legacy_gl_shader_dispatch`, and `legacy_gl_sampler_dispatch`, removing the
  local raw state/copy callback cluster from `src/util.cpp` while renderer
  state and framebuffer-copy execution remain retained under DEBT-0036.
- Retained app startup, app clear, app UI viewport/scissor, command-convert
  renderer state, and desktop VR draw-state endpoints now share
  `legacy_ui_gl_dispatch` for capability, blend equation, clear, viewport,
  scissor, active-texture, and 2D texture-unbind callbacks, removing duplicated
  local raw callback clusters from `src/app.cpp`, `src/app_commands.cpp`, and
  `src/app_vr.cpp` while app/VR renderer execution remains retained under
  DEBT-0036.
- Retained RTT clear and masked-clear endpoints now share
  `legacy_ui_gl_dispatch` for boolean color-mask query, color-mask apply,
  clear-color, and buffer-clear callbacks, removing the local raw clear
  callback cluster from `src/rtt.cpp` while RTT render-target execution remains
  retained under DEBT-0036.
- Retained app startup logging, Windows early context logging/window-title
  detection, and shader capability detection now share
  `legacy_gl_runtime_dispatch` for runtime string and extension enumeration
  callbacks, plus the global OpenGL error drain and Windows debug message
  callback installation now use the same retained runtime dispatch. This removes
  duplicated raw runtime-query clusters from `src/app.cpp`, `src/main.cpp`, and
  `src/app_shaders.cpp` and the last active one-off runtime entrypoints from
  `src/util.cpp` and `src/platform_windows/windows_platform_services.cpp` while
  runtime/capability probing remains retained under DEBT-0036.
- Retained desktop HMD viewport setup, text atlas texture-unit activation,
  Windows/legacy platform default framebuffer binding, and platform render-hint
  enable callbacks now reuse `legacy_ui_gl_dispatch` instead of local raw GL
  adapters while renderer/context ownership remains retained under DEBT-0036
  and DEBT-0017.
- Canvas draw-merge shader-blend selection now consumes the extracted
  `pp_paint_renderer` stroke composite planner for current layer and primary
  brush blend modes, while preserving legacy OpenGL compositing execution under
  DEBT-0036.
- `NodeCanvas` panorama rendering now consumes the same tested
  `pp_paint_renderer` canvas blend-gate planner as `Canvas::draw_merge`, so
  layer and primary-brush blend-trigger compatibility is centralized.
- Shader initialization now publishes the OpenGL backend's renderer-neutral
  feature snapshot through the legacy shader manager, and live canvas blend
  gates consume that `RenderDeviceFeatures` value instead of hand-built
  framebuffer-fetch/texture-copy flags.
- Canvas draw-merge and `NodeCanvas` panorama shader-blend paths now use the
  shared canvas blend-gate plan to decide whether they can read destination
  color through framebuffer fetch or must copy the destination texture before
  the legacy OpenGL blend draw.
- `execute_canvas_draw_merge_branch_body()` now drops the unused
  `draw_checkerboard` flag from the branch helper chain, shrinking the helper
  API mismatch while `Canvas::draw_merge_branch_orchestration()` still owns the
  remaining branch orchestration. `STR-016` remains blocked until the helper
  shape is simplified enough to move the per-layer composite block out cleanly.
- `Canvas::draw_merge_branch_orchestration()` now drops the unused
  `draw_checkerboard` parameter from the internal branch wrapper, shrinking the
  remaining call-shape mismatch while the blocked draw-merge extraction still
  stays in `STR-016`.
- `Canvas::draw_merge_temporary_paint_branch()` now drops the unused
  `copy_blend_destination` parameter from the temporary-paint wrapper,
  shrinking the remaining draw-merge helper API mismatch while the blocked
  extraction still stays in `STR-016`.
- Canvas main-brush, dual-brush, and stroke-pad draw paths now use the tested
  `pp_paint_renderer` stroke-feedback plan to decide whether framebuffer fetch
  supplies destination color or the legacy OpenGL path must copy the target
  texture before drawing.
- Canvas stroke commit ordering now has a tested `pp_paint_renderer`
  `CanvasStrokeCommitSequencePlan` for history readback, dirty-state update,
  scratch copies, erase/composite draw selection, dilate, and texture slot
  roles. A retained commit adapter skeleton consumes that semantic sequence,
  while the live Canvas body still owns history/layer mutation and OpenGL
  execution until the next wiring slice.
- Live `Canvas::stroke_commit` now consumes that semantic commit sequence
  through retained callbacks, so the legacy body no longer owns the loop order
  directly. The callbacks still execute the existing OpenGL RTT, texture,
  sampler, shader, history, and layer mutation work under DEBT-0036.
- `Canvas::layer_merge` now shares the retained stroke composite shader helper
  for its source-over-destination `CompDraw` uniform setup, while keeping the
  existing layer dirty mutation, framebuffer copy, texture binding, and draw
  order local to the Canvas layer-merge path.
- `Canvas::draw_merge` now shares a retained draw-merge shader setup helper for
  checkerboard backgrounds and the final merged texture redraw over the grid;
  layer traversal, blend destination copies, sampler/texture binding, and draw
  ordering remain in the legacy Canvas path.
- `Canvas::draw_merge` non-stroke layer blending now shares the retained
  draw-merge shader setup helper for `TextureBlend` uniforms, preserving the
  optional `TexBG` uniform when copy-based blend destination feedback is active.
  Layer traversal, framebuffer copies, sampler/texture binding, and draw
  ordering remain in the legacy Canvas path.
- `NodeStrokePreview` preview background drawing now shares the retained
  draw-merge shader setup helper for checkerboard uniforms, preserving its
  colorize condition and preview MVP while leaving capture ordering, texture
  copies, stroke setup, and draw execution in the retained preview path.
- `NodeCanvas` live temporary-stroke drawing now shares the retained stroke
  composite shader setup helper for static `CompDraw` uniforms, while keeping
  per-onion-frame alpha updates, texture binding, temporary stroke texture
  selection, and draw execution in the retained node path.
- `NodeCanvas` live temporary-erase drawing now uses a retained stroke erase
  shader setup helper for `CompErase` uniforms, while keeping per-onion-frame
  alpha updates, texture binding, temporary erase texture selection, and draw
  execution in the retained node path.
- `Canvas::stroke_commit` and `Canvas::draw_merge` retained erase compositing
  now share the stroke erase shader setup helper, while texture binding, mask
  RTT binding, dirty/layer mutation, framebuffer feedback, and draw execution
  remain in retained Canvas code.
- Desktop VR temporary erase/draw compositing now shares the retained stroke
  erase and composite shader setup helpers, while sampler/texture binding,
  per-eye/view transforms, temporary stroke texture selection, and draw
  execution remain in retained VR code.
- `CanvasModeMaskCut::apply` retained mask-cut compositing now shares the
  stroke composite shader setup helper, while render-task ordering, framebuffer
  copy bounds, sampler/texture binding, and draw execution remain in retained
  canvas-mode code.
- `NodeStrokePreview::draw` now shares the retained draw-merge texture shader
  setup helper for preview `Texture`, `Tex`, and `MVP` setup, while preview
  texture binding, sampler binding, and draw execution remain in the node path.
- `Canvas::import_layer` retained object drawing now shares the draw-merge
  texture shader setup helper for flat and spherical imports, while texture
  upload, sampler/texture binding, draw callbacks, and object geometry remain in
  retained Canvas code.
- `Canvas::thumbnail_generate` checkerboard redraw and final texture blending
  now share the retained draw-merge shader setup helpers, while destination
  copies, sampler/texture binding, readback, and draw execution remain in
  retained Canvas code.
- `Canvas::draw_objects` retained object compositing now shares the retained
  draw-merge texture shader setup helper, while temporary face readback,
  sampler/texture binding, draw execution, and dirty-box updates remain in
  retained Canvas code.
- Desktop VR checkerboard background drawing now shares the retained draw-merge
  shader setup helper, while render state transitions, per-plane transforms,
  and draw execution remain in retained VR code.
- `Canvas::thumbnail_generate` thumbnail layer blending now shares the retained
  draw-merge shader setup helper for per-layer `TextureBlend` uniforms, while
  destination feedback copies, sampler/texture binding, grid redraw, readback,
  and draw execution remain in retained Canvas code.
- `NodeImage` and `NodeImageTexture` retained plain image drawing now share the
  retained draw-merge texture shader setup helper for `Texture`, `Tex`, and
  `MVP` setup. Texture/sampler binding, atlas setup, blend state, and draw
  execution remain in the node paths.
- `NodeViewport` preview drawing and `NodePanelGrid` sun-overlay drawing now
  share the retained draw-merge texture shader setup helper for `Texture`,
  `Tex`, and `MVP` setup. Texture/sampler binding, viewport/state setup, and
  draw execution remain in the node paths.
- Retained `TextureAlpha` setup now shares the draw-merge shader setup helper
  for Canvas draw-merge layer redraws, depth export merged-layer rendering,
  NodeCanvas cached/live layer redraws, and desktop VR layer redraws, while
  sampler/texture binding, render-task ordering, per-frame alpha updates, and
  draw execution remain in retained code.
- Depth export `TextureColorize` setup now shares the retained draw-merge
  shader setup helper, while sampler/texture binding, layer color selection,
  and draw execution remain in retained Canvas code.
- `NodeCanvas` smoothing-mask `TextureMask` setup now shares the retained
  draw-merge shader setup helper, while smoothing-mask texture binding,
  per-face MVP updates, blend state, and draw execution remain retained.
- Slider `Color` and `ColorHue` setup now shares retained UI overlay helpers,
  while slider geometry, hue direction, border drawing, and draw execution
  remain retained.
- Scrollbar, text-input, and VR frame/cursor/controller shader setup now share
  retained UI/preview helpers, while geometry, blend/depth state,
  sampler/texture binding, and draw execution remain retained.
- Remaining live shader setup outside retained helper headers now shares
  retained helper surfaces for canvas modes, equirect layer export,
  `NodeCanvas` debug dirty bounds, atlas image drawing, and text drawing, while
  geometry, framebuffer flow, texture/sampler binding, blend/depth state,
  readback, and draw execution remain retained.
- `Canvas::stroke_draw` face dirty-box planning now shares a retained stroke
  execution helper wrapping `pp_paint_renderer`, retained stroke commit step
  dispatch clamps malformed step counts to the fixed plan array, and
  compositor coverage now includes malformed retained commit plans plus
  all-input stroke-preview composite planning. Live stroke rasterization,
  callback execution, texture binding, and history mutation remain retained.
- `NodeStrokePreview::stroke_draw_compute` now shares the retained stroke
  execution helper for preview quad frame planning. Preview sample execution,
  mixer passes, texture binding, and final draw ordering remain retained.
- `NodeStrokePreview` preview stroke frame sequencing now shares one retained
  local executor for dual and main preview sample traversal, and
  `pp_paint_renderer_compositor_tests` now covers
  `legacy_node_stroke_preview_execution_services.h` feedback fallback clamping
  plus retained preview composite slot intent. Preview texture binding,
  framebuffer copies, mixer ownership, and final OpenGL draw ordering remain
  retained.
- `Canvas::stroke_draw` current and dual live sample frame/face traversal plus
  dirty tracking now share one retained stroke execution helper surface, while
  shader timing, sampler/texture binding, framebuffer ownership, pad execution,
  and final OpenGL draw ordering remain in the legacy Canvas path.
- `Canvas::stroke_draw` main and dual live-pass dirty semantics now share one
  retained helper around the face loop, current/dual dirty accumulation, and
  dual-pass dirty preservation, while shader timing, sampler/texture binding,
  framebuffer ownership, pad execution, and final OpenGL draw ordering remain
  in the legacy Canvas path.
- `Canvas::stroke_draw` main and dual live-pass per-face framebuffer
  bind/unbind execution now shares one retained helper, and pad-face array
  assembly now shares one retained utility, while shader activation timing,
  texture/sampler binding, framebuffer ownership, pad execution, and final
  OpenGL draw ordering remain in the legacy Canvas path.
- `NodeStrokePreview` dual-pass and main-pass frame-loop execution plus
  full-frame copy-back now share one local retained helper surface, while
  checkerboard/background capture ordering, texture-unit binding, mixer
  ownership, and final composite semantics remain in the preview node.
- `NodeStrokePreview` live-pass sampler binding, dual/main pass texture
  binding, checkerboard/background capture wrapping, and final preview
  copy-back now share named local helpers, while mixer state execution and
  per-sample GL ordering remain in the preview node.
- `Canvas::stroke_draw` main, pad, and dual live-pass texture-input
  binding/unbinding intent now shares retained stroke execution helpers, while
  sampler binding, concrete GL object mapping, framebuffer ownership, and final
  draw execution remain in the legacy Canvas path.
- `NodeStrokePreview::stroke_draw_samples` now shares one local helper for
  destination bind/unbind, framebuffer copy callback wrapping, sample-point
  assembly, and brush-vertex upload/draw, while mixer-pass state execution and
  higher-level pass orchestration remain in the preview node.
- `Canvas::stroke_draw` live-pass sampler bind/unbind plus semantic
  texture-input dispatch now shares retained stroke execution helpers, while
  concrete GL object mapping, framebuffer ownership, shader timing, and final
  draw execution remain in the legacy Canvas path.
- `Canvas::stroke_draw_samples()` now shares
  `execute_legacy_canvas_stroke_sample_polygon(...)` for polygon
  triangulation, sample-point assembly, and retained destination-copy / upload
  / draw helper handoff, while direct GL callback wiring and the remaining
  live draw ownership stay in the legacy Canvas path.
- `Canvas::stroke_draw_mix()` now shares
  `execute_legacy_canvas_stroke_mix_pass(...)` for visible-plane filtering,
  retained sampler/texture-slot binding, and final plane draw ordering, while
  mixer framebuffer/state setup and per-plane shader material/MVP preparation
  remain in the legacy Canvas path.
- `Canvas::stroke_draw_samples()` now shares
  `execute_legacy_canvas_stroke_face_sample_polygon(...)` for face-indexed
  destination bind/copy/unbind and brush upload/draw dispatch, while concrete
  GL object callbacks remain in the legacy Canvas path.
- `Canvas::draw_merge()` non-erase live temporary-stroke composite now shares
  `execute_legacy_canvas_stroke_temporary_composite(...)` for setup, sampler
  bind, texture bind, draw, and texture unbind ordering, while erase-path and
  broader final composite ownership remain in the legacy Canvas path.
- `Canvas::draw_merge()` erase live temporary-stroke composite now also shares
  `execute_legacy_canvas_stroke_temporary_composite(...)`, leaving only the
  concrete GL object callbacks and broader final composite ownership in the
  legacy Canvas path.
- `Canvas::draw_merge()` per-layer blend composite now routes merge-RTT
  unbind, shader setup, optional destination-copy feedback, draw, and cleanup
  ordering through `execute_legacy_canvas_draw_merge_layer_blend(...)`, while
  temporary-stroke branch selection, framebuffer copies, and final merged-plane
  ownership remain in the legacy Canvas path.
- `Canvas::draw_merge()` end-of-plane merged-texture copy plus optional
  checkerboard/final-texture redraw ordering now routes through
  `execute_legacy_canvas_draw_merge_final_plane_composite(...)`, while
  per-plane iteration and the concrete framebuffer, sampler, texture, and draw
  callbacks remain in the legacy Canvas path.
- `NodeStrokePreview::draw_stroke_immediate()` now shares
  `execute_legacy_node_stroke_preview_pass_sequence(...)` for
  dual-pass/background/main-pass/final-composite/copy-back ordering, while the
  remaining local preview ownership is concentrated around `stroke_draw_mix()`
  setup/execution.
- `NodeStrokePreview::draw_stroke_immediate()` now routes preview stroke
  max-size fallback, dual-preview max-size derivation, pattern-scale flips,
  and Bezier preview-point generation through
  `plan_legacy_node_stroke_preview_stroke_setup(...)`, while brush mutation,
  camera wiring, retained `Stroke` population, and live GL execution remain in
  the preview node.
- `NodeStrokePreview::stroke_draw_mix()` now shares one local helper for mixer
  framebuffer bind/unbind, viewport/scissor/blend state, texture-slot
  binding, and final plane draw, while material planning and shader uniform
  setup remain in the preview node.
- `NodeStrokePreview::stroke_draw_mix()` now routes retained mix-pass shader
  setup plus framebuffer/state/input/draw ordering through
  `execute_legacy_node_stroke_preview_mix_pass(...)`, while the preview node
  keeps only the concrete GL save/restore, texture-object bind, and plane-draw
  callbacks.
- `pp_paint_renderer_stroke_execution_tests` now covers retained stroke texture
  input binding order, sample execution destination-copy behavior, live-pass
  face-framebuffer dirty tracking, and pad-face destination-copy behavior
  without depending on the broader compositor test translation unit.
- `pp_paint_renderer_stroke_execution_tests` now also covers retained preview
  background capture ordering, final composite ordering, and preview
  texture-copy bind-before-copy behavior through
  `legacy_canvas_stroke_preview_services.h`.
- `pp_paint_renderer_stroke_execution_tests` now also covers direct retained
  frame-sample callback ordering and dirty-tracking wrapper timing/state
  semantics, including pre-update dirty visibility and
  `previous_pass_dirty_box` override behavior.
- `pp_paint_renderer_stroke_execution_tests` now also covers retained texture
  dispatch activation order and sampler-dispatch routing across brush tip,
  destination, pattern, and mixer helper inputs.
- `Canvas::stroke_draw` pad-pass destination bind/copy/unbind ordering now
  shares the retained stroke execution helper callback surface, while shader
  setup, pad color selection, framebuffer ownership, and final OpenGL draw
  remain in the legacy Canvas path.
- `NodeStrokePreview` final composite sampler/input binding and texture-slot
  intent now share one local retained helper, while mixer execution,
  per-sample stroke callbacks, framebuffer copies, and final OpenGL draw
  ownership remain in the preview node.
- `Canvas::stroke_draw` pad-region planning now shares the retained stroke
  execution helper wrapping `pp_paint_renderer`, while pad color selection,
  dirty-face iteration, framebuffer copies, quad upload, and draw execution
  remain retained.
- `Canvas::stroke_draw` pad-pass dirty-face iteration, pad-region planning, and
  NDC quad assembly now share a retained stroke execution helper callback
  boundary, while Canvas still owns framebuffer copies, brush-shape uploads,
  and draw execution.
- `Canvas::stroke_commit` retained commit input texture/sampler binding,
  erase/composite draw dispatch, committed-copy to the dilate scratch texture,
  and dilate draw now share `legacy_canvas_stroke_commit_services.h`; history
  readback, `ActionStroke` population, layer dirty-box mutation, and retained
  RTT/framebuffer ownership remain in the legacy Canvas path.
- `Canvas::stroke_draw_compute` frame planning now shares the retained stroke
  execution helper for brush-quad construction, mixer feedback bounds, 2D/3D
  projection selection intent, and frame assembly, while legacy projection
  geometry, stroke samples, and live draw execution remain retained.
- `Canvas::stroke_draw` current and dual stroke frame-face traversal now shares
  the retained stroke execution helper, while framebuffer binding, shader
  uniform timing, dirty-box mutation, sampler/texture binding, and live draw
  execution remain retained.
- `Canvas::stroke_draw` current and dual stroke dirty-box mutation now shares
  the retained stroke execution helper, while framebuffer binding, shader
  uniform timing, sampler/texture binding, and live draw execution remain
  retained.
- `Canvas::stroke_draw` current and dual stroke per-face framebuffer/sample
  callback ordering now shares the retained stroke execution helper, while
  framebuffer ownership, shader uniform timing, sampler/texture binding, and
  live draw execution remain retained.
- Remaining simple color, hue, color-quad, grid heightmap, and pen/line
  preview shader setup in UI nodes and canvas modes now shares retained helper
  surfaces, while geometry, texture/sampler binding, blend/depth state,
  readback, and draw execution remain retained.
- `NodeCanvas` density-resolve drawing and desktop VR UI drawing now share the
  retained draw-merge texture shader setup helper, while render target,
  sampler/texture binding, viewport/state restoration, and draw execution
  remain in retained node/VR code.
- `CanvasModeTransform::on_Draw` and `CanvasModeFloodFill::on_Draw` now share
  the retained draw-merge texture shader setup helper for `Texture`, `Tex`, and
  `MVP` setup. Active texture selection, sampler/texture binding, and draw
  execution remain in the canvas mode paths.
- `Canvas::stroke_draw_mix` now shares the retained stroke composite shader
  helper for mixer-pass `CompDraw` setup, while preserving its caller-specific
  texture slot uniforms. Mixer framebuffer/scissor state, sampler and texture
  binding, and draw execution remain retained.
- `NodeCanvas` panorama drawing now uses the same retained draw-merge shader
  setup helper for checkerboard grids and final cached-layer texture redraws;
  onion-frame traversal, blend copies, texture binding, and draw order remain
  retained in the node.
- `NodeCanvas` non-stroke layer blending now shares the retained draw-merge
  shader setup helper for `TextureBlend` uniforms, preserving the optional
  destination texture uniform when copy-based blending is active. Framebuffer
  lifetime, destination copies, texture binding, and draw execution remain
  local to the node.
- Canvas thumbnail layer blending now uses the same canvas destination-feedback
  plan for framebuffer-fetch versus texture-copy decisions; the thumbnail draw
  itself still executes through retained OpenGL canvas code under DEBT-0036.
- Canvas equirectangular import drawing and depth export rendering now route
  depth/blend state and active texture units through the renderer GL backend
  mapping.
- Canvas thumbnail generation and object-drawing helpers now route saved
  viewport/clear/blend state, active texture units, readback format/type,
  framebuffer copy targets, and depth renderbuffer allocation plus framebuffer
  depth attach/detach through tested renderer GL backend dispatch contracts;
  `src/canvas.cpp` no longer owns raw renderbuffer callbacks.
- Retained Canvas, NodeCanvas, NodeStrokePreview, and HMD viewport/scissor/
  capability execution now compiles through the renderer GL backend dispatch
  adapters with `pp_legacy_paint_document`, `pp_panopainter_ui`, and
  `panopainter_app`. CanvasMode overlay, mask, transform, and canvas-tip pick
  paths now also consume the shared retained UI GL dispatch for active-texture,
  capability query/apply, viewport, read-framebuffer query, and RGBA8 pixel
  readback adapter endpoints, removing another local raw-GL adapter cluster
  from `src/canvas_modes.cpp`. `NodePanelGrid` heightmap draw and bake setup
  now uses the same bridge for active-texture, depth/blend capability
  query/apply, viewport query/execution, depth clears, and color-write-mask
  adapter endpoints instead of owning another local dispatch cluster.
- Windows desktop OpenGL context creation now consumes a tested
  `windows_wgl_core_context_3_3_config()` catalog from `pp_renderer_gl`, moving
  the active WGL context/pixel-format attribute literals out of the platform
  entrypoint.
- Known remaining warnings: legacy project/vendor diagnostics, Visual Studio
  vcpkg-manifest warning, `LNK4099` missing libyuv PDBs, and `LNK4098` runtime
  library conflict from retained vendor binaries.

## Current Debt Log

The canonical debt log is now `docs/modernization/debt.md`. Keep this section
as a reminder only; do not add new debt entries here.

| ID | Status | Owner | Item | Reason | Validation | Removal Condition |
| --- | --- | --- | --- | --- | --- | --- |
| DEBT-0001 | Open | TBD | Existing platform build files remain alongside new CMake | Required for incremental migration | Existing platform builds plus new CMake configure | Remove after all platform builds consume shared CMake targets |
| DEBT-0002 | Open | TBD | Vendored SDK and patched libraries retained initially | Some dependencies are SDK-only or have platform-specific binaries | Dependency inventory and platform build smoke tests | Replace or document permanent vendored status after vcpkg triplet evaluation |
| DEBT-0003 | Open | TBD | Existing singletons remain during initial split | Avoid behavior changes while introducing boundaries | App launch and component tests | Replace singleton reaches with context/service injection at component boundaries |

## Current Capability Map Seed

Use this as the starting checklist for Phase 0 inventory.

- Project I/O: PPI open/save, thumbnails, version metadata, autosave/save-as
  flows.
- Image I/O: JPEG/PNG import/export, cube faces, equirectangular export,
  depth export.
- Brush system: ABR import, PPBR import/export, presets, tip/pattern/dual brush,
  pressure, jitter, blend modes.
- Painting: six cube faces, temporary stroke buffers, erase, flood fill, masks,
  alpha lock, layer compositing.
- Layers and animation: layer add/remove/move/merge, blend/opacity/visibility,
  frame add/remove/duplicate/duration, MP4/timelapse export.
- UI: XML layout, Yoga layout, panels, dialogs, color tools, brush tools,
  layers, animation timeline, settings, shortcuts, manual/changelog/about.
- Input: mouse, keyboard, touch, gestures, Wacom tablet, stylus pressure,
  VR controllers.
- Platform services: clipboard, file picker, save picker, directory picker,
  share/display file, keyboard show/hide, cursor visibility.
- VR/platform variants: OpenXR desktop target with retained OpenVR fallback,
  Quest, Focus/Wave, Android standard,
  iOS/macOS, Linux, WebGL.
- Cloud/network: upload, download, browse, license/check flows.
- Recording/export: PBO readbacks, MP4 encoder, timelapse frames.
