From d7fc7a3fe56a9b96330bde4886853801a2639398 Mon Sep 17 00:00:00 2001 From: provos Date: Thu, 24 Sep 2026 22:13:20 -0700 Subject: [PATCH] test: frontend-neutral e2e driver and Svelte migration plan - Move all Dash selectors/gestures behind a UiDriver interface (e2e/drivers/dash.ts); scenarios use only the driver, image helpers and the /__e2e__ oracle. Assertions are unchanged. - Add uiTarget Playwright option, a 'dash' project, requireWorkflow() skips, e2e tsconfig and a CI type-check step. - Add the Svelte 5 migration handoff, target architecture/API contract and a 92-row Dash callback parity checklist; point CLAUDE.md at them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/python-app.yml | 2 + CLAUDE.md | 12 +- docs/SVELTE_5_MIGRATION_HANDOFF.md | 373 ++++++++++++++++++++++ docs/svelte-migration/ARCHITECTURE.md | 151 +++++++++ docs/svelte-migration/PARITY.md | 288 +++++++++++++++++ e2e/README.md | 57 +++- e2e/drivers/dash.ts | 281 +++++++++++++++++ e2e/drivers/types.ts | 110 +++++++ e2e/fixtures.ts | 33 +- e2e/helpers/{app.ts => image.ts} | 180 +---------- e2e/helpers/oracle.ts | 82 +++++ e2e/parallax-maker.spec.ts | 432 ++++++++++++-------------- e2e/playwright.config.ts | 46 ++- e2e/tsconfig.json | 17 + package-lock.json | 396 ++++++++++++++++++++++- package.json | 7 +- 16 files changed, 2036 insertions(+), 431 deletions(-) create mode 100644 docs/SVELTE_5_MIGRATION_HANDOFF.md create mode 100644 docs/svelte-migration/ARCHITECTURE.md create mode 100644 docs/svelte-migration/PARITY.md create mode 100644 e2e/drivers/dash.ts create mode 100644 e2e/drivers/types.ts rename e2e/helpers/{app.ts => image.ts} (50%) create mode 100644 e2e/helpers/oracle.ts create mode 100644 e2e/tsconfig.json diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8357b33..ffe7e89 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -46,6 +46,8 @@ jobs: run: | npm ci npx playwright install --with-deps chromium + - name: Type-check browser tests + run: npm run check:e2e - name: Test browser workflows run: npm run test:e2e - name: Upload browser-test diagnostics diff --git a/CLAUDE.md b/CLAUDE.md index 477b16d..ce0546c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,4 +126,14 @@ pytest parallax_maker/test_filename.py::test_function_name - GPU recommended for reasonable performance - Memory usage scales with image size and model complexity - TailwindCSS compilation required only when modifying styles -- SD3 Medium requires latest diffusers from GitHub \ No newline at end of file +- SD3 Medium requires latest diffusers from GitHub + +## Svelte 5 migration (in progress) + +The Dash UI is being replaced by a Svelte 5 frontend. Dash is frozen as the +behavioral reference; do not add new Dash adapters. Read, in order: +`docs/SVELTE_5_MIGRATION_HANDOFF.md` (background and plan), +`docs/svelte-migration/ARCHITECTURE.md` (target design and API contract), and +`docs/svelte-migration/PARITY.md` (per-callback checklist and progress). +Browser scenarios in `e2e/` are frontend-neutral and run through a `UiDriver` +(`e2e/drivers/`); keep new scenarios free of framework-specific selectors. diff --git a/docs/SVELTE_5_MIGRATION_HANDOFF.md b/docs/SVELTE_5_MIGRATION_HANDOFF.md new file mode 100644 index 0000000..e2323c5 --- /dev/null +++ b/docs/SVELTE_5_MIGRATION_HANDOFF.md @@ -0,0 +1,373 @@ +# Svelte 5 migration handoff + +Prepared 2026-09-24 from local `main` at +`ee6f4ea58d9f6bb6667c086d07becb86d96cd15f`. + +## Objective and working agreement + +Migrate the whole Parallax Maker browser application from Dash to Svelte 5, +retaining Python for inference, image processing, project persistence, and export. +Preserve the existing workflows and image fidelity. The user previously deferred +Svelte while we extracted services and established browser characterization; +that preparation is now merged. This document is the continuation plan, not a +claim that a Svelte frontend or production HTTP API already exists. + +The user favors autonomous implementation, agent supervision, meaningful tests, +and incremental PRs. Before moving each workflow, determine what the existing +Playwright tests actually prove, add characterization for uncovered behavior, +then extract and migrate it. Keep expensive model calls deterministic in routine +tests while exercising real composition, masks, persistence, and exports. +Use separate implementation/test/review lanes if delegation is authorized for the +continuing task. Prior work used independent review and fixed actionable findings +before handoff. PR creation, CI/Copilot review, and squash merging have been done +on explicit requests; this document does not authorize a future merge or paid +model execution. + +## Starting point + +The working tree was clean before this document was added. Local history contains: + +| Commit / PR | Delivered | +| --- | --- | +| `a6a99a8`, [#4](https://github.com/provos/parallax-maker/pull/4) | Deterministic offline Playwright harness and first characterization suite. | +| `83b31d0`, [#5](https://github.com/provos/parallax-maker/pull/5) | Core upload/depth/threshold/slice-generation services. | +| `036f917`, [#7](https://github.com/provos/parallax-maker/pull/7) | Point segmentation and multipoint services, validation, expanded tests. | +| `ee6f4ea`, [#8](https://github.com/provos/parallax-maker/pull/8) | Inpainting/mask/version services, adapter tests, browser coverage, review fixes. | + +PR #8 is already squash-merged into this checkout. Do not restart from the old +`codex/extract-inpainting-services` branch. Refresh remote status before starting +implementation and create an appropriately scoped `codex/` branch. + +Last recorded verification before #8 merged: **258 Python tests and 11 Playwright +scenarios passed**, and GitHub CI was green. These are historical results, not a +fresh test run on the date of this document. The handoff was checked against the +current source; no application tests were rerun for this documentation-only edit. + +## Architecture and source map + +| Source | Role / migration relevance | +| --- | --- | +| `parallax_maker/webui.py` | Dash app, layout/stores, callback registration, entry point, image serving, and remaining orchestration. Creates three service instances. | +| `parallax_maker/components.py` | UI construction and several nested callback groups; still contains business operations. | +| `parallax_maker/clientside.py`, `parallax_maker/assets/scripts/utility.js` | Dash/browser wiring, canvas painting, image geometry, zoom/pan, point markers and mask preview. Must be replaced with owned frontend state and event handling. | +| `parallax_maker/constants.py` | Existing element IDs and UI constants; useful for parity inventory and selectors. | +| `parallax_maker/controller.py` | `AppState`, process-global cache, filesystem persistence, composition, camera and upscaler lifecycle. Still mixes domain and presentation methods. | +| `parallax_maker/slice.py` | `ImageSlice`, filenames, image versions, undo/redo. | +| `parallax_maker/workflow_services.py` | Core workflow commands/results and repository abstraction. | +| `parallax_maker/segmentation_services.py` | Point/multipoint commands, mask combination, validation and model lifecycle. | +| `parallax_maker/inpainting_services.py` | Masks, prompts/model settings, candidates, apply/erase, versions, artifact repository. | +| `parallax_maker/segmentation.py`, `camera.py`, `gltf.py`, `gltf_cli.py` | Image/slice/render/export algorithms and independent CLI support. | +| `parallax_maker/inpainting.py`, `depth.py`, `instance.py`, provider modules | Real model/provider integrations. Keep algorithms and provider functionality in Python. | +| `e2e/`, `parallax_maker/e2e_server.py`, `parallax_maker/e2e_support/` | Existing behavior oracle and deterministic test-only composition root. | +| `pyproject.toml`, `package.json`, `.github/workflows/python-app.yml` | Python packaging, current Tailwind/Playwright tooling, CI. No Svelte dependencies today. | + +The normal entry point is `parallax-maker = parallax_maker.webui:main`. +The three service modules contain no Dash imports or callback concepts, but they +still operate on `AppState`, PIL images and NumPy arrays. They are reusable Python +boundaries, **not JSON APIs**. An HTTP presenter must encode results and assets; +do not serialize service objects or the whole `AppState` indiscriminately. + +## Completed service contracts to preserve + +### Core workflow + +`WorkflowService` implements `upload_image`, `generate_depth`, +`configure_thresholds`, `update_threshold_values`, and `generate_slices`. +Use its existing command/result dataclasses and dependency injection. + +- Repository saves take explicit `(state_id, state, StateSaveOptions)`; the command + identity, rather than a mutable filename field, determines the destination. +- Upload validates PIL input before creating a state. It caches the image but + does not perform a full project save; existing presentation code lazily writes + an input image when serving it. +- Depth generation resets thresholds, reuses a matching model, and saves JSON + plus depth only: slices/input flags are false. +- Threshold operations mutate cached state without saving. Boundary count must + be `num_slices + 1`; fallback boundaries include 0 and 255. Missing/invalid + readiness raises `WorkflowNotReady`; no change raises `WorkflowUnchanged`. +- Slice generation requires image/depth/valid boundary count, uses expansion 5, + assigns slices and performs a full save. +- The service returns image payloads where needed; Dash owns URLs and rendering. + +### Segmentation + +`SegmentationService` implements `select_depth_point`, `select_instance_point`, +`commit_multi_point`, and `set_multi_point_mode`. + +- Plain/Shift/Ctrl clicks map to replace/add/subtract; Shift wins when both + modifiers are present. Single Ctrl still sends a positive point to the model, + then subtracts its resulting mask. Multipoint Ctrl instead queues a negative + model prompt. These are different semantics. +- Queueing does not change the committed mask or run inference. Commit partitions + positive/negative points, applies the result, and retains queued points. Toggling + multipoint mode clears the queue. A valid queued commit does not require a + current depth map afterward. +- A selected slice supplies `slice_image_composed(..., CompositeMode.NONE)` as + inference input. Otherwise the source image is used. Preserve preview semantics. +- Commands validate coordinates, source/threshold readiness, masks, and selected + indices. New model instances are cached only after successful interface, + inference and mask validation; a failed first attempt must be retryable. +- These interactions do not save the project. Invert/feather/checkerboard actions + still live in component callbacks and have not been extracted. + +### Inpainting, masks and versions + +`InpaintingService` implements `save_mask`, `load_mask`, `delete_mask`, +`update_prompts`, `update_model`, `generate_candidates`, `select_candidate`, +`clear_selection`, `apply_candidate`, `erase`, and `move_slice_version`. + +- Canvas alpha becomes an L mask resized BICUBIC to source dimensions. Mask + save/delete changes its artifact, without a project JSON save. The adapter + renders loaded mask pixels as RGBA `(r, 0, 0, r)`. +- Paint uses the saved mask; Fill uses inverse slice alpha. Both patch a **copy** + of source pixels and generate three candidates with real blur/composition. + Generating candidates must not mutate the slice before Apply. +- Enhance generates two candidates, resizes LANCZOS to original dimensions, and + restores the original alpha. It still delegates upscaling through `AppState`. +- Generation persists normalized prompts before model work; they survive model + failure. Old candidate selection survives failed generation and clears only + when the complete replacement set succeeds. +- Selecting the same candidate toggles selection off. Slice/model changes clear + stale selection/candidates. Apply requires a valid selection; the Dash adapter + additionally checks that the corresponding candidate is visibly selected. +- Candidate pixels currently live in browser data URLs. Apply decodes them and + passes a sequence of PIL images to the service. Candidate storage by server ID + would be a new transport design, not an existing facility. +- Apply/Erase write an image version and then save JSON/file mapping only. Undo + and Redo use the same JSON-only save semantics. Erase now logs once. +- Generation currently passes `crop=True` regardless of the ROI checkbox; the + checkbox controls the bounding-box preview. Decide separately whether to change + this product behavior, with explicit regression tests. +- Cache reuse uses transient `AppState.inpainting_pipeline_cache_identity`, with + both pipeline object identity and model/server/API/workflow configuration. + Workflow digest is relevant only to ComfyUI. Do not attach cache fields to + injected pipeline objects or compare mutable loaded pipeline dimensions. +- Replacing a pipeline invalidates its upscaler. Texture export still calls the + older `create_inpainting_pipeline` helper, so it can replace the shared pipeline; + the identity check deliberately detects that replacement. +- The adapters now log generation/apply/mask-save domain and malformed-transport + errors. They decode workflow uploads only for ComfyUI. Preserve useful visible + error feedback when replacing `PreventUpdate` with API errors. + +Version writes and JSON mapping saves are not a cross-file transaction. Global +cache/progress and mutation during callbacks also remain concurrency limitations. + +## Remaining application scope + +Inventory every user-facing action, not just the 11 passing browser scenarios. +These are the main remaining callback groups to extract or deliberately place in +the frontend: + +| Workflow | Current locations | Required continuation | +| --- | --- | --- | +| Slice editing | `webui.delete_slice_request`, `copy_to_clipboard`, `paste_clipboard_request`, `remove_mask_slice_request`, `add_mask_slice_request`, `create_single_slice_request`, `balance_slices_request`, `record_depth_input`, `slice_upload` | Characterize alpha/depth/order/selection, clipboard semantics, upload composition, versioning and exact saves; extract Python commands. | +| Mask tools and display | `components.make_segmentation_callbacks`, `webui.display_slice`, `update_slices`, `update_depth_map_callback` | Extract remaining mutations; separate raw images from checkerboard/overlay presentation. Keep selection invalidation explicit. | +| Project lifecycle | `webui.restore_state`, `save_state`, all `restore_*`, `controller.from_json/fill_from_files/to_file` | Create project service and a public state projection; test old projects and missing/corrupt artifacts. | +| Export/render | `webui.export_state_as_gltf`, `gltf_create`, `gltf_export`, `export_animation`, `upscale_texture`, `download_image` | Move orchestration into export jobs/services; characterize displacement, DOF, upscaled assets and downloads. Keep `parallax-gltf-cli` working. | +| Configuration | `components.make_configuration_callbacks`, `webui.remember_*`, external-server/API/workflow restore callbacks | Extract model/server/credential/workflow/camera persistence and connection probes; retain provider-specific behavior. There are two functions named `remember_camera_parameters`; inspect their callback decorators. | +| Browser interactions | `clientside.py`, `utility.js`, navigation/tools/tab/theme callbacks | Port canvas/brush/eraser/load/clear, point overlays, crop box, zoom/pan/reset, tabs, progress/logs and responsive layout into Svelte. | +| Delivery | Python entry points/package data, Dockerfile, workflows, README | Bundle compiled frontend, serve it reliably, verify installed-package/container behavior and document startup. | + +Known baseline issues that must be triaged rather than silently copied or +accidentally hidden by migration: + +- `AppState.balance_slices_depths` contains `for i in len(self.image_slices)` and + divides by `len - 1`; characterize then fix its normal/empty/single-slice cases. +- State upload is JSON referencing server-side files; it is not a self-contained + browser project upload. Restore requires the corresponding `appstate-*` + directory and images on the server. Keep compatibility; a portable archive is + a separate format/behavior decision. +- Animation writes `rendered_image_%03d.png` server-side; no download currently + fires, and the browser test intentionally records that behavior. +- Depth-point behavior at the first threshold has a preserved wraparound/empty + mask case. Inspect its service test before changing the algorithm. +- The production main-image URL uses second-resolution cache busting. E2E patches + this to a monotonic version, so passing tests do not prove production refresh + correctness. Use explicit revisioned assets in the new transport. +- Existing Dockerfile still references `/app/webui.py`, while current code is + packaged under `parallax_maker`; verify/fix packaging during delivery work. + +## Recommended target and sequence + +The following is a proposed design, not an implemented or user-mandated stack +choice beyond Svelte 5 and the retained Python backend. A Svelte 5 + TypeScript + +Vite client and a small Python HTTP layer are a suitable starting point. Reusing +the existing Flask infrastructure is the lowest-change API option; a new framework +should have a concrete benefit. Verify current official framework documentation +and compatible versions when scaffolding, and commit the dependency lockfile. + +1. **Complete the parity inventory and baseline.** Run the current suite, enumerate + all controls/callbacks, and capture representative UI screenshots. Record + uncovered actions and intentional fixes. The existing tests are behavioral + image assertions, not a complete visual/layout acceptance suite. +2. **Extract remaining backend workflows incrementally.** Start with slice editing + and remaining mask operations, then project/configuration and export. Add the + missing browser characterizations before extraction; add service and adapter + contracts alongside it. Keep working Dash adapters during this phase. +3. **Introduce an explicit application composition root and HTTP contracts.** Wire + repositories, real/fake providers and per-project execution there, independently + of `webui` imports. Start with upload → depth → thresholds → generated slices + as the first usable frontend/API vertical slice. +4. **Migrate successive Svelte workflows.** Add selection and point segmentation, + slice editing, canvas/masks and inpainting, project/settings restoration, then + previews/export. Preserve loading, disabled controls, logs and retry behavior + along with successful images. Establish an API test fixture interface before + retargeting browser tests. +5. **Run Dash and Svelte parity suites while both exist.** Use UI adapters for + framework-specific selectors/events, sharing behavioral assertions and fake + providers. Keep both targets green as each supported workflow migrates. +6. **Cut over and package.** Build assets into the Python distribution, select the + Svelte UI as the normal entry point, document migration and compatibility, and + remove Dash/runtime callback JS only after every required workflow has parity + and the replacement has standalone startup tests. + +Suggested API design constraints: + +- Use explicit state/project IDs, slice IDs or validated indices, and state + revisions. Expose a deliberate JSON projection: metadata, settings, selections, + availability of undo/redo, busy state and revisioned asset references. +- Keep PIL/NumPy/model objects and credentials out of public state responses. + Resolve asset IDs within a project's directory, with proper path containment; + do not turn the legacy raw filename route into an unrestricted file API. +- Use binary uploads/assets or bounded data URLs as an initial transport. If + candidates become server assets, bind each set to project, slice, source + revision and generation ID; reject applying a stale set to a different slice. +- Map domain errors to documented HTTP error codes/messages; keep no-op results + distinct from failures. Retain detailed internal diagnostics without exposing + credentials or provider payloads to browser logs. +- Real inference is slow. Define jobs and progress (polling is sufficient at first), + serialize conflicting mutations per project, and prevent stale responses from + overwriting newer selections/images. Do not assume the process-global cache + works across multiple worker processes. Start with an explicit supported + single-process execution model, then expand deliberately. +- Svelte owns transient UI state and coordinate transforms. Persisted project + state has one authoritative backend representation. Avoid reproducing Dash's + trigger stores and callback cycles as frontend reactive loops. + +## Tests: existing oracle and gaps + +`e2e/parallax-maker.spec.ts` currently has 11 scenarios: + +1. Upload → deterministic depth → three slices with pixel signatures. +2. Instance point replace/Shift union/Ctrl subtraction with exact mask regions. +3. Positive/negative multipoint queue/commit and toggle/reset behavior. +4. Depth click coordinates, depth, log and mask. +5. Selected-slice segmentation input, observed via a test-only source tag. +6. Painted mask → three candidates, inside/outside composition, prompts, + Apply/version change and Undo. +7. Fill candidates favor transparent regions, fill their alpha, and retain opaque + sample alpha; the test checks relative color preservation, not exact opaque RGB. +8. Enhance creates two same-size candidates while retaining alpha. +9. Erase removes painted alpha and supports Undo/Redo. +10. Restore checks exact input/depth signatures and selected controls/settings. +11. glTF download checks three meshes/textures and inline PNGs; animation checks + four valid 320×240 frames with distinct hashes and no browser download. + +Relevant Python contracts are in `test_workflow_services.py`, +`test_workflow_adapters.py`, `test_segmentation_services.py`, +`test_segmentation_adapters.py`, `test_segmentation_component_adapters.py`, +`test_inpainting_services.py`, `test_inpainting_component_adapters.py`, and +`test_inpainting_webui_adapters.py`. Keep backend contracts after retiring Dash; +replace Dash adapter contracts with API/frontend contracts. + +Missing or incomplete browser coverage includes general slice editing/clipboard, +depth-mode modifier combinations, invert/feather, canvas clear/load and zoom/pan, +candidate deselection and failed-generation recovery, provider configuration +failures, project save→restore round trips, displaced/DOF/upscaled export, actual +3D viewer rendering, responsiveness and keyboard accessibility. Some of these +already have service/adapter unit tests; do not describe them all as untested. + +The current suite is **not frontend-neutral**: `helpers/app.ts` knows Dash JSON IDs, +dropdowns and sliders, and scenario bodies also use Dash-specific selectors, +classes and raw `/__e2e__/` requests. A base-URL change alone cannot retarget it. +Move those interactions behind an adapter and preserve image/state oracles. +Use real clicks and pointer gestures, not forced clicks or synthetic dispatch +that bypasses hit testing. Select the visible tab before using a slice thumbnail. + +Pixel pitfalls: thumbnails contain a checkerboard composite whereas raw slice +artifacts/candidates may contain transparency. Compare like representations. +Real feathering requires the small existing tolerance for inside-mask colors; +outside-mask preservation is exact. Do not weaken these checks to image counts +or “something changed.” + +## Deterministic harness details + +- `python -m parallax_maker.e2e_server` is an explicit test-only entry point. It + creates an isolated temporary working directory and cleans it up on SIGTERM. +- `/__e2e__/ready` reports palettes/check size; `/__e2e__/fixture/input.png` serves + the fixture; each GET of `/__e2e__/fixture/state.json` creates a fresh project + directory/cache key. `/__e2e__/state?filename=...` exposes mask/selection/version + metadata; `/__e2e__/artifacts?filename=...` lists files and + `/__e2e__/artifact//` serves a contained artifact. +- These are test observation/setup endpoints, not the production API. Keep them + absent from normal startup. State fixture images already exist server-side + when its JSON is uploaded through the real restore UI. +- `install_fakes()` currently imports `webui`, rebuilds its depth service with a + fake factory, uses runtime segmentation/inpainting factories, patches upscaling + and provider probes, blocks `requests` networking, and disables external UI + scripts/viewer loading. Factor shared fake dependencies into the new composition + root; merely patching the old Dash module will not fake a new API service. +- Preserve real mask handling, patching, composition, version files and exporters. + Fake only expensive provider/inference boundaries and required external probes. + This does not validate real model quality, downloads, GPU memory or credentials. +- One Playwright worker, fresh server, fixed viewport/DPR and unique restore states + prevent cross-test pollution. Keep diagnostics for console errors, failed + requests, unexpected HTTP errors, screenshots, video and traces. +- Wait on observable state/images; Dash's periodic progress polling prevents + reliable `networkidle` waits. Existing canvas save happens on `mouseout`, not + `mouseup`; `drawCanvasStroke` explicitly exits the canvas. A new save-on-pointerup + contract can be an intentional improvement, but must test pending-save ordering + before Generate and slice changes. +- The old JS keeps `currentSlice` asynchronously and clears canvases on image + changes. Give the new canvas an explicit lifecycle so selection, resize or + inference completion cannot silently discard an unsaved stroke. + +## Setup and validation commands + +Run from repository root. Prefer a working Python 3.12 environment; project +metadata permits Python >=3.10,<3.14, while CI currently uses Python 3.10 and +Node 24. An old local `.venv` symlink has broken after Python upgrades before; +check it before diagnosing application failures. No special interpreter path +from an earlier agent session should be assumed to exist. + +```sh +python3.12 -m venv .venv +.venv/bin/python -m pip install -e '.[dev]' +npm ci +npx playwright install chromium +.venv/bin/python -m pytest -o addopts='' -q +E2E_SERVER_COMMAND='.venv/bin/python -m parallax_maker.e2e_server --host 127.0.0.1 --port 8050' npm run test:e2e +git diff --check +``` + +Use the environment setup only when needed. Normal CI runs `pytest` with configured +coverage; `-o addopts=''` above avoids coverage overhead/shared coverage-file races +for local verification. CI also runs fatal flake8 checks, `npm ci`, Chromium +installation, and Playwright. Browser diagnostics are configured under +`e2e/test-results/`. Local browser/server tests may require sandbox approval to +bind localhost; distinguish that from application test failures. + +For the new frontend, add type checking, a production build, API contracts and +installed-asset startup checks to CI. Retain `package-lock.json`; do not rely on +unrecorded global Node tooling. Inspect Python package-data patterns when adding +nested Vite assets; a source checkout passing is insufficient evidence that a +wheel contains the frontend. Build/verify Docker and the independent glTF CLI +before declaring distribution parity. + +## Completion gate and first next step + +Migration is complete when every inventoried workflow works through Svelte, +the shared deterministic browser suite and new gap tests pass, backend contracts +remain green, errors/progress/retries are usable, existing saved projects restore, +and a built distribution runs the full UI without Dash. Include visual review at +representative viewport sizes, real viewer verification, and a bounded real-model +smoke test when execution resources/credentials are authorized. Mocked tests alone +cannot establish real-provider compatibility. + +The next agent should first baseline the merged `main`, create a feature/parity +checklist from the remaining callback inventory, and characterize **slice editing +plus remaining mask tools**. That is the next bounded extraction. Also define +the project/state/asset API contract that will let the +already-extracted upload/depth/slices workflow become the first Svelte vertical +slice. Keep each PR independently runnable and reviewable; report actual test +results, intentional behavior changes and remaining gaps at each handoff. diff --git a/docs/svelte-migration/ARCHITECTURE.md b/docs/svelte-migration/ARCHITECTURE.md new file mode 100644 index 0000000..994785b --- /dev/null +++ b/docs/svelte-migration/ARCHITECTURE.md @@ -0,0 +1,151 @@ +# Svelte migration: target architecture and API contract + +Status: design for PR 1–2. Plan and background are in +[`../SVELTE_5_MIGRATION_HANDOFF.md`](../SVELTE_5_MIGRATION_HANDOFF.md); +progress is tracked in [`PARITY.md`](PARITY.md). + +## Decisions (2026-09-24) + +- **Dash is frozen as the reference UI.** It is not rewired to new services. + New behavior is characterized on Dash with shared e2e scenarios, extracted into + framework-neutral services, exposed through the API, implemented in Svelte, and + the same scenarios must pass on both UIs. +- **Look and feel stays as-is for now.** A redesign is planned later, so the + Svelte UI keeps colors, spacing and typography in CSS custom properties / + Tailwind theme tokens and keeps layout in a few shell components. +- **One process during the migration.** The existing Dash Flask server also + serves the API (`/api/v1/`) and the built Svelte app (`/next/`). Both UIs share + the `AppState` cache and `appstate-*` directories, so a project can be opened in + either. The supported execution model is a single process. +- **Project ID = `appstate-*` directory name**, so existing projects, the e2e + oracle (`/__e2e__/state`) and artifacts endpoints work unchanged. + +## Backend layout + +``` +parallax_maker/ + runtime.py # composition root: Runtime dataclass (factories, services, registry) + api/ + __init__.py # create_api_blueprint(runtime) -> flask.Blueprint + schemas.py # pydantic v2 request/response models (single source of truth) + projects.py # project/asset/threshold/slice routes + jobs.py # JobManager: single worker thread, progress, results + errors.py # domain exception -> HTTP mapping + assets.py # logical asset IDs -> files inside the project dir (containment checked) + server.py # create_server(runtime): Dash app + API blueprint + /next static; main() + static/next/ # built Svelte app (generated by `npm run build:frontend`; packaged) +frontend/ # Svelte 5 + TS + Vite source +``` + +`Runtime` owns: depth/segmentation/inpainting/upscaler factories, the three +existing services built from those factories, the `ProjectRegistry` +(per-project `threading.RLock`, revision counter, active job ID) and the +`JobManager`. `runtime.create_runtime()` builds production dependencies; +`e2e_support.fakes.create_fake_runtime()` builds fakes. `install_fakes()` keeps +patching the frozen Dash module *and* the e2e server passes a fake runtime to +`create_server`, so both UIs use the same fakes. + +Entry points: `parallax-maker` switches to `server:main` (Dash at `/`, Svelte at +`/next/`) once PR 2 lands; at cutover Svelte moves to `/` and Dash is removed. + +## HTTP contract (v1) + +All JSON is camelCase. Errors: `{"error": {"code": str, "message": str}}`. + +| Situation | Status | code | +| --- | --- | --- | +| Malformed body / invalid parameter | 400 | `invalid_request` | +| Unknown project/slice/job/asset | 404 | `not_found` | +| `WorkflowNotReady` / service validation errors | 409 | `not_ready` | +| Project busy (another mutation or job running) | 409 | `busy` | +| Stale `baseRevision` | 409 | `stale_revision` | +| Provider/model failure | 502 | `provider_error` | +| Unexpected | 500 | `internal` (details logged server-side only) | + +`WorkflowUnchanged` is **not** an error: the response is `200` with the current +project view and `"changed": false`. + +### Project view (public state projection) + +```ts +type ProjectView = { + id: string; // appstate-* name + revision: number; // bumps on every mutation; used for stale-response rejection + image: { width: number; height: number } | null; + assets: { input: AssetRef | null; depth: AssetRef | null }; + depthModel: string; + numSlices: number; + thresholds: number[]; // numSlices + 1 boundaries + slices: SliceView[]; + selectedSlice: number | null; + busy: { jobId: string; kind: JobKind } | null; + // later PRs add: segmentation, inpainting, camera, export settings +}; +type SliceView = { + index: number; depth: number; version: number; + canUndo: boolean; canRedo: boolean; + positivePrompt: string; negativePrompt: string; + image: AssetRef; // raw RGBA slice + thumbnail: AssetRef; // checkerboard composite for display +}; +type AssetRef = { url: string }; // /api/v1/projects/{id}/assets/{assetId}?rev={revision} +``` + +Never serialize `AppState`, PIL/NumPy objects, model objects or credentials. + +### Endpoints for the first vertical slice (PR 2) + +| Method & path | Body | Result | +| --- | --- | --- | +| `POST /api/v1/projects` | multipart `image` | `201 ProjectView` (upload; validates via `WorkflowService.upload_image`) | +| `POST /api/v1/projects/restore` | multipart `state` (legacy `appstate.json`) | `200 ProjectView`; requires the server-side directory, as today | +| `GET /api/v1/projects/{id}` | – | `ProjectView` | +| `POST /api/v1/projects/{id}/depth` | `{model}` | `202 {job}` | +| `PUT /api/v1/projects/{id}/slice-count` | `{numSlices}` | `ProjectView` | +| `PUT /api/v1/projects/{id}/thresholds` | `{values, baseRevision}` | `ProjectView` | +| `POST /api/v1/projects/{id}/slices` | `{}` | `202 {job}` | +| `GET /api/v1/jobs/{jobId}` | – | `{id, kind, status: queued/running/succeeded/failed, progress: 0..1, error?, project?: ProjectView}` | +| `GET /api/v1/projects/{id}/assets/{assetId}` | – | image bytes, `Cache-Control: no-cache`, strong ETag | +| `GET /api/v1/logs?after={seq}` | – | `{entries: [{seq, level, message}], next}` (replaces the Dash log pane) | + +Asset IDs are logical (`input`, `depth`, `slice-{i}`, `slice-{i}-thumb`, later +`mask-{i}`, `candidate-{gen}-{k}`), resolved server-side; raw paths are never +accepted. `/next/` must not reuse the legacy unrestricted `/{SRV_DIR}/` route. + +### Concurrency + +- Every mutating request takes the project lock; if a job holds it the request + fails fast with `409 busy` (the UI disables controls while busy). +- `JobManager` runs jobs on one worker thread; the progress reporter updates the + job record. The client polls `GET /jobs/{id}` (~250 ms) until terminal. +- Each response carries `revision`; the client ignores views older than the one + it holds. Mutations that depend on what the user saw send `baseRevision`. + +## Frontend layout + +``` +frontend/ + src/lib/api/ # fetch client, generated types (from pydantic JSON Schema), job polling + src/lib/state/ # project.svelte.ts ($state runes), ui.svelte.ts (tabs, tools, zoom) + src/lib/components/ # one folder per workflow: upload/, depth/, slices/, segmentation/, canvas/, inpainting/, export/, config/ + src/lib/geometry.ts # screen <-> image coordinate transforms (unit tested) + src/App.svelte +``` + +- Svelte 5 runes only (`$state`, `$derived`, `$effect` sparingly, `$props`). + No reproduction of Dash trigger stores or callback cycles. +- Backend state (anything persisted) comes only from `ProjectView`; Svelte owns + transient UI state (active tab, tool, zoom/pan, in-progress strokes). +- Stable test hooks: `data-testid` on every control the e2e `SvelteDriver` uses. +- Tailwind v4 via `@tailwindcss/vite`; theme tokens in `src/app.css`. + +## Tooling and CI additions + +- `package.json` scripts: `build:frontend`, `check:frontend` (svelte-check), + `test:frontend` (vitest), `gen:api-types` (pydantic schema → TS). CI runs all + four, fails if generated types are stale, and runs Playwright for both the + `dash` and `svelte` projects. +- Python package data includes `parallax_maker/static/next/**`; a CI step builds + a wheel and checks that the Svelte `index.html` and assets are inside it. +- Pin versions compatible with each other (verify svelte-check against the + chosen TypeScript major) and commit `package-lock.json`. diff --git a/docs/svelte-migration/PARITY.md b/docs/svelte-migration/PARITY.md new file mode 100644 index 0000000..d499dcc --- /dev/null +++ b/docs/svelte-migration/PARITY.md @@ -0,0 +1,288 @@ +# Dash → Svelte parity checklist + +This document tracks parity between the existing Dash UI and the Svelte 5 +replacement, one row per Dash callback / clientside callback / browser event +handler discovered in `parallax_maker/webui.py`, `parallax_maker/components.py`, +`parallax_maker/clientside.py`, and `parallax_maker/assets/scripts/utility.js`. +A row's "Svelte" checkbox is only ever checked when the Svelte UI implements the +same behavior **and** a shared e2e scenario (`e2e/parallax-maker.spec.ts`, once +frontend-neutral) or an equivalent API contract test exercises it on both UIs — +not merely when Svelte code exists for it. + +## Counts + +- `parallax_maker/webui.py`: **48** `@app.callback` registrations (WEB-01..WEB-48). +- `parallax_maker/components.py`: **25** callback-decorator statements, producing + **26** live registrations at runtime (`make_tabs_callback` is invoked twice, for + `"viewer"` and `"main"`) plus **1 defined-but-never-registered** callback + (`make_label_container_callback` is never called from `webui.py`) — **CMP-01..CMP-26**. +- Clientside callbacks: **7** registered in `parallax_maker/clientside.py` + + **5** registered inside `components.make_canvas_callbacks` = **12** total + (CLI-01..CLI-12). +- Pure browser event handlers in `utility.js` with no Dash `Input`/`Output` at all + (plain `addEventListener`/`ResizeObserver` wiring): **6** (JS-01..JS-06). (A + further 2 `addEventListener` calls exist but are implementation detail of + CLI-01/CLI-02 and are folded into those rows rather than double-counted.) +- **Total Dash-reachable behaviors enumerated: 92** (48 WEB + 26 CMP + 12 CLI + 6 JS). + +## E2E scenario legend + +Numbers below refer to `e2e/parallax-maker.spec.ts`, in file order: + +1. Upload → deterministic depth → three real slices. +2. Instance-point replace/Shift-union/Ctrl-subtract exact mask regions. +3. Multipoint queue/commit and toggle/reset behavior. +4. Default depth-map click records pixel/depth/log/mask. +5. Selected-slice segmentation input (composed slice sent to model). +6. Painted mask → 3 candidates, prompts, Apply + version change + Undo. +7. Fill candidates weighted toward transparent slice pixels. +8. Enhance → 2 same-size candidates, alpha preserved. +9. Erase removes painted alpha; Undo/Redo. +10. Restore: images/controls/prompts/camera/theme. +11. glTF download (3 meshes/textures) + animation export (4 frames, no download). + +Scenarios 2–11 all start from `restoreFixtureState()`; only scenario 1 starts +from `uploadInputImage()`. Scenarios 1–11 all call `clickMainTab()` at least once. + +--- + +## Upload/Depth/Slices + +| ID | Function (file:line) | Trigger(s) | Effect | Backend service | E2E coverage | Svelte | +| --- | --- | --- | --- | --- | --- | --- | +| WEB-04 | `update_threshold_values` webui.py:331 | Input `{threshold-slider,ALL}.value` (also its own Output); State `SLIDER_NUM_SLICES`, filename | Pushes edited threshold values through `WorkflowService.update_threshold_values`; re-renders main image preview if changed | WorkflowService | none (no test drags a threshold slider) | [ ] | +| WEB-05 | `update_thresholds_html` webui.py:361 | Input `STORE_UPDATE_THRESHOLD_CONTAINER.data` | Rebuilds the threshold `dcc.Slider` list from `state.imgThresholds` | inline (AppState read) | 1, 10 | [ ] | +| WEB-06 | `update_thresholds` webui.py:397 | Input `CTR_DEPTH_MAP.children`, `SLIDER_NUM_SLICES.value` | Calls `WorkflowService.configure_thresholds`; logs missing-depth/threshold info; triggers WEB-05 | WorkflowService | 1, 10 | [ ] | +| WEB-07 | `update_input_image` webui.py:428 | Input `UPLOAD_IMAGE.contents`; gated on active tab classnames | Decodes upload, calls `WorkflowService.upload_image`; serves input image, resets depth-map placeholder, triggers depth generation | WorkflowService | 1 | [ ] | +| WEB-09 | `generate_depth_map_from_button` webui.py:556 | Input `BTN_GENERATE_DEPTHMAP.n_clicks`; `running=` disables button | Sets `STORE_TRIGGER_GEN_DEPTHMAP` to kick off depth generation | inline (trigger only) | none (button never clicked in tests; upload auto-triggers depth instead) | [ ] | +| WEB-10 | `generate_depth_map_callback` webui.py:570 | Input `STORE_TRIGGER_GEN_DEPTHMAP.data` | Calls `WorkflowService.generate_depth` (resets thresholds, caches model) | WorkflowService | 1 | [ ] | +| WEB-20 | `generate_slices_request` webui.py:899 | Input `BTN_GENERATE_SLICE.n_clicks` (`#generate-slice-button`) | Forwards click count into `STORE_GENERATE_SLICE` to trigger WEB-25 | inline (trigger only) | 1 | [ ] | +| WEB-25 | `generate_slices` webui.py:1123 | Input `STORE_GENERATE_SLICE.data` | Calls `WorkflowService.generate_slices` (expand=5, full save); triggers WEB-21 | WorkflowService | 1 | [ ] | + +## Segmentation + +| ID | Function (file:line) | Trigger(s) | Effect | Backend service | E2E coverage | Svelte | +| --- | --- | --- | --- | --- | --- | --- | +| WEB-08 | `click_event` webui.py:473 | Input `SEG_MULTI_COMMIT.n_clicks`, `el.n_events` (image click); State rect data, mode, filename | Routes plain/Shift/Ctrl clicks to `SegmentationService.select_depth_point` / `select_instance_point`, or commits queued multipoint via `commit_multi_point`; renders resulting mask preview and appends log lines | SegmentationService | 2, 3, 4, 5 | [ ] | +| CMP-21 | `toggle_segmentation_buttons` components.py:1663 | Input `DROPDOWN_MODE_SELECTOR.value`, `STORE_APPSTATE_FILENAME.data` | Enables/disables Multi and Commit buttons when mode == "segment" and a project is loaded | inline | 1–11 (fires on every filename set); mode explicitly switched in 2, 3, 5 | [ ] | +| CMP-22 | `toggle_multi_point` components.py:1676 | Input `SEG_MULTI_POINT.n_clicks` (`#multi-point`) | Calls `SegmentationService.set_multi_point_mode`; toggles selected/unselected class; clears point-preview via `STORE_CLEAR_PREVIEW` (CLI-06) | SegmentationService | 3 | [ ] | +| CLI-05 | `visualize_point` clientside.py:39 | Input `STORE_CLICKED_POINT.data` (written only by WEB-08's queued-point branch) | Draws a green/red dot on the preview canvas for a queued multipoint click | inline (JS) | 3 | [ ] | +| CLI-06 | `preview_canvas_clear` clientside.py:46 | Input `STORE_CLEAR_PREVIEW.data` (written only by CMP-22) | Clears the preview canvas overlay when multipoint mode is toggled | inline (JS) | 3 | [ ] | + +## Slice editing + +| ID | Function (file:line) | Trigger(s) | Effect | Backend service | E2E coverage | Svelte | +| --- | --- | --- | --- | --- | --- | --- | +| WEB-12 | `delete_slice_request` webui.py:623 | Input `BTN_DELETE_SLICE.n_clicks` | Deletes `state.selected_slice`; JSON-only save; re-renders main image | inline (AppState) | none | [ ] | +| WEB-13 | `copy_to_clipboard` webui.py:654 | Input `BTN_COPY_SLICE.n_clicks` | Copies composed slice (or full image) + current mask into `state.clipboard_image` (in-memory only, not persisted) | inline (AppState) | none | [ ] | +| WEB-14 | `paste_clipboard_request` webui.py:688 | Input `BTN_PASTE_SLICE.n_clicks` | Blends clipboard image into selected slice **in place** (mutates slice array directly), bumps version, JSON-only save | inline (AppState) | none | [ ] | +| WEB-15 | `remove_mask_slice_request` webui.py:726 | Input `BTN_REMOVE_SLICE.n_clicks` | Subtracts current mask from selected slice's alpha in place, bumps version, JSON-only save | inline (AppState) | none | [ ] | +| WEB-16 | `add_mask_slice_request` webui.py:766 | Input `BTN_ADD_SLICE.n_clicks`; `running=` disables button | Creates a masked crop of `state.imgData` and blends it into the selected slice in place, bumps version, JSON-only save | inline (AppState) | none | [ ] | +| WEB-18 | `create_single_slice_request` webui.py:832 | Input `BTN_CREATE_SLICE.n_clicks`; `running=` disables button | Creates a brand-new `ImageSlice` from the current mask (or an empty transparent slice if no mask), appends and selects it, saves image + JSON | inline (AppState) | none | [ ] | +| WEB-19 | `balance_slices_request` webui.py:875 | Input `BTN_BALANCE_SLICE.n_clicks` | Calls `AppState.balance_slices_depths()`, JSON-only save | inline (AppState) | **none — see Known quirks: this crashes** | [ ] | +| WEB-22 | `display_depth_input` webui.py:1037 | Input `{slice-depth-display,MATCH}.n_clicks` | Un-hides the numeric depth `` on a slice thumbnail | inline | none | [ ] | +| WEB-23 | `record_depth_input` webui.py:1053 | Input `{slice-depth-input,ALL}.value`/`.n_submit` | Calls `state.change_slice_depth`, possibly reorders/deselects, JSON-only save | inline (AppState) | none | [ ] | +| WEB-24 | `undo_slice` webui.py:1083 | Input `{slice-undo-backwards/forwards,ALL}.n_clicks` | Calls `InpaintingService.move_slice_version` (FORWARD/BACKWARD) to step a slice's saved image-version history; triggers WEB-21 re-render | InpaintingService | 6, 9 | [ ] | +| WEB-34 | `slice_upload` webui.py:1428 | Input `{UPLOAD_SLICE,ALL}.contents` (per-thumbnail drag/drop) | Decodes dropped image, fixes aspect ratio, writes a new slice version, JSON-only save, and re-composes `state.imgData` from all slices | inline (AppState) | none | [ ] | + +## Mask tools + +| ID | Function (file:line) | Trigger(s) | Effect | Backend service | E2E coverage | Svelte | +| --- | --- | --- | --- | --- | --- | --- | +| WEB-11 | `update_depth_map_callback` webui.py:591 | Input `STORE_TRIGGER_UPDATE_DEPTHMAP.data` | Encodes `state.depthMapData` as a PNG `` for `#depthmap-image` | inline (AppState) | 1, 10 | [ ] | +| WEB-21 | `update_slices` webui.py:913 | Input `STORE_UPDATE_SLICE.data` | Rebuilds the slice-thumbnail strip (undo/redo carets, depth badges, upload targets); re-renders the composed main image for the selected slice and clears `slice_pixel`/`slice_mask` | inline (AppState) | 1, 6, 9, 10 | [ ] | +| WEB-26 | `display_slice` webui.py:1150 | Input `{slice,ALL}.n_clicks`, `{slice-overlay,ALL}.n_clicks` | Selects/deselects a slice, composes checkerboard/grayscale preview, loads its saved prompts, calls `InpaintingService.clear_selection` | InpaintingService (selection) + inline (AppState) | 5, 6, 7, 8, 9, 10 | [ ] | +| CMP-19 | `invert_mask` components.py:1615 | Input `SEG_INVERT_MASK.n_clicks` ("Invert") | Inverts `state.slice_mask` (creating an all-zero mask first if none exists) and re-renders masked preview | inline (AppState) | none (handoff-listed gap) | [ ] | +| CMP-20 | `blur_mask` components.py:1639 | Input `SEG_FEATHER_MASK.n_clicks` ("Feather") | `cv2.blur`s `state.slice_mask` by a fixed 10px kernel and re-renders masked preview | inline (AppState) | none (handoff-listed gap) | [ ] | +| CMP-23 | `toggle_checkerboard` components.py:1706 | Input `SEG_TOGGLE_CHECKERBOARD.n_clicks` | Toggles `state.use_checkerboard`, re-composes the selected slice preview in the new mode | inline (AppState) | none | [ ] | + +## Canvas/Inpainting + +| ID | Function (file:line) | Trigger(s) | Effect | Backend service | E2E coverage | Svelte | +| --- | --- | --- | --- | --- | --- | --- | +| WEB-17 | `update_prompt_text` webui.py:804 | Input `TEXT_POSITIVE_PROMPT.value`, `TEXT_NEGATIVE_PROMPT.value` (`#positive-prompt`/`#negative-prompt`) | Persists normalized prompts via `InpaintingService.update_prompts` (survives model failure); no Output | InpaintingService | 6 | [ ] | +| CMP-02 | `enable_apply_inpainting_button` components.py:684 | Input `CTR_INPAINTING_DISPLAY.children`, `{inpainting-image,ALL}.className` | Enables `#apply-inpainting-button` only when `state.selected_inpainting` is a valid, visibly-selected candidate index | inline (AppState read) | 6 | [ ] | +| CMP-03 | `erase_inpainting` components.py:707 | Input `BTN_ERASE_INPAINTING.n_clicks` (`#erase-inpainting-button`); `running=` disables button | Calls `InpaintingService.erase` (inverse-slice-alpha patch, new version, JSON/file-mapping save); logs slice index; triggers WEB-21 | InpaintingService | 9 | [ ] | +| CMP-04 | `update_inpainting_image_display` components.py:745 | Input `BTN_GENERATE_INPAINTING`/`BTN_FILL_INPAINTING`/`BTN_ENHANCE` `.n_clicks` (`#generate-inpainting-button`/`#fill-inpainting-button`/`#enhance-button`); `running=` disables all three buttons | Decodes ComfyUI workflow if needed, calls `InpaintingService.generate_candidates` in PAINT/FILL/ENHANCE mode, renders returned candidate images as data URLs | InpaintingService | 6 (PAINT), 7 (FILL), 8 (ENHANCE) | [ ] | +| CMP-05 | `select_inpainting_image` components.py:823 | Input `{inpainting-image,ALL}.n_clicks` | Calls `InpaintingService.select_candidate`; toggles the clicked candidate's highlight class off if re-clicked (deselect); previews selected candidate or falls back to composed slice image | InpaintingService | 6 | [ ] | +| CMP-06 | `apply_inpainting` components.py:875 | Input `BTN_APPLY_INPAINTING.n_clicks` (`#apply-inpainting-button`); `running=` disables button | Decodes candidate data URLs to PIL images, calls `InpaintingService.apply_candidate` (writes new image version + JSON/file-mapping save); triggers WEB-21 and CMP-07 | InpaintingService | 6 | [ ] | +| CMP-07 | `react_selected_slice_change` components.py:916 | Input `STORE_INPAINTING.data` | Enables/disables all inpainting controls based on whether a slice is selected; calls `InpaintingService.clear_selection`; writes `STORE_SELECTED_SLICE` (feeds CLI-04) | InpaintingService | 5, 6, 7, 8, 9, 10 | [ ] | +| CMP-24 | `save_slice_mask` components.py:1748 | Input `CANVAS_DATA.data` (from CLI-09's mouseout save) | Empty string → `InpaintingService.delete_mask`; else decodes canvas PNG → `InpaintingService.save_mask` (alpha resized BICUBIC, padding, ROI-crop flag); returns bounding box for CLI-07 | InpaintingService | 6, 9 | [ ] | +| CMP-25 | `load_canvas_mask` components.py:1799 | Input `BTN_LOAD_CANVAS.n_clicks` ("Load") | Calls `InpaintingService.load_mask`; re-renders it as RGBA `(r,0,0,r)` and feeds CLI-08 to paint it back onto the canvas | InpaintingService | none (handoff-listed gap: canvas load) | [ ] | +| CLI-04 | `record_selected_slice` clientside.py:30 | Input `STORE_SELECTED_SLICE.data` (written by CMP-07) | Sets JS `currentSlice`, which gates whether CLI-09/JS-01 record paint strokes and drives JS-06's contextual help text | inline (JS) | 5, 6, 7, 8, 9, 10 | [ ] | +| CLI-07 | `show_bounding_box` clientside.py:55 | Input `STORE_BOUNDING_BOX.data` (written by CMP-24) | Draws a 2s ROI-preview rectangle on the preview canvas | inline (JS) | none (no test enables the ROI checkbox, so `bounding_box` stays `None`) | [ ] | +| CLI-08 | `canvas_load` components.py:1818 | Input `CANVAS_MASK_DATA.data` (written by CMP-25) | Sets up the main canvas if needed and draws the loaded mask image onto it | inline (JS) | none | [ ] | +| CLI-09 | `canvas_draw` components.py:1825 | Input `CANVAS_PAINT.event` (mousedown/mouseup/**mouseout**/mouseenter via `EventListener`) | Sets up canvas on first use; starts/stops drawing; **saves on `mouseout`, not `mouseup`** — returns `canvas.toDataURL()` into `CANVAS_DATA` only if a stroke was drawn since last save | inline (JS) | 6, 9 | [ ] | +| CLI-10 | `canvas_clear` (Clear button) components.py:1832 | Input `BTN_CLEAR_CANVAS.n_clicks` | Clears canvas pixels and resets cached 2D context | inline (JS) | none (handoff-listed gap) | [ ] | +| CLI-11 | `canvas_clear` (auto-clear) components.py:1838 | Input `IMAGE.src` (**every** main-image change) | Clears canvas pixels whenever the main image updates — see Known quirks (`# XXX - this will kill the canvas during inpainting - bad`) | inline (JS) | 1–11 (fires on virtually every image update) | [ ] | +| CLI-12 | `canvas_toggle_erase` components.py:1845 | Input `BTN_ERASE_MODE.n_clicks` ("Erase" — the canvas eraser-brush toggle, distinct from `#erase-inpainting-button`) | Flips `isErasing`, switches `globalCompositeOperation` between `source-over`/`destination-out` and stroke color/width | inline (JS) | none | [ ] | +| JS-01 | canvas `mousemove` → `draw`/`previewBrush` utility.js:288,152,58 | Native `mousemove` on `#canvas` | Live brush-size preview circle when idle; paints the red stroke and records `canvasLastDrawnTime` while `isDrawing` | inline (JS) | 6, 9 (via `drawCanvasStroke` helper) | [ ] | +| JS-02 | canvas `contextmenu` suppression utility.js:289 | Native `contextmenu` on `#canvas` | `preventDefault()`s the native menu so Alt+Right-drag can resize the brush instead | inline (JS) | none | [ ] | + +## Project lifecycle + +| ID | Function (file:line) | Trigger(s) | Effect | Backend service | E2E coverage | Svelte | +| --- | --- | --- | --- | --- | --- | --- | +| WEB-36 | `restore_inpainting` webui.py:1514 | Input `STORE_RESTORE_STATE.data` | Sets `STORE_INPAINTING=True` to re-run CMP-07 after a restore | inline (trigger only) | 2–11 | [ ] | +| WEB-37 | `remember_camera_parameters` (2nd def, restore variant) webui.py:1527 | Input `STORE_RESTORE_STATE.data` | Populates camera-distance/focal-length/max-distance/displacement sliders from `state.camera`/`state.mesh_displacement` | inline (AppState read) | 2–11 (asserted in 10) | [ ] | +| WEB-38 | `restore_dark_mode` webui.py:1547 | Input `STORE_RESTORE_STATE.data` | Sets `BTN_DARK_MODE.n_clicks` parity to reflect `state.dark_mode`, indirectly re-running WEB-01's class toggle | inline (AppState read) | 2–11 (asserted in 10) | [ ] | +| WEB-39 | `restore_api_key` webui.py:1561 | Input `STORE_RESTORE_STATE.data` | Restores `INPUT_API_KEY.value` from `state.api_key` | inline (AppState read) | 2–11 | [ ] | +| WEB-40 | `restore_workflow` webui.py:1579 | Input `STORE_RESTORE_STATE.data` | Re-uploads a saved ComfyUI workflow file's bytes as a data URL if one exists on disk | inline (AppState read) | 2–11 | [ ] | +| WEB-41 | `restore_models` webui.py:1597 | Input `STORE_RESTORE_STATE.data` | Restores depth/inpainting model dropdown selections | inline (AppState read) | 2–11 (asserted in 10) | [ ] | +| WEB-42 | `update_external_server_address` webui.py:1617 | Input `STORE_RESTORE_STATE.data` | Restores `INPUT_EXTERNAL_SERVER.value` | inline (AppState read) | 2–11 | [ ] | +| WEB-43 | `update_model_viewer` webui.py:1631 | Input `STORE_RESTORE_STATE.data` | Rebuilds the glTF `