diff --git a/docs/performance.md b/docs/performance.md
new file mode 100644
index 0000000000..04162b2039
--- /dev/null
+++ b/docs/performance.md
@@ -0,0 +1,219 @@
+# Desktop frontend performance experiments
+
+Measured September 26, 2026 on an Apple M4 Pro, macOS arm64, Chrome
+153.0.8010.53, Node 24.13.0. These are production frontend measurements in
+headless Chrome with mocked Tauri services. They do **not** measure native
+process launch, WKWebView/WebView2, Rust, disk startup, real account loading,
+or generation latency.
+
+## Changes and results
+
+Seven samples per version; table values are medians. Each sample uses a fresh
+browser context at 1440 × 1000 with device scale 1 and browser HTTP cache
+disabled. The browser process stays alive between samples. A reload in the
+same context is measured separately. There is no CPU/network throttling.
+External requests, including analytics, fonts, and media, are blocked in both
+versions. The login fixture is signed in; the bundled model overlay is used.
+
+| Measurement | Before | After | Reduction |
+|-----------------------------|-----------|-----------|-----------|
+| Startup to frame | 566.40 ms | 350.60 ms | 38.1% |
+| Refresh to frame | 248.60 ms | 198.10 ms | 20.3% |
+| Startup JavaScript | 9.05 MB | 6.47 MB | 28.6% |
+| Startup long tasks (sum) | 336.00 ms | 184.00 ms | 45.2% |
+| First drawing page to frame | 120.90 ms | 118.20 ms | 2.2% |
+| Drawing to home: dispatch | 23.40 ms | 9.00 ms | 61.5% |
+| Drawing to home: frame | 29.50 ms | 23.00 ms | 22.0% |
+| Home to drawing: dispatch | 42.10 ms | 18.80 ms | 55.3% |
+| Home to drawing: frame | 105.70 ms | 86.50 ms | 18.2% |
+
+The 2.2% first-open difference is small enough to treat as unchanged. These
+local measurements are evidence of reduced frontend work, not predictions
+of identical percentage gains on every user's machine. Raw samples, ranges,
+environment, fixture sizes, browser errors, and build provenance are in
+[before.json](performance/2026-09-26-before.json) and
+[after.json](performance/2026-09-26-after.json).
+
+Three changes produced these results:
+
+1. `MainApp.tsx` imported every editor at launch. Fifteen pages now use
+ `React.lazy` behind a Suspense boundary below the persistent shell. Their
+ code loads when selected; the top bar stays available during loading.
+ The default Apps page remains eager. Drawing also remains eager: its
+ shared code is already part of the shell, and experimentally deferring
+ its small host adapter made first-open latency worse for little saving.
+ First-open latency for the other deferred editors is a remaining tradeoff
+ to measure, especially on slower disks/CPUs. All 15 deferred modules were
+ imported successfully in the browser, but this is not an end-to-end test
+ of every editor's features.
+2. `TabState.ts` exported the drawing scene to JSON when leaving and imported
+ it when returning. The Zustand scene store already survives page unmount.
+ With four imported images, the old path performed four FileReader reads
+ on exit and four more on return, allocated base64 copies, rebuilt image
+ nodes, cleared the selection, and appended an undo-history entry. Tab
+ changes now keep the live store. The measured round trip performs **zero
+ image-file reads**, preserves the selected node, and keeps history length
+ at one instead of growing to two. Explicit file export/import remains
+ available. Tests cover image identity, model metadata, selection, redo,
+ gallery reset, rapid navigation, and portable file export.
+3. The shell ran `getGPUTier()` at startup and stored its result in an unread
+ React state value. Removing it eliminates **one startup WebGL context
+ request** and its associated detection work. The real 3D renderer still
+ creates its context when used. This experiment reports combined timing
+ gains, not an isolated timing attribution for each individual change.
+
+## Repeat the experiment
+
+From the repository root:
+
+```sh
+cd frontend
+npm ci
+npm run perf:desktop -- --output /tmp/artcraft-perf-current --smoke-imports
+```
+
+The default browser is an installed Google Chrome (`--channel chrome`). The
+runner requires permission to launch a browser and bind a loopback server.
+It never uses your regular browser profile, account, or native app data.
+`@playwright/test` is pinned as a development dependency. Artifacts are written
+outside the repository by default. Use a dedicated output directory: its
+`dist` subdirectory is rebuilt by Vite.
+
+For this particular two-module change, reproduce the original implementation
+without replacing local source files:
+
+```sh
+npm run perf:desktop -- --output /tmp/artcraft-perf-before \
+ --baseline-ref 780e4494e995163fed1d677b88c3cf03b3fca63c
+npm run perf:desktop -- --output /tmp/artcraft-perf-after --smoke-imports
+node tools/performance/compare.mjs \
+ /tmp/artcraft-perf-before/results.json \
+ /tmp/artcraft-perf-after/results.json
+```
+
+`--baseline-ref` overlays **only** `MainApp.tsx` and `pages/Stores/TabState.ts`
+from that Git revision in the benchmark build. All other source files and
+dependencies remain current. It reproduces the two production files changed
+in this experiment; it is not a general historical-checkout benchmark. For
+future changes, capture a normal baseline **before** editing, use a new output
+directory for the changed version, and keep the runner, fixture, dependencies,
+browser, and machine identical. Run sequentially while the machine is idle;
+do not build or run other CPU-heavy tasks during samples. Repeat in reverse
+order if results are close or noisy.
+
+Useful options:
+
+```sh
+# Build in a restricted shell, then run where browser/server use is allowed.
+npm run perf:desktop -- --output /tmp/artcraft-perf-current --build-only
+npm run perf:desktop -- --output /tmp/artcraft-perf-current --skip-build
+
+# More samples; seven is the default.
+npm run perf:desktop -- --output /tmp/artcraft-perf-current --runs 15
+
+# Correctness checks for the navigation change.
+npx vitest run --config apps/artcraft/vitest.config.ts
+```
+
+Do not use `--skip-build` after changing app code; it intentionally measures
+the existing artifact. `build-info.json` records the revision, changed-file
+names, baseline overlay, and build time. The benchmark plugin alone exposes
+the stores needed to seed the scene. It is not part of the normal Vite/Tauri
+build, and no mock IPC or benchmark global is added to the shipped app.
+
+The runner builds from the app's working directory, matching its normal
+build command. This matters because Tailwind resolves its config and content
+relative to that directory. It rejects a run without working Tailwind styles
+or with uncaught browser errors. `drawing.png` provides a visual check after
+the measured operations. The initial unstyled exploratory runs were discarded.
+
+## What the measurements mean
+
+The fixture creates four deterministic 1024 × 1024 PNG image nodes and 80
+strokes of 200 coordinates each, with one selected image. On the recorded
+browser each PNG is 2,356,706 bytes. Fixture construction, image decoding,
+and a one-second settling period are outside the tab-transition timings.
+Each sample follows the same sequence:
+
+1. Navigate to the production app, wait for the Apps heading and for the
+ signed-in login overlay to disappear, then wait two animation frames.
+2. Reload that page and repeat the same readiness checks.
+3. Seed the scene while on Apps, open drawing, and wait for its canvas and
+ two animation frames. Allow a second for its background bake to settle.
+4. Switch to Apps and then back to drawing, measuring each separately.
+5. After a settling period, inspect node count, selection, history, and IPC
+ counters. Save a screenshot on the first run. Optionally evaluate all
+ deferred page modules after the final timed operation.
+
+`readyMs` is `performance.now()` after readiness checks and two animation
+frames; its origin is navigation start. It includes browser loading and
+automation readiness-observation overhead. `jsBytes` sums decoded resource
+sizes of loaded JavaScript; it is uncompressed bytes, not all packaged files.
+`longTaskMs` sums observed startup tasks longer than 50 ms, not total CPU time
+or total blocking time. Long Tasks support is browser-dependent.
+
+`stateReadyMs` measures the awaited `setActiveTab()` action, including React
+work synchronously flushed by its store subscribers. `paintMs` continues
+until the destination heading/canvas exists and two animation frames have
+passed. This is a **rendering-opportunity proxy**, not proof of GPU presentation
+or of every asynchronous image decode completing. In particular, the old
+return path could still be decoding images after the canvas first appeared.
+Tab measurements also create `artcraft:tab:APPS` and `artcraft:tab:2D` User
+Timing entries in the benchmark page.
+
+The runner calls the same store action used by navigation UI. It excludes
+pointer-event dispatch, menu animations, and physical click-to-screen latency.
+It does not automate paid generations, sign in to a real account, or measure
+remote services. Save the raw JSON as well as the summary; a single minimum
+time is not a reliable comparison.
+
+`dist/performance-bundle.json` ranks the largest retained modules per chunk
+and records chunk imports. Module `renderedBytes` is Rollup's intermediate
+module size; it is useful for attribution but should not be summed as final
+minified/gzipped size. Use actual resource bytes for the startup total.
+
+## Remaining investigation
+
+- The shell still loads about 6.47 MB of JavaScript. Shared imports through
+ drawing, scene, gallery, and settings entry points need a deeper dependency
+ audit. Separate lightweight store/type imports from editor code, then use
+ the bundle report and the same startup experiment to verify what actually
+ leaves the initial graph. The report identifies Spark, Three.js core and
+ renderer, the inline FBX-to-GLB conversion worker, PostHog, and Mediabunny
+ among the largest retained modules. Their presence is measured; the runtime
+ benefit of deferring each individually has not yet been measured.
+- Returning to the populated canvas still takes about 86 ms. The editor
+ remount, Konva drawing, image/transformer setup, and composite bake need a
+ timeline profile before choosing the next change. Keep scene restoration,
+ undo, selection, and background generation behavior in correctness checks.
+- `TaskQueue.tsx` polls every five seconds and rebuilds task arrays even when
+ the popover is closed. The benchmark uses an empty queue, so it does not
+ establish the cost for a large real queue. A fixed, representative task
+ fixture plus idle CPU/render counts is the next useful experiment there.
+- `useStageSnapshot` looks expensive in isolation, but its only call is
+ commented out in `PaintSurface.tsx`. It was excluded as a current bottleneck.
+
+Validation completed: normal production Vite build, all 10 app tests and 20
+login tests, all 15 deferred page-module imports, visual canvas inspection,
+and a check that the normal build contains no benchmark globals or fixtures.
+The broad TypeScript check remains blocked by existing workspace declaration/
+project-reference errors and Remix/Deno types. Comparing diagnostics with the
+two original modules showed the same diagnostic messages (some import traces
+and reporting locations changed); this is not a clean typecheck claim.
+
+## Validate in the native app
+
+Before claiming native launch or interaction gains, run matching release
+Tauri builds on the same machine and WebView version. Keep account, window
+size, network conditions, and canvas artwork fixed. Record the Web Inspector
+timeline for launch/reload, opening a tool for the first time, drawing → Apps,
+and Apps → drawing. Repeat at least seven times and retain the traces with
+build IDs and medians. Separate native process launch from page reload; do
+not mix dev/HMR traces with production measurements.
+
+For canvas tests, create a blank 1024-square canvas, import the same four
+images, add the same strokes, select an image, and wait for background work
+before switching. Check image content, selection, undo/redo, and opening a new
+gallery image afterward. Record actual click-to-visible-frame latency in
+the inspector as well as JavaScript tasks. The browser results above justify
+the code changes but do not replace that native validation.
diff --git a/docs/performance/2026-09-26-after.json b/docs/performance/2026-09-26-after.json
new file mode 100644
index 0000000000..0c18691c97
--- /dev/null
+++ b/docs/performance/2026-09-26-after.json
@@ -0,0 +1,527 @@
+{
+ "timestamp": "2026-09-27T00:32:54.524Z",
+ "build": {
+ "builtAt": "2026-09-27T00:30:35.760Z",
+ "revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
+ "baselineOverlay": null,
+ "changedFiles": [
+ "frontend/apps/artcraft/app/src/pages/MainApp.tsx",
+ "frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
+ "frontend/package-lock.json",
+ "frontend/package.json"
+ ]
+ },
+ "environment": {
+ "platform": "darwin",
+ "arch": "arm64",
+ "cpu": "Apple M4 Pro",
+ "browser": "153.0.8010.53",
+ "node": "v24.13.0",
+ "runs": 7,
+ "viewport": "1440x1000@1",
+ "ipc": "mocked",
+ "network": "local assets only, browser cache disabled"
+ },
+ "summary": {
+ "startup": {
+ "readyMs": {
+ "median": 350.60000002384186,
+ "min": 348.7000000476837,
+ "max": 457.39999997615814
+ },
+ "jsBytes": {
+ "median": 6466606,
+ "min": 6466606,
+ "max": 6466606
+ },
+ "longTaskMs": {
+ "median": 184,
+ "min": 179,
+ "max": 284
+ },
+ "webglContexts": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ }
+ },
+ "refresh": {
+ "readyMs": {
+ "median": 198.10000002384186,
+ "min": 197.10000002384186,
+ "max": 198.80000007152557
+ },
+ "jsBytes": {
+ "median": 6466606,
+ "min": 6466606,
+ "max": 6466606
+ },
+ "longTaskMs": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ },
+ "webglContexts": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ }
+ },
+ "firstDraw": {
+ "stateReadyMs": {
+ "median": 38.300000071525574,
+ "min": 37.60000002384186,
+ "max": 40.299999952316284
+ },
+ "paintMs": {
+ "median": 118.19999992847443,
+ "min": 115.30000007152557,
+ "max": 120
+ },
+ "fileReads": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ }
+ },
+ "leaveDraw": {
+ "stateReadyMs": {
+ "median": 9,
+ "min": 7.5,
+ "max": 11
+ },
+ "paintMs": {
+ "median": 23,
+ "min": 19.600000023841858,
+ "max": 26.600000023841858
+ },
+ "fileReads": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ }
+ },
+ "returnDraw": {
+ "stateReadyMs": {
+ "median": 18.800000071525574,
+ "min": 15.799999952316284,
+ "max": 20.700000047683716
+ },
+ "paintMs": {
+ "median": 86.5,
+ "min": 78.79999995231628,
+ "max": 90.10000002384186
+ },
+ "fileReads": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ }
+ }
+ },
+ "samples": [
+ {
+ "startup": {
+ "readyMs": 457.39999997615814,
+ "jsBytes": 6466606,
+ "longTaskMs": 284,
+ "webglContexts": 0
+ },
+ "refresh": {
+ "readyMs": 197.80000007152557,
+ "jsBytes": 6466606,
+ "longTaskMs": 0,
+ "webglContexts": 0
+ },
+ "firstDraw": {
+ "stateReadyMs": 38.800000071525574,
+ "paintMs": 119.5,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 7.600000023841858,
+ "paintMs": 20.399999976158142,
+ "fileReads": 0
+ },
+ "returnDraw": {
+ "stateReadyMs": 17.799999952316284,
+ "paintMs": 87.79999995231628,
+ "fileReads": 0
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 1,
+ "historyLength": 1,
+ "commands": {
+ "plugin:event|listen": 25,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 365.7999999523163,
+ "jsBytes": 6466606,
+ "longTaskMs": 185,
+ "webglContexts": 0
+ },
+ "refresh": {
+ "readyMs": 197.10000002384186,
+ "jsBytes": 6466606,
+ "longTaskMs": 0,
+ "webglContexts": 0
+ },
+ "firstDraw": {
+ "stateReadyMs": 37.60000002384186,
+ "paintMs": 116,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 7.5,
+ "paintMs": 26.600000023841858,
+ "fileReads": 0
+ },
+ "returnDraw": {
+ "stateReadyMs": 15.799999952316284,
+ "paintMs": 78.79999995231628,
+ "fileReads": 0
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 1,
+ "historyLength": 1,
+ "commands": {
+ "plugin:event|listen": 25,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 366.59999990463257,
+ "jsBytes": 6466606,
+ "longTaskMs": 193,
+ "webglContexts": 0
+ },
+ "refresh": {
+ "readyMs": 198.39999997615814,
+ "jsBytes": 6466606,
+ "longTaskMs": 0,
+ "webglContexts": 0
+ },
+ "firstDraw": {
+ "stateReadyMs": 40.299999952316284,
+ "paintMs": 118.19999992847443,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 9,
+ "paintMs": 23,
+ "fileReads": 0
+ },
+ "returnDraw": {
+ "stateReadyMs": 19.699999928474426,
+ "paintMs": 86.5,
+ "fileReads": 0
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 1,
+ "historyLength": 1,
+ "commands": {
+ "plugin:event|listen": 25,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 348.7000000476837,
+ "jsBytes": 6466606,
+ "longTaskMs": 184,
+ "webglContexts": 0
+ },
+ "refresh": {
+ "readyMs": 197.89999997615814,
+ "jsBytes": 6466606,
+ "longTaskMs": 0,
+ "webglContexts": 0
+ },
+ "firstDraw": {
+ "stateReadyMs": 37.700000047683716,
+ "paintMs": 115.60000002384186,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 9.300000071525574,
+ "paintMs": 26.40000009536743,
+ "fileReads": 0
+ },
+ "returnDraw": {
+ "stateReadyMs": 18.700000047683716,
+ "paintMs": 84.90000009536743,
+ "fileReads": 0
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 1,
+ "historyLength": 1,
+ "commands": {
+ "plugin:event|listen": 25,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 349.60000002384186,
+ "jsBytes": 6466606,
+ "longTaskMs": 179,
+ "webglContexts": 0
+ },
+ "refresh": {
+ "readyMs": 198.10000002384186,
+ "jsBytes": 6466606,
+ "longTaskMs": 0,
+ "webglContexts": 0
+ },
+ "firstDraw": {
+ "stateReadyMs": 38.300000071525574,
+ "paintMs": 118.5,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 11,
+ "paintMs": 19.600000023841858,
+ "fileReads": 0
+ },
+ "returnDraw": {
+ "stateReadyMs": 20.700000047683716,
+ "paintMs": 90.10000002384186,
+ "fileReads": 0
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 1,
+ "historyLength": 1,
+ "commands": {
+ "plugin:event|listen": 25,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 350.60000002384186,
+ "jsBytes": 6466606,
+ "longTaskMs": 179,
+ "webglContexts": 0
+ },
+ "refresh": {
+ "readyMs": 198.20000004768372,
+ "jsBytes": 6466606,
+ "longTaskMs": 0,
+ "webglContexts": 0
+ },
+ "firstDraw": {
+ "stateReadyMs": 39.60000002384186,
+ "paintMs": 120,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 11,
+ "paintMs": 22.199999928474426,
+ "fileReads": 0
+ },
+ "returnDraw": {
+ "stateReadyMs": 20,
+ "paintMs": 87,
+ "fileReads": 0
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 1,
+ "historyLength": 1,
+ "commands": {
+ "plugin:event|listen": 25,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 349.89999997615814,
+ "jsBytes": 6466606,
+ "longTaskMs": 180,
+ "webglContexts": 0
+ },
+ "refresh": {
+ "readyMs": 198.80000007152557,
+ "jsBytes": 6466606,
+ "longTaskMs": 0,
+ "webglContexts": 0
+ },
+ "firstDraw": {
+ "stateReadyMs": 38,
+ "paintMs": 115.30000007152557,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 8.200000047683716,
+ "paintMs": 24.899999976158142,
+ "fileReads": 0
+ },
+ "returnDraw": {
+ "stateReadyMs": 18.800000071525574,
+ "paintMs": 84.5,
+ "fileReads": 0
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 1,
+ "historyLength": 1,
+ "commands": {
+ "plugin:event|listen": 25,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ }
+ ],
+ "importedPages": [
+ "src/pages/PageAngles/index.ts",
+ "src/pages/PageAudio/CreateAudio.tsx",
+ "src/pages/PageBackgroundChange/index.ts",
+ "src/pages/PageImage/TextToImage.tsx",
+ "src/pages/PageImageTo3DObject/index.ts",
+ "src/pages/PageImageTo3DWorld/index.ts",
+ "src/pages/PageImageWatermarkRemover/index.ts",
+ "src/pages/PageMoodboard/index.ts",
+ "src/pages/PageRemoveBackground/index.ts",
+ "src/pages/PageScene/index.tsx",
+ "src/pages/PageStoryboard/index.ts",
+ "src/pages/PageVideo/ImageToVideo.tsx",
+ "src/pages/PageVideoEditor/index.tsx",
+ "src/pages/PageVideoFrameExtractor/index.ts",
+ "src/pages/PageVideoWatermarkRemover/index.ts"
+ ],
+ "errors": []
+}
diff --git a/docs/performance/2026-09-26-before.json b/docs/performance/2026-09-26-before.json
new file mode 100644
index 0000000000..1ba0029ccf
--- /dev/null
+++ b/docs/performance/2026-09-26-before.json
@@ -0,0 +1,510 @@
+{
+ "timestamp": "2026-09-27T00:30:21.217Z",
+ "build": {
+ "builtAt": "2026-09-27T00:28:39.209Z",
+ "revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
+ "baselineOverlay": "HEAD",
+ "changedFiles": [
+ "frontend/apps/artcraft/app/src/pages/MainApp.tsx",
+ "frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
+ "frontend/package-lock.json",
+ "frontend/package.json"
+ ]
+ },
+ "environment": {
+ "platform": "darwin",
+ "arch": "arm64",
+ "cpu": "Apple M4 Pro",
+ "browser": "153.0.8010.53",
+ "node": "v24.13.0",
+ "runs": 7,
+ "viewport": "1440x1000@1",
+ "ipc": "mocked",
+ "network": "local assets only, browser cache disabled"
+ },
+ "summary": {
+ "startup": {
+ "readyMs": {
+ "median": 566.3999999761581,
+ "min": 532.3999999761581,
+ "max": 847.7999999523163
+ },
+ "jsBytes": {
+ "median": 9052991,
+ "min": 9052991,
+ "max": 9052991
+ },
+ "longTaskMs": {
+ "median": 336,
+ "min": 314,
+ "max": 407
+ },
+ "webglContexts": {
+ "median": 1,
+ "min": 1,
+ "max": 1
+ }
+ },
+ "refresh": {
+ "readyMs": {
+ "median": 248.60000002384186,
+ "min": 247.59999990463257,
+ "max": 351.1999999284744
+ },
+ "jsBytes": {
+ "median": 9052991,
+ "min": 9052991,
+ "max": 9052991
+ },
+ "longTaskMs": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ },
+ "webglContexts": {
+ "median": 1,
+ "min": 1,
+ "max": 1
+ }
+ },
+ "firstDraw": {
+ "stateReadyMs": {
+ "median": 40.40000009536743,
+ "min": 37.299999952316284,
+ "max": 43.299999952316284
+ },
+ "paintMs": {
+ "median": 120.90000009536743,
+ "min": 117.89999997615814,
+ "max": 126.79999995231628
+ },
+ "fileReads": {
+ "median": 0,
+ "min": 0,
+ "max": 0
+ }
+ },
+ "leaveDraw": {
+ "stateReadyMs": {
+ "median": 23.399999976158142,
+ "min": 20.100000023841858,
+ "max": 26
+ },
+ "paintMs": {
+ "median": 29.5,
+ "min": 21.200000047683716,
+ "max": 35.5
+ },
+ "fileReads": {
+ "median": 4,
+ "min": 4,
+ "max": 4
+ }
+ },
+ "returnDraw": {
+ "stateReadyMs": {
+ "median": 42.10000002384186,
+ "min": 36.59999990463257,
+ "max": 45.200000047683716
+ },
+ "paintMs": {
+ "median": 105.69999992847443,
+ "min": 100.89999997615814,
+ "max": 109.20000004768372
+ },
+ "fileReads": {
+ "median": 4,
+ "min": 4,
+ "max": 4
+ }
+ }
+ },
+ "samples": [
+ {
+ "startup": {
+ "readyMs": 847.7999999523163,
+ "jsBytes": 9052991,
+ "longTaskMs": 407,
+ "webglContexts": 1
+ },
+ "refresh": {
+ "readyMs": 351.1999999284744,
+ "jsBytes": 9052991,
+ "longTaskMs": 0,
+ "webglContexts": 1
+ },
+ "firstDraw": {
+ "stateReadyMs": 43.299999952316284,
+ "paintMs": 126.79999995231628,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 25.100000023841858,
+ "paintMs": 35.5,
+ "fileReads": 4
+ },
+ "returnDraw": {
+ "stateReadyMs": 36.59999990463257,
+ "paintMs": 100.89999997615814,
+ "fileReads": 4
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 0,
+ "historyLength": 2,
+ "commands": {
+ "plugin:event|listen": 26,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 539.3999999761581,
+ "jsBytes": 9052991,
+ "longTaskMs": 317,
+ "webglContexts": 1
+ },
+ "refresh": {
+ "readyMs": 264.60000002384186,
+ "jsBytes": 9052991,
+ "longTaskMs": 0,
+ "webglContexts": 1
+ },
+ "firstDraw": {
+ "stateReadyMs": 39.39999997615814,
+ "paintMs": 120.20000004768372,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 20.100000023841858,
+ "paintMs": 21.200000047683716,
+ "fileReads": 4
+ },
+ "returnDraw": {
+ "stateReadyMs": 41.60000002384186,
+ "paintMs": 104.60000002384186,
+ "fileReads": 4
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 0,
+ "historyLength": 2,
+ "commands": {
+ "plugin:event|listen": 26,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 561.9000000953674,
+ "jsBytes": 9052991,
+ "longTaskMs": 329,
+ "webglContexts": 1
+ },
+ "refresh": {
+ "readyMs": 248.19999992847443,
+ "jsBytes": 9052991,
+ "longTaskMs": 0,
+ "webglContexts": 1
+ },
+ "firstDraw": {
+ "stateReadyMs": 40.40000009536743,
+ "paintMs": 120.90000009536743,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 23.399999976158142,
+ "paintMs": 33.39999997615814,
+ "fileReads": 4
+ },
+ "returnDraw": {
+ "stateReadyMs": 37.299999952316284,
+ "paintMs": 101.10000002384186,
+ "fileReads": 4
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 0,
+ "historyLength": 2,
+ "commands": {
+ "plugin:event|listen": 26,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 569.5,
+ "jsBytes": 9052991,
+ "longTaskMs": 336,
+ "webglContexts": 1
+ },
+ "refresh": {
+ "readyMs": 248,
+ "jsBytes": 9052991,
+ "longTaskMs": 0,
+ "webglContexts": 1
+ },
+ "firstDraw": {
+ "stateReadyMs": 38,
+ "paintMs": 119.30000007152557,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 26,
+ "paintMs": 34.799999952316284,
+ "fileReads": 4
+ },
+ "returnDraw": {
+ "stateReadyMs": 45.200000047683716,
+ "paintMs": 109.20000004768372,
+ "fileReads": 4
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 0,
+ "historyLength": 2,
+ "commands": {
+ "plugin:event|listen": 26,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 567.5,
+ "jsBytes": 9052991,
+ "longTaskMs": 339,
+ "webglContexts": 1
+ },
+ "refresh": {
+ "readyMs": 248.60000002384186,
+ "jsBytes": 9052991,
+ "longTaskMs": 0,
+ "webglContexts": 1
+ },
+ "firstDraw": {
+ "stateReadyMs": 40.89999997615814,
+ "paintMs": 122.10000002384186,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 23.200000047683716,
+ "paintMs": 29.5,
+ "fileReads": 4
+ },
+ "returnDraw": {
+ "stateReadyMs": 42.10000002384186,
+ "paintMs": 105.69999992847443,
+ "fileReads": 4
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 0,
+ "historyLength": 2,
+ "commands": {
+ "plugin:event|listen": 26,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 566.3999999761581,
+ "jsBytes": 9052991,
+ "longTaskMs": 336,
+ "webglContexts": 1
+ },
+ "refresh": {
+ "readyMs": 281.39999997615814,
+ "jsBytes": 9052991,
+ "longTaskMs": 0,
+ "webglContexts": 1
+ },
+ "firstDraw": {
+ "stateReadyMs": 43.09999990463257,
+ "paintMs": 124.69999992847443,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 23.399999976158142,
+ "paintMs": 26.199999928474426,
+ "fileReads": 4
+ },
+ "returnDraw": {
+ "stateReadyMs": 42.799999952316284,
+ "paintMs": 107.19999992847443,
+ "fileReads": 4
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 0,
+ "historyLength": 2,
+ "commands": {
+ "plugin:event|listen": 26,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ },
+ {
+ "startup": {
+ "readyMs": 532.3999999761581,
+ "jsBytes": 9052991,
+ "longTaskMs": 314,
+ "webglContexts": 1
+ },
+ "refresh": {
+ "readyMs": 247.59999990463257,
+ "jsBytes": 9052991,
+ "longTaskMs": 0,
+ "webglContexts": 1
+ },
+ "firstDraw": {
+ "stateReadyMs": 37.299999952316284,
+ "paintMs": 117.89999997615814,
+ "fileReads": 0
+ },
+ "leaveDraw": {
+ "stateReadyMs": 21.5,
+ "paintMs": 22.899999976158142,
+ "fileReads": 4
+ },
+ "returnDraw": {
+ "stateReadyMs": 44.09999990463257,
+ "paintMs": 108.39999997615814,
+ "fileReads": 4
+ },
+ "fixture": {
+ "images": 4,
+ "imageSize": 1024,
+ "imageBytesEach": 2356706,
+ "strokes": 80,
+ "coordinatesPerStroke": 200
+ },
+ "afterReturn": {
+ "nodeCount": 84,
+ "selectedCount": 0,
+ "historyLength": 2,
+ "commands": {
+ "plugin:event|listen": 26,
+ "get_app_info_command": 1,
+ "list_image_models_command": 1,
+ "list_video_models_command": 1,
+ "get_task_queue_command": 1,
+ "estimate_image_cost_command": 5,
+ "storyteller_get_login_session_command": 1,
+ "plugin:http|fetch": 5,
+ "storyteller_get_credits_command": 1,
+ "storyteller_get_subscription_command": 1,
+ "plugin:window|is_maximized": 1,
+ "plugin:event|unlisten": 2
+ }
+ }
+ }
+ ],
+ "errors": []
+}
diff --git a/docs/website-login.md b/docs/website-login.md
new file mode 100644
index 0000000000..2015efaefd
--- /dev/null
+++ b/docs/website-login.md
@@ -0,0 +1,80 @@
+# Website login from the desktop app
+
+The React login/signup dialog calls Tauri commands only, including its startup
+session check and password forms. Website and QR actions appear only in login
+mode. Tauri never connects to MySQL; the desktop uses Rust HTTP API bindings. Creating/polling login challenges and
+verifying `/v1/session` use `artcraft_client::endpoints::users::login_challenges`.
+The Rust command uses `AppEnvConfigs.storyteller_host` for every request, keeps the
+private device token in native memory, and returns only a local challenge handle,
+approval URL/code/deadline, status, and the verified user to JavaScript. The signed
+session cookie never crosses IPC or gets written to JavaScript local storage.
+
+After redemption, Rust verifies the signed session against the same API host,
+installs the server's cookie into the native HTTP jar and credential manager, and
+persists the jar. Closing the dialog removes the local handle; a cancellation
+while HTTP is in flight prevents installing the late response. Retries retrieve
+the same server session. Neither creation nor website approval pre-creates one.
+
+## Session response compatibility
+
+A server feature flag added after a desktop release must not prevent login. The
+session decoder keeps known feature flags and ignores unknown flag names, such
+as `use_qt` on older clients. Malformed flag values and required authentication
+fields still fail validation. Tests cover a real Rust HTTP session response with
+both known and future flags, followed by native cookie installation/persistence.
+A late startup session check cannot reset an active or completed website login.
+
+## Diagnosing 401 responses
+
+`storyteller_activity_thread` reports errors from `/v1/analytics/active_user_v2`.
+This is the background activity ping, not the login-challenge endpoint. It requires
+a user session. An anonymous `visitor` cookie alone is now skipped. Expired or
+revoked sessions can still produce a 401 and require signing in again.
+
+Activity failures log the configured API origin (including port), endpoint, and
+error. Native website-login requests/failures log origin, endpoint, and HTTP
+status. Request bodies, device tokens, approval URLs, and signed cookies are not
+logged by the new client, including at debug level.
+
+## API environment
+
+`~/Artcraft/settings/env_configs.json` controls the native API host:
+
+```json
+{"storyteller_host":"localhost","storyteller_port":12345}
+```
+
+Local mode uses `http://localhost:12345` and a local approval website at
+`http://localhost:4201/login/desktop`. Run storyteller-web with its migrations
+applied, and the services webapp with `USE_LOCAL_API=1`. The local website must
+also use the local API. A challenge in a local database cannot be approved on the
+production website, even when that browser already has a production session.
+
+For the production API and production website, use `"storyteller_host":"production"`
+(or omit the override), then restart ArtCraft. API host configuration is not
+silently changed by login. Local and production approval URLs cannot be mixed.
+Localhost development URLs are for the same computer; mobile QR testing needs
+a shared/deployed environment.
+
+## Tests
+
+From the desktop repository:
+
+```sh
+SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
+cd frontend
+npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
+```
+
+Native tests use only ephemeral loopback HTTP servers and temporary cookie stores.
+They exercise the real API bindings and the command implementation, including
+cookie verification/persistence, rejection, expiry, retries, in-flight cancellation,
+origin separation, no redirects, redacted errors, visitor-only telemetry, future
+feature flags, malformed session data, and password login/signup/session checks.
+UI tests prohibit JavaScript HTTP and runtime API imports. They verify the IPC
+contract, native browser opening, QR/code display, status, backoff, cleanup,
+login-only button placement, native password forms, and stale session responses.
+
+The services repository also has real MySQL integration tests for approval,
+redemption, rejection, concurrency, and downstream authentication, using isolated
+local databases. No test contacts a production API or database.
diff --git a/frontend/apps/artcraft/app/src/pages/MainApp.tsx b/frontend/apps/artcraft/app/src/pages/MainApp.tsx
index 3db83f71cf..b35bebd13a 100644
--- a/frontend/apps/artcraft/app/src/pages/MainApp.tsx
+++ b/frontend/apps/artcraft/app/src/pages/MainApp.tsx
@@ -10,8 +10,7 @@
// siblings, never wrapped by EngineProvider, so they have no
// dependency on the 3D engine being present.
-import { useEffect, useState } from "react";
-import * as gpu from "detect-gpu";
+import { lazy, Suspense, useEffect } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
@@ -58,26 +57,55 @@ import { useTextToImageStore } from "./PageImage/TextToImageStore";
import { AppsIndexPage } from "./PageApps/AppsIndexPage";
import PageDraw from "./PageDraw/PageDraw";
-import TextToImage from "./PageImage/TextToImage";
-import ImageToVideo from "./PageVideo/ImageToVideo";
-import CreateAudio from "./PageAudio/CreateAudio";
-import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
-import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
-import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
-import { ImageTo3DObject } from "./PageImageTo3DObject";
-import { ImageTo3DWorld } from "./PageImageTo3DWorld";
-import { RemoveBackground } from "./PageRemoveBackground";
-import { Angles } from "./PageAngles";
-import { Storyboard } from "./PageStoryboard";
-import { PageBackgroundChange } from "./PageBackgroundChange";
-import { PageScene } from "./PageScene";
-import { PageVideoEditor } from "./PageVideoEditor";
-import { PageMoodboard } from "./PageMoodboard";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
+// Load editors when selected, keeping their code off the launch path. Drawing
+// stays eager: its shared store/UI is already needed by the shell, so deferring
+// its small host adapter adds first-open latency for very little byte saving.
+// Declare lazy components once so navigating does not recreate their identity.
+const TextToImage = lazy(() => import("./PageImage/TextToImage"));
+const ImageToVideo = lazy(() => import("./PageVideo/ImageToVideo"));
+const CreateAudio = lazy(() => import("./PageAudio/CreateAudio"));
+const VideoFrameExtractor = lazy(() =>
+ import("./PageVideoFrameExtractor").then((m) => ({ default: m.VideoFrameExtractor })),
+);
+const VideoWatermarkRemover = lazy(() =>
+ import("./PageVideoWatermarkRemover").then((m) => ({ default: m.VideoWatermarkRemover })),
+);
+const ImageWatermarkRemover = lazy(() =>
+ import("./PageImageWatermarkRemover").then((m) => ({ default: m.ImageWatermarkRemover })),
+);
+const ImageTo3DObject = lazy(() =>
+ import("./PageImageTo3DObject").then((m) => ({ default: m.ImageTo3DObject })),
+);
+const ImageTo3DWorld = lazy(() =>
+ import("./PageImageTo3DWorld").then((m) => ({ default: m.ImageTo3DWorld })),
+);
+const RemoveBackground = lazy(() =>
+ import("./PageRemoveBackground").then((m) => ({ default: m.RemoveBackground })),
+);
+const Angles = lazy(() =>
+ import("./PageAngles").then((m) => ({ default: m.Angles })),
+);
+const Storyboard = lazy(() =>
+ import("./PageStoryboard").then((m) => ({ default: m.Storyboard })),
+);
+const PageBackgroundChange = lazy(() =>
+ import("./PageBackgroundChange").then((m) => ({ default: m.PageBackgroundChange })),
+);
+const PageScene = lazy(() =>
+ import("./PageScene").then((m) => ({ default: m.PageScene })),
+);
+const PageVideoEditor = lazy(() =>
+ import("./PageVideoEditor").then((m) => ({ default: m.PageVideoEditor })),
+);
+const PageMoodboard = lazy(() =>
+ import("./PageMoodboard").then((m) => ({ default: m.PageMoodboard })),
+);
+
interface Props {
sceneToken?: string;
}
@@ -121,8 +149,7 @@ export const MainApp = ({ sceneToken }: Props) => {
toast.error("File deleted.");
});
- // Session probe (runs once per shell mount) and GPU detection.
- // Both are app-wide concerns, not 3D-only.
+ // Session probe (runs once per shell mount).
useEffect(() => {
const usersApi = new UsersApi();
usersApi.GetSession().then((result) => {
@@ -132,20 +159,6 @@ export const MainApp = ({ sceneToken }: Props) => {
});
}, []);
- const [, setValidGpu] = useState("unknown");
- useEffect(() => {
- const { getGPUTier } = gpu;
- getGPUTier().then((gpuTier) => {
- console.log("GPU tier", gpuTier);
- let isValid = false;
- const fps = gpuTier.fps || 0;
- if (gpuTier.tier > 1) isValid = true;
- if (fps > 15) isValid = true;
- if (gpuTier.gpu === "apple gpu (Apple GPU)") isValid = true;
- setValidGpu(isValid ? "valid" : "error");
- });
- }, []);
-
const { triggerRecheck } = useLoginModalStore();
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
useCreditsModalStore();
@@ -179,7 +192,18 @@ export const MainApp = ({ sceneToken }: Props) => {
}}
/>
-