diff --git a/.github/workflows/deploy-pages-channel.yml b/.github/workflows/deploy-pages-channel.yml
new file mode 100644
index 000000000..5740ac954
--- /dev/null
+++ b/.github/workflows/deploy-pages-channel.yml
@@ -0,0 +1,130 @@
+name: Deploy Pages Channel
+
+on:
+ workflow_dispatch:
+ inputs:
+ channel:
+ description: Static site channel to deploy
+ required: true
+ type: choice
+ options:
+ - beta
+ - demo
+ ref:
+ description: Git ref to build
+ required: true
+ default: main
+ dry_run:
+ description: Build and smoke-check without pushing to the Pages repo
+ required: true
+ type: boolean
+ default: false
+
+concurrency:
+ group: pages-channel-${{ inputs.channel }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ RUST_BACKTRACE: 1
+ RISC_V_TARGET: riscv32imac-unknown-none-elf
+ RUSTUP_PERMIT_COPY_RENAME: 1
+
+jobs:
+ deploy:
+ name: Build and deploy ${{ inputs.channel }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install system deps
+ run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config
+
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.ref }}
+ fetch-depth: 0
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: nightly-2026-04-27
+ components: rust-src
+
+ - name: Install Rust targets
+ run: |
+ rustup target add wasm32-unknown-unknown
+ rustup target add ${{ env.RISC_V_TARGET }}
+
+ - name: Cache cargo
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ runner.arch }}-pages-cargo-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-pages-cargo-
+
+ - name: Install just
+ uses: extractions/setup-just@v3
+
+ - name: Install wasm-bindgen-cli
+ run: cargo install wasm-bindgen-cli --version 0.2.114 --locked
+
+ - name: Install espflash
+ if: inputs.channel == 'beta'
+ run: cargo install espflash --version 3.3.0 --locked
+
+ - name: Build beta artifact
+ if: inputs.channel == 'beta'
+ run: just studio-web-deploy-dir beta target/pages/channel beta.lightplayer.app
+
+ - name: Smoke-check beta artifact
+ if: inputs.channel == 'beta'
+ run: just studio-web-smoke target/pages/channel
+
+ - name: Build demo artifact
+ if: inputs.channel == 'demo'
+ run: just web-demo-deploy-dir demo target/pages/channel demo.lightplayer.app
+
+ - name: Smoke-check demo artifact
+ if: inputs.channel == 'demo'
+ run: just web-demo-smoke target/pages/channel
+
+ - name: Create Pages App token
+ if: ${{ inputs.dry_run == false }}
+ id: pages-app-token
+ uses: actions/create-github-app-token@v3
+ with:
+ app-id: ${{ secrets.LIGHTPLAYER_PAGES_APP_ID }}
+ private-key: ${{ secrets.LIGHTPLAYER_PAGES_PRIVATE_KEY }}
+ owner: light-player
+ repositories: |
+ lightplayer-beta-pages
+ lightplayer-demo-pages
+ permission-contents: write
+
+ - name: Publish channel artifact
+ env:
+ LIGHTPLAYER_PAGES_TOKEN: ${{ steps.pages-app-token.outputs.token }}
+ CHANNEL: ${{ inputs.channel }}
+ DRY_RUN: ${{ inputs.dry_run }}
+ run: |
+ set -euo pipefail
+ target_repo=""
+ case "$CHANNEL" in
+ beta) target_repo="light-player/lightplayer-beta-pages" ;;
+ demo) target_repo="light-player/lightplayer-demo-pages" ;;
+ *) echo "unknown channel: $CHANNEL" >&2; exit 2 ;;
+ esac
+
+ args=(--repo "$target_repo" --dir target/pages/channel --message "deploy: ${CHANNEL} ${GITHUB_SHA::12}")
+ if [[ "$DRY_RUN" == "true" ]]; then
+ args+=(--dry-run)
+ fi
+ scripts/pages/publish-static-repo.sh "${args[@]}"
diff --git a/.github/workflows/deploy-studio-pages.yml b/.github/workflows/deploy-studio-pages.yml
new file mode 100644
index 000000000..1e91557ec
--- /dev/null
+++ b/.github/workflows/deploy-studio-pages.yml
@@ -0,0 +1,88 @@
+name: Deploy Studio Pages
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+concurrency:
+ group: pages-production
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+env:
+ RUST_BACKTRACE: 1
+ RISC_V_TARGET: riscv32imac-unknown-none-elf
+ RUSTUP_PERMIT_COPY_RENAME: 1
+
+jobs:
+ deploy:
+ name: Build and deploy Studio
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Install system deps
+ run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config
+
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: nightly-2026-04-27
+ components: rust-src
+
+ - name: Install Rust targets
+ run: |
+ rustup target add wasm32-unknown-unknown
+ rustup target add ${{ env.RISC_V_TARGET }}
+
+ - name: Cache cargo
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/bin/
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-${{ runner.arch }}-pages-cargo-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ runner.arch }}-pages-cargo-
+
+ - name: Install just
+ uses: extractions/setup-just@v3
+
+ - name: Install wasm-bindgen-cli
+ run: cargo install wasm-bindgen-cli --version 0.2.114 --locked
+
+ - name: Install espflash
+ run: cargo install espflash --version 3.3.0 --locked
+
+ - name: Build Pages artifact
+ run: just studio-web-deploy-dir production target/pages/studio lightplayer.app
+
+ - name: Smoke-check Pages artifact
+ run: just studio-web-smoke target/pages/studio
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: target/pages/studio
+
+ - name: Deploy Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 6969e8294..d16d34d7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,4 +33,10 @@ perf.data*
.DS_Store
.builtins-source-hash
traces/
-profiles/
\ No newline at end of file
+profiles/
+lp-app/lpa-studio-web/public/pkg/
+lp-app/lpa-studio-web/public/fw-browser-worker.js
+lp-app/lpa-studio-web/public/firmware/
+lp-app/lpa-studio-web/dist/
+lp-app/lpa-studio-web/story-images/.new/
+lp-app/lpa-studio-web/story-images/.scratch/
diff --git a/.idea/lp2025.iml b/.idea/lp2025.iml
index 27f10b7b8..8fe621d19 100644
--- a/.idea/lp2025.iml
+++ b/.idea/lp2025.iml
@@ -103,6 +103,13 @@
+
+
+
+
+
+
+
diff --git a/AGENTS.md b/AGENTS.md
index 8779970b5..b4405e44e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -222,6 +222,58 @@ deprecated. Do not adopt it in new code. If a plan file you are executing
asks for "tests at the top", treat that as a stale instruction and put the
test module at the bottom anyway.
+## Personal planning workflow
+
+New agent planning work uses the Photomancer personal planning workspace, not
+new repo-local plan or roadmap directories.
+
+- Use `pm-plan` for new planning, roadmap, and investigation artifacts.
+- Use `pm-implement` to execute an existing shared `plan.md`.
+- Use `pm-review` for durable review artifacts.
+- Resolve context from `agent-context.toml`; the repo slug is `lightplayer`.
+- Resolve the workspace from `PHOTOMANCER_PLANNING_ROOT`, or from the default
+ `~/.photomancer/planning` link.
+- Store new active artifacts under
+ `/lightplayer/-/`.
+- Store completed artifacts under `/lightplayer/_archive/`.
+- Store review artifacts under `/lightplayer/_reviews/`.
+
+Durable decisions belong in repo ADRs under `docs/adr/`. Intermediate plans,
+phase prompts, review notes, scratch reports, and implementation logs belong in
+the shared planning workspace. Existing `docs/plans`, `docs/plans-old`,
+`docs/roadmaps`, and `docs/roadmaps-old` content is historical and should not
+be migrated unless a separate migration plan asks for it.
+
+## Studio UI visual baselines
+
+When a change touches non-generated files under `lp-app/lp-studio-web/`, run the
+Studio story baseline helper before committing:
+
+```bash
+just studio-story-baselines-if-needed
+```
+
+If it updates files under `lp-app/lp-studio-web/story-images/`, include those
+PNG changes in the same commit and mention the affected story baselines in the
+final summary. The helper intentionally ignores generated web artifacts,
+scratch PNGs, fresh check PNGs, and the baseline PNGs themselves.
+
+Useful related commands:
+
+```bash
+just studio-story-pngs # ignored scratch PNGs for quick local review
+just studio-story-baselines # update committed story baselines
+just studio-story-check # compare fresh PNGs to committed baselines
+```
+
+`studio-story-baselines` and `studio-story-check` require `oxipng`; run
+`scripts/dev-init.sh` or install it with `cargo install oxipng` /
+`brew install oxipng`.
+
+Do not add an auto-mutating Git hook for this workflow unless the user asks for
+one explicitly. Hooks that rewrite the working tree during commit are annoying
+during rebases, merges, and partial commits.
+
## Validation Commands
These commands must pass for any change touching the shader pipeline:
diff --git a/Cargo.lock b/Cargo.lock
index 0fde21ea6..fc8ef2505 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -426,6 +426,28 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "async-task"
version = "4.7.1"
@@ -443,6 +465,22 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "async-tungstenite"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f"
+dependencies = [
+ "atomic-waker",
+ "futures-core",
+ "futures-io",
+ "futures-task",
+ "futures-util",
+ "log",
+ "pin-project-lite",
+ "tungstenite 0.27.0",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -506,6 +544,54 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "multer",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+]
+
[[package]]
name = "base64"
version = "0.13.1"
@@ -678,6 +764,9 @@ name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+dependencies = [
+ "serde",
+]
[[package]]
name = "calloop"
@@ -730,6 +819,12 @@ dependencies = [
"wayland-client",
]
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
[[package]]
name = "cc"
version = "1.2.56"
@@ -748,6 +843,17 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -775,6 +881,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "charset"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
+dependencies = [
+ "base64 0.22.1",
+ "encoding_rs",
+]
+
[[package]]
name = "chrono"
version = "0.4.44"
@@ -788,6 +904,33 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
[[package]]
name = "clap"
version = "4.5.60"
@@ -909,6 +1052,131 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa"
+[[package]]
+name = "const-serialize"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc"
+dependencies = [
+ "const-serialize-macro 0.7.2",
+]
+
+[[package]]
+name = "const-serialize"
+version = "0.8.0-alpha.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b"
+dependencies = [
+ "const-serialize 0.7.2",
+ "const-serialize-macro 0.8.0-alpha.0",
+ "serde",
+]
+
+[[package]]
+name = "const-serialize-macro"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "const-serialize-macro"
+version = "0.8.0-alpha.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "const-str"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160"
+
+[[package]]
+name = "const_format"
+version = "0.2.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
+dependencies = [
+ "const_format_proc_macros",
+ "konst",
+]
+
+[[package]]
+name = "const_format_proc_macros"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "content_disposition"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc14a88e1463ddd193906285abe5c360c7e8564e05ccc5d501755f7fbc9ca9c"
+dependencies = [
+ "charset",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cookie_store"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
+dependencies = [
+ "cookie",
+ "document-features",
+ "idna",
+ "log",
+ "publicsuffix",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
[[package]]
name = "cordyceps"
version = "0.3.4"
@@ -1489,27 +1757,493 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case 0.10.0",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.117",
+ "unicode-xid",
+]
+
[[package]]
name = "dialoguer"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
dependencies = [
- "console",
- "shell-words",
- "tempfile",
- "thiserror 1.0.69",
- "zeroize",
+ "console",
+ "shell-words",
+ "tempfile",
+ "thiserror 1.0.69",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dioxus"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c01ecf7ddbae18a419ad3d83c486101a85ffc5740ea09cdd0f09a30dc12170d"
+dependencies = [
+ "dioxus-asset-resolver",
+ "dioxus-cli-config",
+ "dioxus-config-macro",
+ "dioxus-config-macros",
+ "dioxus-core",
+ "dioxus-core-macro",
+ "dioxus-devtools",
+ "dioxus-document",
+ "dioxus-fullstack",
+ "dioxus-history",
+ "dioxus-hooks",
+ "dioxus-html",
+ "dioxus-logger",
+ "dioxus-signals",
+ "dioxus-stores",
+ "dioxus-web",
+ "manganis",
+ "subsecond",
+ "warnings",
+]
+
+[[package]]
+name = "dioxus-asset-resolver"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69387edbbc60c7cb93ad96d8cc7a22b49a76e21643380b89b1c49a78d347ff60"
+dependencies = [
+ "dioxus-cli-config",
+ "http",
+ "infer",
+ "jni 0.21.1",
+ "js-sys",
+ "ndk",
+ "ndk-context",
+ "ndk-sys 0.6.0+11769913",
+ "percent-encoding",
+ "thiserror 2.0.18",
+ "tokio",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "dioxus-cli-config"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c000f584ddf608e2b272b3074bf11512a474eeeb2eb85a1915f276ce5c4a8615"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "dioxus-config-macro"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7637091592978fbfdb45a16b26bd99fd97fb1bd7e31c6a963530e00c022af321"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "dioxus-config-macros"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54f9ed8fc1a215ad34bb8dbae42a4ea54efbcd26ca9006bbe5cca78e511bf25f"
+
+[[package]]
+name = "dioxus-core"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45887100ff0cf89abeb8b659808294fda48cd53f3b424e36407dedffcfea830b"
+dependencies = [
+ "anyhow",
+ "const_format",
+ "dioxus-core-types",
+ "futures-channel",
+ "futures-util",
+ "generational-box",
+ "longest-increasing-subsequence",
+ "rustc-hash 2.1.1",
+ "rustversion",
+ "serde",
+ "slab",
+ "slotmap",
+ "subsecond",
+ "tracing",
+]
+
+[[package]]
+name = "dioxus-core-macro"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "370c63663dff0f24df5dfea643ca239283542c6b228a302f69b32e1d36762b7f"
+dependencies = [
+ "convert_case 0.8.0",
+ "dioxus-rsx",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dioxus-core-types"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36963eab106b169737762f9cd5ee5fd97f585989dcb2d8e30a596e97a6999009"
+
+[[package]]
+name = "dioxus-devtools"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2349cedbdf1b429df1f1bea61fdee0ad3dae7b2548eedfbeca82710122a57da0"
+dependencies = [
+ "dioxus-cli-config",
+ "dioxus-core",
+ "dioxus-devtools-types",
+ "dioxus-signals",
+ "serde",
+ "serde_json",
+ "subsecond",
+ "thiserror 2.0.18",
+ "tracing",
+ "tungstenite 0.28.0",
+]
+
+[[package]]
+name = "dioxus-devtools-types"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab9b0f7565d1916b70915f59b89ea8054ef0a9d67a364a32bbee68ef5f3818d"
+dependencies = [
+ "dioxus-core",
+ "serde",
+ "subsecond-types",
+]
+
+[[package]]
+name = "dioxus-document"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37e3a5bec7ffc999ff23446a487eb5cd86111d1574a23533dd3f8b3c69a53a22"
+dependencies = [
+ "dioxus-core",
+ "dioxus-core-macro",
+ "dioxus-core-types",
+ "dioxus-html",
+ "futures-channel",
+ "futures-util",
+ "generational-box",
+ "lazy-js-bundle",
+ "serde",
+ "serde_json",
+ "tracing",
+]
+
+[[package]]
+name = "dioxus-fullstack"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37f0558edb88af5ad47275ae36a7f06317163ba482db377c26d7d8590b5cd0f6"
+dependencies = [
+ "anyhow",
+ "async-stream",
+ "async-tungstenite",
+ "axum",
+ "axum-core",
+ "base64 0.22.1",
+ "bytes",
+ "ciborium",
+ "const-str",
+ "const_format",
+ "content_disposition",
+ "derive_more",
+ "dioxus-asset-resolver",
+ "dioxus-cli-config",
+ "dioxus-core",
+ "dioxus-fullstack-core",
+ "dioxus-fullstack-macro",
+ "dioxus-hooks",
+ "dioxus-html",
+ "dioxus-signals",
+ "form_urlencoded",
+ "futures",
+ "futures-channel",
+ "futures-util",
+ "gloo-net",
+ "headers",
+ "http",
+ "http-body",
+ "http-body-util",
+ "js-sys",
+ "mime",
+ "pin-project",
+ "reqwest",
+ "rustversion",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "serde_qs",
+ "serde_urlencoded",
+ "thiserror 2.0.18",
+ "tokio-util",
+ "tracing",
+ "tungstenite 0.27.0",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "dioxus-fullstack-core"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc634b28b4b1e3eab1e8df4f98510e2d2fa39d686321467f977213155e86ed2b"
+dependencies = [
+ "anyhow",
+ "axum-core",
+ "base64 0.22.1",
+ "ciborium",
+ "dioxus-core",
+ "dioxus-document",
+ "dioxus-history",
+ "dioxus-hooks",
+ "dioxus-signals",
+ "futures-channel",
+ "futures-util",
+ "generational-box",
+ "http",
+ "inventory",
+ "parking_lot",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "dioxus-fullstack-macro"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85a8fe7da549859fae00c7f4bf11a2aab734ae7ef6f98f280dce9bea1f3326ec"
+dependencies = [
+ "const_format",
+ "convert_case 0.8.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "dioxus-history"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a15232302d1933015fcf2d6fe9e286ad36f6e9c205a546089a0f326023bb0d2"
+dependencies = [
+ "dioxus-core",
+ "tracing",
+]
+
+[[package]]
+name = "dioxus-hooks"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4534f91cf6305204b948bdec130076ac9ecc7c22faab29475b76870558bf73ea"
+dependencies = [
+ "dioxus-core",
+ "dioxus-signals",
+ "futures-channel",
+ "futures-util",
+ "generational-box",
+ "rustversion",
+ "slab",
+ "tracing",
+]
+
+[[package]]
+name = "dioxus-html"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e03d6ad4040b667f2b2eefcb678840e630938c09bf9ec39b04ea4d1d96d90d44"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "dioxus-core",
+ "dioxus-core-macro",
+ "dioxus-core-types",
+ "dioxus-hooks",
+ "dioxus-html-internal-macro",
+ "enumset",
+ "euclid",
+ "futures-channel",
+ "futures-util",
+ "generational-box",
+ "keyboard-types",
+ "lazy-js-bundle",
+ "rustversion",
+ "tracing",
+]
+
+[[package]]
+name = "dioxus-html-internal-macro"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "584e2772127ab00f0d5e1d4d9795f39fecebc828ece0b7a02349d438bc1b1ce7"
+dependencies = [
+ "convert_case 0.8.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dioxus-interpreter-js"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11999d6eb5bb179a9512dad30e5de408aab66f2cb65de9098c9fbe02927e2978"
+dependencies = [
+ "js-sys",
+ "lazy-js-bundle",
+ "rustc-hash 2.1.1",
+ "sledgehammer_bindgen",
+ "sledgehammer_utils",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "dioxus-logger"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ccdfe36d2cb830a2784e40f7e6f7199805a2c6da99bd65b1ca308f11aed28"
+dependencies = [
+ "dioxus-cli-config",
+ "tracing",
+ "tracing-subscriber",
+ "tracing-wasm",
+]
+
+[[package]]
+name = "dioxus-rsx"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2106afda239a4c7c22ffa1ca19117011225fc1c735c139c0a5b765996aa8bb1d"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "rustversion",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dioxus-signals"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3705754f5e043deec9fc7af0d159f18e5b21c02c47d255c7e477f31368f0b6d2"
+dependencies = [
+ "dioxus-core",
+ "futures-channel",
+ "futures-util",
+ "generational-box",
+ "parking_lot",
+ "rustc-hash 2.1.1",
+ "tracing",
+ "warnings",
+]
+
+[[package]]
+name = "dioxus-stores"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64bec7b21c86b1360ec965a07a53a2c96b7caee3465049e1c299a45024e87614"
+dependencies = [
+ "dioxus-core",
+ "dioxus-signals",
+ "dioxus-stores-macro",
+ "generational-box",
+]
+
+[[package]]
+name = "dioxus-stores-macro"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a5875e9f890f27b1cc3e5b56c1e23601211470315a1fb8627c4ca4f3b2be9a"
+dependencies = [
+ "convert_case 0.8.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
-name = "digest"
-version = "0.10.7"
+name = "dioxus-web"
+version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+checksum = "bc0a0be76b404e8242a597db0fb239d05f8dee4e7856bc1fc7144f7e244822fd"
dependencies = [
- "block-buffer",
- "crypto-common",
+ "dioxus-cli-config",
+ "dioxus-core",
+ "dioxus-core-types",
+ "dioxus-devtools",
+ "dioxus-document",
+ "dioxus-history",
+ "dioxus-html",
+ "dioxus-interpreter-js",
+ "dioxus-signals",
+ "futures-channel",
+ "futures-util",
+ "generational-box",
+ "gloo-timers",
+ "js-sys",
+ "lazy-js-bundle",
+ "rustc-hash 2.1.1",
+ "send_wrapper",
+ "serde",
+ "serde-wasm-bindgen",
+ "serde_json",
+ "tracing",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
]
[[package]]
@@ -1610,6 +2344,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -2551,6 +3291,15 @@ dependencies = [
"vcell",
]
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -2715,6 +3464,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
+ "futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -2813,6 +3563,24 @@ dependencies = [
"slab",
]
+[[package]]
+name = "fw-browser"
+version = "40.0.0"
+dependencies = [
+ "fw-core",
+ "lpa-server",
+ "lpc-hardware",
+ "lpc-model",
+ "lpc-shared",
+ "lpc-wire",
+ "lpfs",
+ "lpvm-wasm",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-test",
+]
+
[[package]]
name = "fw-checks"
version = "40.0.0"
@@ -2907,6 +3675,21 @@ dependencies = [
"unwinding",
]
+[[package]]
+name = "fw-host"
+version = "40.0.0"
+dependencies = [
+ "fw-core",
+ "lpa-client",
+ "lpa-server",
+ "lpc-hardware",
+ "lpc-model",
+ "lpc-shared",
+ "lpc-wire",
+ "lpfs",
+ "tokio",
+]
+
[[package]]
name = "fw-tests"
version = "40.0.0"
@@ -2953,6 +3736,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a"
+[[package]]
+name = "generational-box"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cd0d825b8d339701ad330dbcd6399519ced4d143484954daf6e3185dace4f77"
+dependencies = [
+ "parking_lot",
+ "tracing",
+]
+
[[package]]
name = "generator"
version = "0.8.8"
@@ -2995,8 +3788,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -3006,9 +3801,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
+ "wasm-bindgen",
]
[[package]]
@@ -3059,6 +3856,52 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "gloo-net"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-sink",
+ "gloo-utils",
+ "http",
+ "js-sys",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
+dependencies = [
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "glow"
version = "0.14.2"
@@ -3239,6 +4082,30 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "headers"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "headers-core",
+ "http",
+ "httpdate",
+ "mime",
+ "sha1",
+]
+
+[[package]]
+name = "headers-core"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
+dependencies = [
+ "http",
+]
+
[[package]]
name = "heapless"
version = "0.8.0"
@@ -3293,18 +4160,106 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -3500,6 +4455,15 @@ dependencies = [
"rustversion",
]
+[[package]]
+name = "infer"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
+dependencies = [
+ "cfb",
+]
+
[[package]]
name = "inotify"
version = "0.9.6"
@@ -3533,6 +4497,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "inventory"
+version = "0.3.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "io-kit-sys"
version = "0.4.1"
@@ -3543,6 +4516,12 @@ dependencies = [
"mach2",
]
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
[[package]]
name = "is-terminal"
version = "0.4.17"
@@ -3710,6 +4689,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.11.0",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -3727,6 +4715,21 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+[[package]]
+name = "konst"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
+dependencies = [
+ "konst_macro_rules",
+]
+
+[[package]]
+name = "konst_macro_rules"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
+
[[package]]
name = "kqueue"
version = "1.1.1"
@@ -3747,6 +4750,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "lazy-js-bundle"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccafada6c9541db44db758619236f2748f6e1bdaa84d04ded858567cd1e89321"
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -3877,6 +4886,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "longest-increasing-subsequence"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86"
+
[[package]]
name = "loom"
version = "0.7.2"
@@ -3912,6 +4927,7 @@ dependencies = [
"lp-riscv-inst",
"lp-shader",
"lpa-client",
+ "lpa-link",
"lpa-server",
"lpc-engine",
"lpc-hardware",
@@ -3926,6 +4942,7 @@ dependencies = [
"lpvm-cranelift",
"lpvm-native",
"notify",
+ "pollster",
"rustc-demangle",
"serde",
"serde_json",
@@ -3934,7 +4951,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"toml",
- "tungstenite",
+ "tungstenite 0.21.0",
"unicode-width 0.2.2",
]
@@ -4070,6 +5087,24 @@ dependencies = [
"tokio-tungstenite",
]
+[[package]]
+name = "lpa-link"
+version = "40.0.0"
+dependencies = [
+ "fw-host",
+ "js-sys",
+ "lpa-client",
+ "lpc-model",
+ "serde",
+ "serde-wasm-bindgen",
+ "serialport",
+ "strum",
+ "tokio",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "lpa-server"
version = "40.0.0"
@@ -4084,7 +5119,31 @@ dependencies = [
"lpc-shared",
"lpc-view",
"lpc-wire",
- "lpfs",
+ "lpfs",
+]
+
+[[package]]
+name = "lpa-studio-ux"
+version = "40.0.0"
+dependencies = [
+ "async-trait",
+ "js-sys",
+ "lpa-client",
+ "lpa-link",
+ "lpc-model",
+ "lpc-wire",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "lpa-studio-web"
+version = "40.0.0"
+dependencies = [
+ "dioxus",
+ "lpa-studio-ux",
+ "web-sys",
]
[[package]]
@@ -4483,6 +5542,12 @@ dependencies = [
"wasmtime",
]
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "mach2"
version = "0.4.3"
@@ -4492,6 +5557,17 @@ dependencies = [
"libc",
]
+[[package]]
+name = "macro-string"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -4501,6 +5577,50 @@ dependencies = [
"libc",
]
+[[package]]
+name = "manganis"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bfcf56309de35b48b8780ea097ace5c3b773a617b52edc49dfc9a63a7d9dc43"
+dependencies = [
+ "const-serialize 0.7.2",
+ "const-serialize 0.8.0-alpha.0",
+ "jni 0.21.1",
+ "manganis-core",
+ "manganis-macro",
+ "ndk-context",
+ "objc2 0.6.4",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "manganis-core"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a24d6be68f594495aea60850a284029d585d7b7839b26096c1b6d758f8518648"
+dependencies = [
+ "const-serialize 0.7.2",
+ "const-serialize 0.8.0-alpha.0",
+ "dioxus-cli-config",
+ "dioxus-core-types",
+ "serde",
+ "winnow",
+]
+
+[[package]]
+name = "manganis-macro"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e782a10318d707c0833e31876ded8acf91287eee0010af8392559af614c7226"
+dependencies = [
+ "dunce",
+ "macro-string",
+ "manganis-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -4510,6 +5630,12 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.0"
@@ -4558,6 +5684,32 @@ dependencies = [
"paste",
]
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "minicov"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
+dependencies = [
+ "cc",
+ "walkdir",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -4607,6 +5759,23 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
[[package]]
name = "naga"
version = "23.1.0"
@@ -4811,6 +5980,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -5170,6 +6345,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
[[package]]
name = "openssl"
version = "0.10.76"
@@ -5433,6 +6614,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
[[package]]
name = "pp-rs"
version = "0.2.2"
@@ -5478,12 +6665,40 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "version_check",
+]
+
[[package]]
name = "profiling"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+[[package]]
+name = "psl-types"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
+
+[[package]]
+name = "publicsuffix"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
+dependencies = [
+ "idna",
+ "psl-types",
+]
+
[[package]]
name = "pulley-interpreter"
version = "42.0.1"
@@ -5532,6 +6747,61 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases 0.2.1",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash 2.1.1",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash 2.1.1",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases 0.2.1",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -5560,10 +6830,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
- "rand_chacha",
+ "rand_chacha 0.3.1",
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -5574,6 +6854,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -5588,6 +6878,9 @@ name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
[[package]]
name = "rand_core"
@@ -5769,6 +7062,50 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "cookie",
+ "cookie_store",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "webpki-roots",
+]
+
[[package]]
name = "rgb"
version = "0.8.53"
@@ -5778,6 +7115,20 @@ dependencies = [
"bytemuck",
]
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "riscv"
version = "0.15.0"
@@ -5933,6 +7284,41 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -6076,6 +7462,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+dependencies = [
+ "futures-core",
+]
+
[[package]]
name = "ser-write"
version = "0.3.1"
@@ -6106,6 +7501,17 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -6138,16 +7544,38 @@ dependencies = [
]
[[package]]
-name = "serde_json"
-version = "1.0.149"
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_qs"
+version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
dependencies = [
- "itoa",
- "memchr",
+ "percent-encoding",
"serde",
- "serde_core",
- "zmij",
+ "thiserror 2.0.18",
]
[[package]]
@@ -6170,6 +7598,18 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -6319,12 +7759,42 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+[[package]]
+name = "sledgehammer_bindgen"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133"
+dependencies = [
+ "sledgehammer_bindgen_macro",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "sledgehammer_bindgen_macro"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "sledgehammer_utils"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae"
+dependencies = [
+ "rustc-hash 1.1.0",
+]
+
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
+ "serde",
"version_check",
]
@@ -6521,6 +7991,40 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "subsecond"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cc79674bd55726e6b123204403389400229a95fe4a3b2c5453dada70b06ca95"
+dependencies = [
+ "js-sys",
+ "libc",
+ "libloading",
+ "memfd",
+ "memmap2",
+ "serde",
+ "subsecond-types",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "subsecond-types"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9798bfed58797aed51c672aa99810aac30a50d3120ecfdcf28c13784e9a8f1c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "svgbobdoc"
version = "0.3.0"
@@ -6556,6 +8060,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -6666,6 +8179,36 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "time"
+version = "0.3.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -6744,6 +8287,16 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
@@ -6753,7 +8306,22 @@ dependencies = [
"futures-util",
"log",
"tokio",
- "tungstenite",
+ "tungstenite 0.21.0",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
]
[[package]]
@@ -6816,6 +8384,51 @@ version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.11.0",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -6878,6 +8491,23 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "tracing-wasm"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07"
+dependencies = [
+ "tracing",
+ "tracing-subscriber",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -6897,13 +8527,47 @@ dependencies = [
"httparse",
"log",
"native-tls",
- "rand",
+ "rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
+[[package]]
+name = "tungstenite"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.4",
+ "sha1",
+ "thiserror 2.0.18",
+ "utf-8",
+]
+
+[[package]]
+name = "tungstenite"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.4",
+ "sha1",
+ "thiserror 2.0.18",
+ "utf-8",
+]
+
[[package]]
name = "twox-hash"
version = "2.1.2"
@@ -6951,6 +8615,12 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -6987,6 +8657,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
[[package]]
name = "unwinding"
version = "0.2.8"
@@ -7076,6 +8752,37 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "warnings"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad"
+dependencies = [
+ "pin-project",
+ "tracing",
+ "warnings-macro",
+]
+
+[[package]]
+name = "warnings-macro"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -7159,6 +8866,45 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-bindgen-test"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe"
+dependencies = [
+ "async-trait",
+ "cast",
+ "js-sys",
+ "libm",
+ "minicov",
+ "nu-ansi-term",
+ "num-traits",
+ "oorandom",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test-macro",
+ "wasm-bindgen-test-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-test-macro"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "wasm-bindgen-test-shared"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e"
+
[[package]]
name = "wasm-compose"
version = "0.244.0"
@@ -7212,6 +8958,19 @@ dependencies = [
"wasmparser 0.244.0",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -7736,6 +9495,15 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "webpki-roots"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "wgpu"
version = "23.0.1"
@@ -8565,6 +10333,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "xxhash-rust"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
+
[[package]]
name = "yoke"
version = "0.8.1"
@@ -8612,7 +10386,7 @@ dependencies = [
"hex",
"nix 0.29.0",
"ordered-stream",
- "rand",
+ "rand 0.8.5",
"serde",
"serde_repr",
"sha1",
diff --git a/Cargo.toml b/Cargo.toml
index 0320754ed..b61df2b65 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,7 +11,12 @@ members = [
"lp-core/lpc-shared",
"lp-app/lpa-server",
"lp-app/lpa-client",
+ "lp-app/lpa-link",
+ "lp-app/lpa-studio-ux",
+ "lp-app/lpa-studio-web",
+ "lp-fw/fw-browser",
"lp-fw/fw-core",
+ "lp-fw/fw-host",
"lp-fw/fw-checks",
"lp-cli",
"lp-fw/fw-esp32",
@@ -68,7 +73,10 @@ default-members = [
"lp-core/lpc-shared",
"lp-app/lpa-server",
"lp-app/lpa-client",
+ "lp-app/lpa-link",
+ "lp-app/lpa-studio-ux",
"lp-fw/fw-core",
+ "lp-fw/fw-host",
"lp-fw/fw-checks",
"lp-fw/fw-tests",
"lp-cli",
diff --git a/README.md b/README.md
index a314ccaaa..cfd521b15 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ To get started with development:
```
This will:
- - Check for required tools (Rust, Cargo, rustup, just)
+ - Check for required tools (Rust, Cargo, rustup, just, oxipng)
- Verify Rust version meets minimum requirements (1.90.0+)
- Install the RISC-V target (`riscv32imac-unknown-none-elf`) if needed
- Set up git hooks (pre-commit hook runs `just check`)
@@ -61,6 +61,8 @@ To get started with development:
2. **Required tools:**
- Rust toolchain (1.90.0 or later) - [Install Rust](https://rustup.rs/)
- `just` - Task runner: `cargo install just` or via package manager
+ - `oxipng` - Lossless PNG optimizer for Studio story image baselines:
+ `cargo install oxipng` or `brew install oxipng`
3. **Common development commands:**
- `just fci` - Fix, check, build, and test the whole project. Do this before you submit a PR.
diff --git a/agent-context.toml b/agent-context.toml
new file mode 100644
index 000000000..3aebea282
--- /dev/null
+++ b/agent-context.toml
@@ -0,0 +1,5 @@
+[agent]
+repo_slug = "lightplayer"
+planning_root_env = "PHOTOMANCER_PLANNING_ROOT"
+skills_root_env = "PHOTOMANCER_SKILLS_ROOT"
+default_skills_subdir = "skills"
diff --git a/docs/adr/2026-06-17-browser-firmware-runtime.md b/docs/adr/2026-06-17-browser-firmware-runtime.md
new file mode 100644
index 000000000..6a3813f0e
--- /dev/null
+++ b/docs/adr/2026-06-17-browser-firmware-runtime.md
@@ -0,0 +1,90 @@
+# ADR: Browser Firmware Runtime Boundary
+
+- **Status:** Accepted
+- **Date:** 2026-06-17
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** None
+
+## Context
+
+The first Studio milestone needs a browser-local LightPlayer runtime that feels
+like firmware, not a shader playground. The previous `fw-browser` proof exposed
+direct shader/LPVM primitives, which was useful for proving browser shader
+execution but too far from the real Studio/device shape.
+
+Studio needs to create browser-local runtimes, send normal LightPlayer protocol
+messages, observe logs/status, load projects, tick deterministically in tests,
+and inspect output through the same project-read resource path used by other
+firmware targets.
+
+## Decision
+
+`fw-browser` is a browser/Web Worker firmware target. It owns an in-memory
+`LpServer`, filesystem, virtual hardware, output provider, manual time source,
+and server tick loop. JavaScript creates a module Worker and talks to it through
+structured `postMessage` envelopes.
+
+Input envelopes include `protocol_in`, `tick`, `start`, `stop`, and `drain`.
+`protocol_in` carries a whole `lpc_wire` client JSON frame. Output envelopes
+include `status`, `log`, and `protocol_out`. `protocol_out` carries a whole
+`lpc_wire` server JSON frame. Logs/status stay separate from protocol frames so
+Studio can show connection health and raw protocol independently.
+
+`fw-core` owns only target-neutral runtime helpers: draining client messages and
+ticking an `LpServer` frame. Browser Worker lifecycle, host process lifecycle,
+and ESP32 scheduling remain target-specific.
+
+`lpa-link browser-worker` models endpoint/session identity and reports a
+`BrowserWorker` connection with protocol `fw-browser-post-message-v1`.
+The web frontend still owns the actual JavaScript `Worker` object and binds that
+worker to Studio/client code.
+
+Output smoke coverage uses canonical project-read `OutputChannels` payloads,
+not direct access to `MemoryOutputProvider` and not a bespoke output snapshot.
+
+## Consequences
+
+M1 Studio can depend on a firmware-shaped browser runtime: create a worker,
+write project files via protocol messages, load a project, tick, read resources,
+and surface logs/status.
+
+Browser and host runtimes remain distinct. `fw-browser` is for Studio
+simulation and browser-local project testing; `fw-host` is for host-OS local
+runtime deployments.
+
+The current automated Rust wasm check can compile the browser runtime tests, but
+executing `wasm-bindgen-test` requires working browser/WebDriver provisioning.
+The static browser smoke page is therefore part of the validation ladder until
+CI browser tooling is provisioned.
+
+## Alternatives Considered
+
+- Keep direct shader/LPVM exports as the browser API.
+ - Rejected because it bypasses `LpServer`, filesystem, project loading, logs,
+ and the protocol boundary Studio must use.
+- Emulate serial `M!` framing inside the Worker boundary.
+ - Rejected for M0a because structured worker messages are simpler and keep
+ logs/status/protocol clearly separated. Serial-like framing remains useful
+ for serial transports.
+- Make `fw-core` own a full runtime factory.
+ - Rejected because target-specific lifecycle, logging, scheduling, and
+ hardware setup would make `fw-core` too broad too early.
+- Verify output through a bespoke worker `outputSnapshot`.
+ - Rejected for the primary smoke because canonical project-read resources are
+ the surface Studio and agents should be able to trust.
+
+## Follow-ups
+
+- Provision stable CI/browser tooling for `wasm-bindgen-test` or a Playwright
+ Worker smoke.
+- Add a browser-side `lpa-client` transport/binding for
+ `fw-browser-post-message-v1`.
+- Add richer diagnostics and optional output snapshots for Studio device panels
+ after the canonical protocol path remains stable.
+
+## Later Note
+
+As of the provider-owned resources refactor and the `lpa-studio-ux` experiment,
+`lpa-link browser-worker` owns the JavaScript Worker lifecycle. Browser UI code
+should consume the Studio UX surface rather than owning the Worker directly.
diff --git a/docs/adr/2026-06-17-studio-link-and-local-runtimes.md b/docs/adr/2026-06-17-studio-link-and-local-runtimes.md
new file mode 100644
index 000000000..02b888384
--- /dev/null
+++ b/docs/adr/2026-06-17-studio-link-and-local-runtimes.md
@@ -0,0 +1,85 @@
+# ADR: Studio Link And Local Runtimes
+
+- **Status:** Accepted
+- **Date:** 2026-06-17
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** None
+
+## Context
+
+LightPlayer Studio needs to discover and manage local and physical LightPlayer
+endpoints without putting low-level device and runtime concerns directly in the
+UI. The first Studio milestone also needs a browser-local runtime so the web app
+can prove the end-to-end flow before real hardware flashing is available.
+
+The low-level layer is broader than a byte transport. Web Serial, browser-local,
+host-local, and later websocket/server-owned links may all need discovery,
+status, reset, flash, raw filesystem access, diagnostics, logs, and eventually a
+client connection to an `lp-server`.
+
+Browser-local and host-local runtimes also have different purposes. A browser
+runtime is for Studio project testing and simulation in a Web Worker-shaped
+environment. A host runtime is for running LightPlayer on a host OS, server, or
+single-board computer.
+
+## Decision
+
+Add `lpa-link` as the app-side low-level link layer below Studio capabilities
+and beside `lpa-client`.
+
+`lpa-link` owns provider discovery, endpoint identity/status, low-level
+management surfaces, raw logs/diagnostics, and opening a server/client
+connection. `lpa-client` remains the typed client/RPC layer once a connection
+exists. Studio owns higher-level capabilities, user/agent actions, client
+sessions, project sessions, undo, and product workflows above the link layer.
+
+Use separate runtime targets:
+
+- `fw-browser` for browser/Web Worker Studio simulation and project testing.
+- `fw-host` for host-OS local runtime use cases.
+
+Local runtime support is plural-first from the start. The type model must allow
+multiple browser or host runtime instances so future multi-node and radio-style
+LightPlayer systems are not forced through singleton assumptions.
+
+## Consequences
+
+Studio can be driven by both UI code and future agent harnesses through the same
+domain surfaces, while `lpa-link` keeps low-level endpoint concerns out of the
+UI.
+
+`lpa-link` can grow Web Serial and hardware-management functions without
+confusing those functions with typed project/client RPCs.
+
+`fw-browser` and `fw-host` can evolve independently where browser and host
+runtime constraints differ. This avoids pretending that browser shader execution,
+host process lifecycle, and embedded firmware are the same product surface.
+
+Browser runtime validation needs an explicit ladder: wasm target check,
+wasm-bindgen package build, a Rust-native `wasm-bindgen-test`, and browser smoke
+coverage. CI-enforced headless browser execution remains dependent on browser
+and WebDriver provisioning.
+
+## Alternatives Considered
+
+- Put all local behavior directly in Studio UI code.
+ - Rejected because it would entangle UI workflows with endpoint discovery,
+ hardware management, logs, and connection lifecycle.
+- Treat the new layer as only a transport crate.
+ - Rejected because real links own more than `connect()`: discovery, status,
+ management, diagnostics, flashing, reset, and raw filesystem access belong
+ below Studio capabilities.
+- Use one generic local firmware/runtime for browser and host.
+ - Rejected because browser and host runtimes have different purposes,
+ compilation constraints, output surfaces, and lifecycle models.
+- Start singleton-shaped and generalize later.
+ - Rejected because multi-instance runtime support is foundational for future
+ multi-node LightPlayer systems.
+
+## Follow-ups
+
+- Provision CI/browser tooling for `fw-browser` `wasm-bindgen-test` execution.
+- Add Web Serial and real hardware flashing as a later link provider.
+- Keep embedded runtime shader compilation intact; browser and host runtimes are
+ Studio/local surfaces, not replacements for on-device GLSL JIT compilation.
diff --git a/docs/adr/2026-06-18-browser-serial-shim.md b/docs/adr/2026-06-18-browser-serial-shim.md
new file mode 100644
index 000000000..3db9127c9
--- /dev/null
+++ b/docs/adr/2026-06-18-browser-serial-shim.md
@@ -0,0 +1,57 @@
+# ADR 2026-06-18: Browser Serial Shim
+
+## Status
+
+Superseded by [2026-06-21 LPA Link Provider-Owned Resources](./2026-06-21-lpa-link-provider-owned-resources.md)
+and [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md).
+
+## Context
+
+LightPlayer Studio needs a static web path that can connect to already-flashed
+ESP32 hardware over Web Serial. The rest of the Studio stack should stay in
+Rust: `lp-studio-core` owns actions/state, `lp-studio-runtime` owns protocol
+flow, and `lpa-link` models device/link/session concepts.
+
+The browser Web Serial API is available to JavaScript, but the `web-sys`
+bindings for `Serial` and `SerialPort` are currently gated behind
+`web_sys_unstable_apis`. Requiring that cfg for normal Studio wasm builds would
+make the build/deploy path more fragile and would leak a browser-platform detail
+into unrelated Rust validation.
+
+## Decision
+
+Use a tiny JavaScript shim for direct Web Serial stream ownership.
+
+- `lp-app/lp-studio-web/public/browser-serial.js` owns
+ `navigator.serial.requestPort()`, `SerialPort.open()`, stream readers,
+ stream writers, line buffering, and close/cancel behavior.
+- The shim installs a narrow global function surface before the Rust wasm module
+ starts.
+- `lp-studio-runtime` calls that function surface through
+ `browser_serial_shim.rs`.
+- Rust still owns Studio actions/effects/events, endpoint/session modeling,
+ `M!` protocol framing, JSON request/response parsing, server-event handling,
+ diagnostics, and demo project upload semantics.
+- `lpa-link` models `browser-serial-esp32` as a provider/session/connection
+ kind, but it does not own browser stream objects.
+
+## Consequences
+
+Studio can build with ordinary wasm settings while still using Web Serial in
+supported browsers.
+
+The boundary is intentionally narrow and replaceable. If stable `web-sys`
+bindings become practical later, the shim can be collapsed into Rust without
+changing the Studio action model or the `browser-serial-esp32` provider
+vocabulary.
+
+The cost is one small JavaScript file in the static web shell. Browser stream
+edge cases such as reader cancellation, disconnects, and permission errors must
+be handled and tested at that boundary.
+
+## Update 2026-06-22
+
+Browser serial ownership moved below Studio into `lpa-link`'s
+`browser-serial-esp32` provider. `lpa-studio-ux` now adapts the connected link
+session into `lpa-client`; `lpa-studio-web` does not own Web Serial streams or a
+runtime shim.
diff --git a/docs/adr/2026-06-18-link-provider-id-convention.md b/docs/adr/2026-06-18-link-provider-id-convention.md
new file mode 100644
index 000000000..6348b6deb
--- /dev/null
+++ b/docs/adr/2026-06-18-link-provider-id-convention.md
@@ -0,0 +1,76 @@
+# ADR: Link Provider ID Convention
+
+- **Status:** Accepted
+- **Date:** 2026-06-18
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** None
+
+## Context
+
+`lpa-link` provider IDs are becoming durable application vocabulary. Studio, the
+CLI, tests, and future agent harnesses will use these IDs to describe how an app
+discovers, manages, and connects to LightPlayer runtimes or devices.
+
+The initial provider names used `local-host` and `local-browser`, and early
+serial planning used names such as `serial-esp32-host`. That ordering did not
+scale cleanly once the same mechanism needed different host and browser
+capabilities, such as host serial versus browser Web Serial, or host websocket
+discovery versus browser websocket constraints.
+
+## Decision
+
+Use kebab-case provider IDs with this grammar:
+
+```text
+{environment}-{mechanism}-{target?}
+```
+
+The environment identifies where the provider runs, such as `host` or
+`browser`. The mechanism identifies how the provider reaches or owns the runtime
+or device, such as `process`, `worker`, `serial`, or `websocket`. The target is
+optional and is included when management behavior is target-specific.
+
+Canonical examples:
+
+- `host-process`
+- `browser-worker`
+- `host-serial-esp32`
+- `browser-serial-esp32`
+- `host-websocket`
+- `browser-websocket`
+
+Use Rust module/type names that match the provider ID in Rust style, such as
+`providers::host_serial_esp32::HostSerialEsp32Provider`.
+
+## Consequences
+
+Provider IDs now expose capability differences before the caller inspects the
+provider. For example, `host-websocket` and `browser-websocket` can differ in
+discovery, permissions, and network constraints without overloading a generic
+`websocket` provider name.
+
+ESP32 serial providers remain target-specific because flashing, reset,
+boot-mode handling, and raw filesystem access are device-family behavior, not
+generic serial behavior.
+
+The early `local-host` and `local-browser` names are renamed to `host-process`
+and `browser-worker` instead of being carried forward as permanent aliases.
+
+## Alternatives Considered
+
+- `{mechanism}-{target}-{environment}`, such as `serial-esp32-host`.
+ - Rejected because it groups host and browser variants apart even though the
+ environment determines discovery, permissions, and management capabilities.
+- Generic provider IDs such as `websocket` or `serial`.
+ - Rejected for providers whose behavior differs materially by environment or
+ target family.
+- Keep `local-host` and `local-browser`.
+ - Rejected because "local" hides the containment model that matters to the
+ link layer: host process versus browser worker.
+
+## Follow-ups
+
+- Add `browser-serial-esp32` when Web Serial hardware support is implemented.
+- Add `host-websocket` and `browser-websocket` separately when websocket
+ discovery and connection behavior is ready.
diff --git a/docs/adr/2026-06-18-portable-lpa-client.md b/docs/adr/2026-06-18-portable-lpa-client.md
new file mode 100644
index 000000000..02ec0f07d
--- /dev/null
+++ b/docs/adr/2026-06-18-portable-lpa-client.md
@@ -0,0 +1,96 @@
+# ADR: Portable lpa-client And Link Server Connections
+
+- **Status:** Accepted
+- **Date:** 2026-06-18
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** None
+
+## Context
+
+LightPlayer now has multiple app-side ways to reach a running `lp-server`:
+host-process, host serial ESP32, browser worker, browser serial ESP32, and
+future websocket/server-owned variants. The CLI grew first and its `LpClient`
+was shaped around Tokio, shared mutexes, request timeouts, host transports, and
+CLI heartbeat rendering.
+
+Studio web is now the primary app direction. It needs the same server protocol
+semantics in browser runtimes and future agent harnesses without forcing Tokio,
+`Send`, or host transport types into the core client model. At the same time,
+the CLI and native host paths should keep their current ergonomics.
+
+`lpa-link` also needs a crisp boundary. It owns endpoint discovery, status,
+logs, diagnostics, reset, flashing, raw filesystem access, and connection
+lifecycle. It should not grow typed project operations. Once a link reaches a
+running server, `lpa-client` should own the server protocol.
+
+## Decision
+
+Make `lpa-client::LpClient` the portable, runtime-neutral client for the
+LightPlayer server protocol.
+
+- `ClientIo` is the small portable send/receive/close contract over
+ `lpc-wire` messages. It does not require Tokio or `Send`.
+- `LpClient` owns request id allocation, response correlation, server error
+ handling, typed filesystem/project/overlay operations, and protocol events.
+- Heartbeats, server logs, and uncorrelated responses are returned as
+ `ClientEvent`s instead of being printed or mapped to UI state in the core.
+- Project deploy ordering lives in `lpa-client` helpers so Studio, CLI, and
+ agents can share stop/write/load semantics.
+- Host/native behavior lives in `TokioLpClient` and host transport modules:
+ shared Tokio mutexes, timeouts, current CLI heartbeat display, local
+ transports, serial transports, and websocket transports.
+- Host support remains enabled by default for existing native callers, while
+ browser/core consumers can compile with `default-features = false`.
+- `lpa-link` exposes server connections from connected sessions. Host links
+ return a `LinkServerConnection` that can become a `TokioLpClient`; browser
+ links model endpoint/session/protocol identity and should adapt their browser
+ streams into `ClientIo`.
+- The dependency direction is `lpa-link -> lpa-client` only for connection
+ types/adapters. `lpa-client` must not depend on `lpa-link`.
+
+## Consequences
+
+Studio browser work can reuse server protocol semantics without copying request
+id, response correlation, heartbeat/log, server error, or project deploy logic.
+
+The CLI keeps a cloneable Tokio client wrapper and current command behavior,
+but that wrapper is now a host adapter rather than the definition of the client
+model.
+
+`lpa-link` stays a low-level device/runtime link layer. It can grow ESP32
+flashing, reset, raw filesystem, and diagnostics without confusing those
+management operations with typed project/client protocol operations.
+
+Browser worker and Web Serial Studio paths now adapt connected `lpa-link`
+sessions into `lpa-client::ClientIo` from `lpa-studio-ux`, so Studio does not
+need duplicated browser-local protocol clients.
+
+The feature model is slightly more explicit. Host adapters and host transports
+sit behind `host`; the portable core can be checked for wasm with
+`cargo check -p lpa-client --target wasm32-unknown-unknown --no-default-features`.
+
+## Alternatives Considered
+
+- Keep the Tokio `LpClient` as the core model.
+ - Rejected because it would make Studio web and agent harnesses inherit host
+ runtime choices.
+- Split out a new `lpa-client-core` crate immediately.
+ - Rejected because the dependency cycle did not require it; feature-gated
+ modules keep the boundary clear with less crate churn.
+- Keep browser serial and worker protocol logic inside `lp-studio-runtime`.
+ - Rejected as the final architecture because it would duplicate foundational
+ protocol semantics. Accepted only as temporary M2 bring-up code until M2c
+ cuts browser streams over to `ClientIo`.
+- Put typed project operations in `lpa-link`.
+ - Rejected because links should own discovery/management/connection, while
+ `lpa-client` owns server protocol semantics once connected.
+
+## Follow-ups
+
+- Continue hardening the browser `ClientIo` adapters for disconnects,
+ resynchronization, logs, and heartbeat edge cases.
+- Consider moving host serial framing/resynchronization helpers below
+ `ClientTransport` if host and browser serial hardening converge.
+- Keep ESP32 multi-project behavior conservative by using stop/write/load
+ deploy helpers until firmware intentionally supports multiple live projects.
diff --git a/docs/adr/2026-06-18-studio-action-session-architecture.md b/docs/adr/2026-06-18-studio-action-session-architecture.md
new file mode 100644
index 000000000..8169b01d5
--- /dev/null
+++ b/docs/adr/2026-06-18-studio-action-session-architecture.md
@@ -0,0 +1,81 @@
+# ADR: Studio Action And Session Architecture
+
+Date: 2026-06-18
+
+## Status
+
+Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+
+## Context
+
+LightPlayer Studio needs a real UI while preserving the product's core embedded
+GLSL JIT architecture. The first Studio milestone also needs to stay honest for
+future non-UI consumers: host harnesses, tests, and agents should be able to
+drive the same product surface as the web UI.
+
+The first implementation slice uses:
+
+- `browser-worker` for static web/GitHub Pages-style simulation.
+- `host-process` for host-side non-UI harness validation.
+- `lpa-link` below Studio for endpoint discovery, status, management, logs,
+ diagnostics, and opening server/client connections.
+
+At the same time, Studio actions need to look forward to generic UI help,
+agent-discoverable tools, and undo/redo, without implementing editing or undo in
+M1.
+
+## Decision
+
+Studio is split into three application-facing crates:
+
+- `lp-studio-core` owns state, documented actions, action metadata, effects,
+ events, diagnostics, capabilities, and session records.
+- `lp-studio-runtime` executes effects and translates link/client/runtime facts
+ back into Studio events.
+- `lp-studio-web` renders `StudioState` with Dioxus and dispatches
+ `StudioAction` values.
+
+The core loop is:
+
+```text
+StudioAction -> StudioState + StudioEffect -> StudioEvent -> StudioState
+```
+
+The core crate is synchronous and UI-free. It does not perform I/O, spawn
+runtimes, own browser workers, open serial ports, or render components.
+
+Runtime code owns I/O. It consumes `StudioEffect` values and emits
+`StudioEvent` values. The host path validates this with `host-process` and
+`fw-host`; the browser path validates it with the `fw-browser` Web Worker
+envelope.
+
+Actions are documented program objects. Each action has metadata and a
+descriptor surface for labels, summaries, categories, origin, correlation, and
+history policy. This gives UI controls and future agents a common way to explain
+and inspect available operations.
+
+M1 does not implement undo/redo. It only classifies history behavior. Most M1
+actions are operational, read-only, or navigation actions, so they are
+non-undoable or ephemeral. Future global undo should attach to successful domain
+edit transactions, not to every `StudioAction`.
+
+## Consequences
+
+- Dioxus is a thin consumer rather than the owner of Studio behavior.
+- A non-UI harness can validate the same action/effect/event shape as the web
+ UI.
+- The first deployed web path can use `browser-worker` without implying that
+ browser simulation replaces ESP32 runtime compilation.
+- `lpa-link` remains below Studio capabilities; Studio does not branch UI code
+ directly on serial/process/worker mechanics.
+- Future agents can discover documented actions and capabilities without
+ scraping UI components.
+- Undo remains deliberately deferred, but the action model will not need to be
+ reclassified later to separate operational actions from edit history.
+
+## Deferred
+
+- Real ESP32 hardware UX and Web Serial belong to M2.
+- Agent execution/harness depth belongs to M3.
+- Editing, overlay conflicts, commit/discard, undo/redo, and file/body edit
+ sessions belong to later authoring milestones.
diff --git a/docs/adr/2026-06-18-studio-native-storybook.md b/docs/adr/2026-06-18-studio-native-storybook.md
new file mode 100644
index 000000000..52edd407c
--- /dev/null
+++ b/docs/adr/2026-06-18-studio-native-storybook.md
@@ -0,0 +1,58 @@
+# ADR 2026-06-18: Studio-Native Component Storybook
+
+## Status
+
+Accepted.
+
+## Context
+
+LightPlayer Studio is a Rust-first Dioxus web application. We want the component
+development ergonomics of Storybook: isolated examples, meaningful states near
+the component source, direct links, and fast visual review. At the same time, we
+do not want to introduce a JavaScript Storybook toolchain before the Studio UI
+surface is large enough to justify it.
+
+The current Studio components render product-shaped Studio state and simple UI
+props. That makes them well suited to local fixture-driven stories.
+
+## Decision
+
+Studio component stories will be native Dioxus code in `lp-studio-web`.
+
+- Story files live next to components as sibling `*_stories.rs` modules.
+- A small explicit Rust registry collects story descriptors and render functions.
+- Stories render the real Studio components against fake, domain-shaped
+ `StudioView` / `UxPaneView` fixtures.
+- `just studio-dev` builds the web app with the `stories` feature so the local
+ storybook is available at `/#/stories`.
+- Production/static `studio-web-build` does not enable the storybook feature.
+- `just studio-story-pngs` generates local PNGs into gitignored
+ `lp-app/lpa-studio-web/story-images/.scratch/`.
+
+## Consequences
+
+The component workflow stays close to the Rust code and avoids a second UI
+runtime. Stories can evolve with the same types the production app uses, which
+keeps UI fixtures honest as the Studio domain model grows.
+
+The tradeoff is that we do not get Storybook's ecosystem features for free:
+add-ons, controls, automatic discovery, and hosted visual-regression workflows
+would need to be built or adopted later.
+
+PNG generation is intentionally local-only for now. Before PNGs become committed
+baselines or CI gates, we need a stable rendering environment, a curated story
+set, and rules for volatile content such as logs, animation, timestamps, and
+browser/font differences.
+
+## Update 2026-06-18
+
+The PNG baseline policy was amended by
+[ADR 2026-06-18: Studio Story PNG Baselines](./2026-06-18-studio-story-png-baselines.md).
+The native Dioxus storybook decision remains accepted.
+
+## Update 2026-06-22
+
+Studio stories now render `StudioView` and `UxPaneView` fixtures from
+`lpa-studio-ux`. The storybook remains native Dioxus code, but it should follow
+the active view/action model rather than the deleted `lpa-studio-core`
+`StudioState` model.
diff --git a/docs/adr/2026-06-18-studio-provisioning-core-model.md b/docs/adr/2026-06-18-studio-provisioning-core-model.md
new file mode 100644
index 000000000..3e1cc5c8e
--- /dev/null
+++ b/docs/adr/2026-06-18-studio-provisioning-core-model.md
@@ -0,0 +1,100 @@
+# ADR: Studio Provisioning Core Model
+
+- **Status:** Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+- **Date:** 2026-06-18
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+
+## Context
+
+The first Studio UI proved that LightPlayer can run in the browser, connect to
+the browser worker simulator, connect to ESP32 hardware over Web Serial, upload
+a demo project, and show basic runtime state. That UI was intentionally
+demo-shaped: a pair of buttons drove scripted helper flows and the app inferred
+device state from low-level session fields.
+
+The real Studio needs a product provisioning flow. A user should be able to
+open Studio, choose a simulator or hardware provider, handle browser/runtime
+availability, grant permission, open a link, probe the target, recover or flash
+when needed, deploy/load a project, and arrive at a live project session. The
+same flow also needs to be understandable by future agents and deterministic
+fake runtimes.
+
+`lpa-link` already owns lower-level provider, endpoint, session, connection, and
+management vocabulary. It should not grow product UX concepts such as "show a
+compatible browser help action" or "this provisioning issue can be retried."
+
+## Decision
+
+Add a first-class provisioning/device-manager model to `lp-studio-core`.
+
+- `DeviceManagerState` is part of `StudioState`.
+- `ProviderCatalog` owns provider cards, selected provider, provider
+ availability, and discovered/granted endpoints.
+- `DeviceFlowState` models the user journey through choosing a provider,
+ requesting access, opening a link, probing, provisioning, flashing, deploying
+ a project, readiness, degradation, and disconnection.
+- `DeviceIssue` and `RecoveryAction` provide structured failures and suggested
+ next steps for UI and future agents.
+- `ProgressState`, `ProvisioningReason`, and `TargetProbeResult` model
+ long-running operations and target classification even before real flashing
+ is implemented.
+- Existing live records such as `DeviceSession`, `ConnectionSession`,
+ `ClientSession`, and `ProjectSession` remain canonical for the connected
+ runtime and loaded project.
+- `LinkSelection` is removed as canonical Studio state. Provider selection and
+ endpoints now live in `ProviderCatalog`.
+- Actions, effects, and events gain provisioning vocabulary for provider catalog
+ refresh, starting/canceling/retrying provisioning, target probing, progress,
+ and typed issues.
+
+## Consequences
+
+The web UI can render the provisioning journey directly from core state instead
+of keeping hidden wizard state in Dioxus.
+
+Runtime implementations and future fake scenarios can emit incremental events
+for intermediate states rather than returning a fully scripted `StudioApp`.
+
+Future agents can inspect the same documented actions, issues, and recovery
+actions as the UI.
+
+`lpa-link` stays focused on low-level link/device/runtime concerns. Product
+availability and recovery guidance stay in Studio core.
+
+There is temporary scaffold churn in current demo helpers and stories because
+they must read provider selection/endpoints from `ProviderCatalog` instead of
+`LinkSelection`. That churn is accepted because the demo UI is not the product
+architecture.
+
+## Alternatives Considered
+
+- Keep deriving provisioning state from optional session fields.
+ - Rejected because states such as unsupported browser, permission canceled,
+ blank device, flash needed, or project deploy failed cannot be represented
+ cleanly by `Option` and `Option`.
+- Keep `LinkSelection` for compatibility with demo helpers.
+ - Rejected because the references were only scaffolding pressure. In active
+ development, preserving stale state would make the final model less clear.
+- Move product availability and recovery guidance into `lpa-link`.
+ - Rejected because `lpa-link` should remain below Studio product semantics.
+- Build the final Dioxus provisioning UI first.
+ - Rejected because the UI should render a tested core flow instead of
+ inventing local wizard state.
+
+## Follow-ups
+
+- M2 provisioning work should add deterministic scenario fakes and flow tests.
+- M3 should replace the demo UI with the real onboarding/device-manager UX and
+ journey stories.
+- Real browser/host ESP32 flashing and recovery should plug into the same flow
+ model instead of creating a separate provisioning path.
+
+## Update 2026-06-22
+
+The `lp-studio-core` provisioning model was removed with the old
+core/runtime split. Active Studio state now lives in `lpa-studio-ux` as
+resource-owning Link, Server, and Project UX nodes. Flashing, recovery, and
+provisioning concepts should be reintroduced there deliberately when the
+hardware UX is ported.
diff --git a/docs/adr/2026-06-18-studio-runtime-scenarios.md b/docs/adr/2026-06-18-studio-runtime-scenarios.md
new file mode 100644
index 000000000..ba3564e06
--- /dev/null
+++ b/docs/adr/2026-06-18-studio-runtime-scenarios.md
@@ -0,0 +1,90 @@
+# ADR: Studio Runtime Scenarios
+
+- **Status:** Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+- **Date:** 2026-06-18
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+
+## Context
+
+The Studio provisioning core model gives the UI, runtimes, and future agents a
+shared vocabulary for provider choice, permissions, link opening, target
+probing, flashing, project loading, readiness, degradation, and recovery.
+
+That model needs deterministic coverage before the real Dioxus provisioning UI
+is built. Hardware and browser APIs are useful integration targets, but they are
+too manual and environment-dependent to cover the full matrix of product states:
+unsupported browser, permission cancellation, blank device, endpoint-open
+failure, firmware incompatibility, flash failure, project deploy/load failure,
+and connection loss.
+
+`lpa-link` already has lower-level fake providers, but those fakes model link
+provider mechanics. They should not become the owner of Studio product outcomes
+or UI journey states.
+
+## Decision
+
+Add a public, I/O-free `lp-studio-runtime::scenario` module for deterministic
+Studio provisioning scenarios.
+
+- Scenario definitions live in `lp-studio-runtime`, not `lp-studio-core` and
+ not `lpa-link`.
+- `ProvisioningScenario` describes product-level runtime outcomes.
+- `ScenarioRuntime` implements `EffectExecutor` and maps scripted outcomes into
+ real `StudioEvent` values.
+- `ScenarioHarness` drives a real `StudioApp` through real
+ `StudioActionKind -> StudioEffect -> StudioEvent -> StudioApp::apply_event`
+ loops.
+- `ScenarioSnapshot` records lightweight journey checkpoints for tests and
+ future UI stories.
+- Scenario data has no browser, host-process, hardware, timing, or filesystem
+ dependency.
+
+## Consequences
+
+Provisioning tests can cover happy and failure paths without Web Serial,
+firmware, device availability, or manual interaction.
+
+Future Dioxus journey stories can reuse the same scenario vocabulary instead of
+inventing separate UI fixture states.
+
+The scenario layer pressures the real core reducer and event contract. When a
+user-visible path cannot be expressed, the core model must gain a narrow typed
+event or status instead of hiding the path behind a generic runtime error.
+
+`lpa-link` remains focused on provider/endpoint/session/management mechanics.
+Studio product outcomes such as permission canceled, flash failed, or project
+deploy failed stay above that layer.
+
+The public runtime test fixture surface is now part of Studio architecture. It
+should stay small, serializable where useful, and I/O-free.
+
+## Alternatives Considered
+
+- Keep scenario fakes as private test helpers.
+ - Rejected because M3 UI journey stories and future agent harnesses need the
+ same vocabulary.
+- Put the fakes in `lp-studio-core`.
+ - Rejected because scenarios execute effects and model runtime outcomes; core
+ should own state transitions, not fake effect execution.
+- Put the fakes in `lpa-link`.
+ - Rejected because the important states are Studio product outcomes rather
+ than link-layer mechanics.
+- Rely on browser and hardware integration tests only.
+ - Rejected because they cannot cheaply or deterministically cover permission,
+ flashing, project, and connection failure combinations.
+
+## Follow-ups
+
+- M3 should render provisioning journey stories from `ProvisioningScenario` or
+ `ScenarioSnapshot` rather than duplicating fixture vocabulary in the web app.
+- Real browser/host flashing work should emit the same typed events and issues
+ exercised by scenario tests.
+
+## Update 2026-06-22
+
+The `lpa-studio-runtime` crate was deleted when Studio moved to the
+resource-owning `lpa-studio-ux` model. Future deterministic Studio stories and
+tests should be built from `StudioView`, typed `UxAction` values, and focused
+UX-node fixtures rather than the deleted runtime scenario module.
diff --git a/docs/adr/2026-06-18-studio-story-png-baselines.md b/docs/adr/2026-06-18-studio-story-png-baselines.md
new file mode 100644
index 000000000..46dbc981d
--- /dev/null
+++ b/docs/adr/2026-06-18-studio-story-png-baselines.md
@@ -0,0 +1,53 @@
+# ADR 2026-06-18: Studio Story PNG Baselines
+
+## Status
+
+Accepted.
+
+## Context
+
+The native Studio storybook can generate PNGs for each component story. The
+initial decision kept those PNGs local-only to avoid repository bloat and
+browser-dependent screenshot churn.
+
+During early Studio UI work, the most valuable developer experience is being
+able to see which component stories changed in the same commit as the source
+change. LightPlayer is currently a small, solo-developed project, and the
+initial story PNG set is modest enough that the visibility is worth trying
+before investing in CI visual-regression infrastructure.
+
+## Decision
+
+Commit a curated baseline PNG set for `lp-studio-web` stories.
+
+- Committed baselines live under `lp-app/lp-studio-web/story-images/`.
+- Scratch review PNGs stay gitignored under
+ `lp-app/lp-studio-web/story-images/.scratch/`.
+- Fresh check output lives under gitignored
+ `lp-app/lp-studio-web/story-images/.new/`.
+- `just studio-story-baselines` regenerates the committed baseline set.
+- `just studio-story-check` compares fresh story PNGs to committed baselines
+ without updating them.
+- `just studio-story-baselines-if-needed` runs baseline generation only when
+ non-generated files under `lp-app/lp-studio-web/` changed since `HEAD`.
+- Story captures are clipped to the marked story canvas content rather than the
+ full browser viewport.
+- Baseline and check commands require `oxipng` so fresh captures are normalized
+ the same way as committed images.
+- Agents should run the helper before committing Studio UI work and include
+ changed baseline PNGs in the same commit.
+- Do not use an auto-mutating Git hook for now.
+
+## Consequences
+
+Studio UI commits can show source changes and visual story changes together,
+which makes review much easier while the UI foundation is still moving quickly.
+
+The tradeoff is that binary files will enter the repo and may churn when
+browser rendering, fonts, or story fixtures change. To keep that acceptable, the
+baseline set should stay curated, volatile content should be avoided in stories,
+and baseline updates should remain intentional.
+
+CI can later run `just studio-story-check`, but CI should not commit updated
+PNGs. If the image set grows substantially or churn becomes painful, revisit
+this decision before adding Git LFS or hard visual gates.
diff --git a/docs/adr/2026-06-18-studio-web-provisioning-controller.md b/docs/adr/2026-06-18-studio-web-provisioning-controller.md
new file mode 100644
index 000000000..4a104c613
--- /dev/null
+++ b/docs/adr/2026-06-18-studio-web-provisioning-controller.md
@@ -0,0 +1,87 @@
+# ADR: Studio Web Provisioning Controller
+
+- **Status:** Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+- **Date:** 2026-06-18
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+
+## Context
+
+The first Studio web UI proved the end-to-end browser and hardware paths, but
+it did so through demo helper functions that returned a fully completed
+`StudioApp`. That was useful for proof-of-concept work, but it hid the product
+journey from the UI: provider catalog, permission/access, endpoint discovery,
+link opening, server readiness, project-state inspection, project selection,
+recovery, deploying, and readiness.
+
+Studio now has a core provisioning model and deterministic scenario runtime.
+The web app needs to drive that model from day one without moving domain state
+into Dioxus-local wizard code. It also needs to keep simulator and Web Serial
+hardware paths shaped the same way, because future host/web providers should
+plug into the same user journey.
+
+## Decision
+
+Use a thin browser-side provisioning controller in `lp-studio-web`.
+
+- The controller dispatches real `StudioActionKind` values into `StudioApp`.
+- It drains returned `StudioEffect` values through the active browser runtime.
+- It applies returned `StudioEvent` values back into `StudioApp`.
+- It owns browser-provider routing, including a merged provider catalog for
+ browser-worker simulator and browser-serial ESP32.
+- It auto-advances only obvious steps, such as endpoint granted -> connect and
+ server ready -> read project state.
+- It does not own Studio domain transitions, project semantics, provider
+ availability, or recovery decisions.
+- Browser-worker is a real `EffectExecutor`, like browser serial and host
+ process, rather than a bespoke helper path.
+- After server readiness, the default path is `ReadProjectState`: attach to one
+ loaded project, ask for user/project intent when zero or many are loaded, and
+ render recovery when recovery is reported. Loading the starter project is an
+ explicit user action.
+
+## Consequences
+
+The main web app renders the provisioning journey from shared Studio state
+instead of swapping in a finished demo app.
+
+Simulator and browser-serial hardware now pressure the same
+action/effect/event/reducer contract. Future browser flashing, host serial, and
+remote providers can reuse the same controller shape with provider-specific
+capabilities.
+
+The controller is intentionally thin and browser-specific. It is not a new
+domain layer, and it should not grow product policy that belongs in
+`lp-studio-core` or protocol/runtime behavior that belongs in
+`lp-studio-runtime`.
+
+Story fixtures and PNG baselines can show the user journey state by state. The
+visual story set becomes a lightweight review surface for this foundational UX.
+
+## Alternatives Considered
+
+- Keep using demo helpers that return completed `StudioApp` values.
+ - Rejected because the real onboarding/provisioning journey would remain
+ invisible to the UI and hard to review.
+- Put the provisioning controller in `lp-studio-core`.
+ - Rejected because core must stay UI- and runtime-independent.
+- Put the controller in `lp-studio-runtime`.
+ - Rejected because provider routing and Dioxus signal/update concerns are web
+ presentation concerns; runtime adapters should execute effects.
+- Upload/load the starter project automatically after connecting.
+ - Rejected because real hardware usually already has a project loaded, and
+ overwriting/loading should be explicit user intent.
+
+## Follow-ups
+
+- Wire real browser-side ESP32 flashing into the same provider/controller flow.
+- Add real recovery/safe-mode protocol once firmware/server support exists.
+- Consider extracting shared controller test helpers if desktop Studio needs
+ the same auto-advance policy outside Dioxus.
+
+## Update 2026-06-22
+
+The active Studio web app no longer owns a separate provisioning controller.
+`lpa-studio-ux` owns Link, Server, and Project controller logic directly and
+`lpa-studio-web` renders `StudioView` panes plus `UxAction` controls.
diff --git a/docs/adr/2026-06-21-lpa-link-provider-owned-resources.md b/docs/adr/2026-06-21-lpa-link-provider-owned-resources.md
new file mode 100644
index 000000000..5d2488c82
--- /dev/null
+++ b/docs/adr/2026-06-21-lpa-link-provider-owned-resources.md
@@ -0,0 +1,50 @@
+# ADR 2026-06-21: LPA Link Provider-Owned Resources
+
+## Status
+
+Accepted
+
+## Context
+
+`lpa-link` had a split ownership model. It exposed shared endpoint records, but
+each provider returned a provider-specific session type, while browser serial
+and browser worker resource ownership mostly lived in the old Studio runtime
+crate and `lpa-studio-web` glue.
+
+That made the boundary hard to reason about: the link provider looked like the
+owner of endpoint/session identity, but Web Serial ports, worker lifecycles,
+ESP32 probe/flash, and browser-side JavaScript were owned elsewhere.
+
+## Decision
+
+`lpa-link` providers own their concrete resources. `LinkEndpoint` remains a
+provider-neutral endpoint snapshot. `LinkSession` is now a provider-neutral
+session snapshot/handle. Provider-private endpoint/session state owns concrete
+resources, and public provider operations accept endpoint/session ids.
+
+Browser provider JavaScript that implements provider mechanics is owned by
+`lpa-link`:
+
+- `browser_worker` owns the worker wrapper and worker lifecycle.
+- `browser_serial_esp32` owns Web Serial request/open/release/close.
+- `browser_serial_esp32` owns ESP32 bootloader probe and firmware flash
+ bindings.
+
+Application-owned browser artifacts are passed to provider constructors as
+same-origin paths, not as remote URLs or a general locator model.
+
+`lpa-studio-ux` owns the active Studio controller layer above `lpa-link`. It may
+adapt connected link sessions into `lpa-client`, but it does not own provider
+resource identity or lifecycle.
+
+## Consequences
+
+- Provider behavior is discoverable in one crate.
+- Browser serial endpoint-to-port state is provider-private.
+- Browser worker lifecycle is provider-private.
+- `lpa-client` continues to own request ids, response correlation, protocol
+ errors, heartbeat/log events, and project deploy helpers.
+- Web apps must provide same-origin sidecar paths for generated assets such as
+ `fw_browser.js`, `fw_browser_bg.wasm`, firmware manifests, and esptool modules.
+- Future providers should follow the same pattern: shared endpoint/session
+ records, provider-private concrete state, and provider methods by id.
diff --git a/docs/adr/2026-06-21-studio-manager-action-model.md b/docs/adr/2026-06-21-studio-manager-action-model.md
new file mode 100644
index 000000000..5f82eede3
--- /dev/null
+++ b/docs/adr/2026-06-21-studio-manager-action-model.md
@@ -0,0 +1,91 @@
+# ADR: Studio Manager Action Model
+
+Date: 2026-06-21
+
+## Status
+
+Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+
+## Context
+
+Studio has outgrown a single provisioning-flow model. The UI needs a device/link
+surface now, but the same product model also needs to support future server,
+project, CLI, and agent consumers. A flat `StudioActionKind` made every action
+look like an app-global operation and did not clearly say which manager owned
+the state or decision.
+
+At the same time, the current web UI still renders one device-oriented journey.
+The architecture needs to move toward link/server/project managers without
+forcing a full presentation rewrite in the same step.
+
+## Decision
+
+Studio core exposes three manager-owned action vocabularies:
+
+- `LinkActionRequest` for provider choice, endpoint access, link open/close,
+ target probing, flashing, reset, diagnostics, and link issues.
+- `ServerActionRequest` for `lp-server` protocol status and server project-state
+ reads.
+- `ProjectActionRequest` for project attachment, project loading/deployment,
+ inventory reads, and project-local navigation.
+
+`StudioActionKind` is now an app-wide wrapper around those manager-local
+requests. Generic consumers dispatch `StudioActionKind`, while manager code owns
+its local request vocabulary.
+
+Each manager read model exposes `available_actions()`, returning
+`AvailableAction`. `AvailableAction` combines:
+
+- a dispatchable action payload;
+- an `ActionDescriptor` for label, summary, category, and history policy;
+- enablement;
+- presentation priority;
+- optional confirmation metadata for risky actions.
+
+`StudioState::available_actions()` combines link, server, and project actions
+into app-wide `StudioActionKind` values for UI and agent consumers.
+
+The visible link vocabulary no longer uses `ProviderSelected` or
+`EndpointGranted` as flow states. Provider selection is catalog state, and
+endpoint discovery proceeds directly into `LinkState::Opening` with a connect
+effect. This keeps the happy path friction-free and leaves user-visible states
+for moments where the user or system has meaningful work.
+
+## Consequences
+
+- UI and future agents can ask core for the current state and valid actions
+ without hard-coding flow-specific buttons.
+- Action descriptors remain shared program documentation rather than duplicated
+ component copy.
+- The runtime boundary stays effect/event based; manager actions do not perform
+ I/O directly.
+- The current web UI can keep rendering transitional server/project milestones
+ through `LinkState` until the presentation is split into link/server/project
+ panes.
+- Endpoint discovery now auto-continues into link opening instead of exposing a
+ separate endpoint-granted stop.
+
+## Alternatives Considered
+
+- Keep a flat `StudioActionKind`: simpler in the short term, but it obscures
+ ownership and makes agent/tool documentation harder to organize.
+- Move all server/project states out of `LinkState` immediately: cleaner final
+ model, but it would couple this core action pass to a larger web UI rewrite.
+- Let UI components derive available actions directly from state: fast for the
+ current UI, but it would duplicate policy and make non-UI consumers less
+ honest.
+
+## Follow-ups
+
+- Split the web presentation around link/server/project read models.
+- Move remaining transitional server/project journey states out of `LinkState`
+ once the UI consumes the new manager states directly.
+- Use `AvailableAction` for visible controls instead of component-local button
+ decisions.
+
+## Update 2026-06-22
+
+The manager action idea became the active `lpa-studio-ux` model: `LinkUx`,
+`ServerUx`, and `ProjectUx` own typed operations and expose contextual
+`UxAction` values. `AvailableAction` and the effect/event runtime boundary were
+removed with the old core/runtime crates.
diff --git a/docs/adr/2026-06-21-studio-ux-layer.md b/docs/adr/2026-06-21-studio-ux-layer.md
new file mode 100644
index 000000000..931851008
--- /dev/null
+++ b/docs/adr/2026-06-21-studio-ux-layer.md
@@ -0,0 +1,142 @@
+# ADR: Studio UX Layer
+
+- **Status:** Accepted
+- **Date:** 2026-06-21
+- **Deciders:** Photomancer
+- **Supersedes:** [2026-06-18 Studio Action And Session Architecture](./2026-06-18-studio-action-session-architecture.md)
+
+## Context
+
+The first Studio prototype split UI-independent state from runtime execution:
+
+```text
+StudioAction -> StudioState + StudioEffect -> StudioEvent -> StudioState
+```
+
+That proved the browser-worker simulator and hardware provisioning paths, but it
+made ownership hard to reason about. Effects such as opening a link, creating a
+client, loading a project, and reading inventory were not merely UI effects;
+they were the actual application logic. The web app also still composed browser
+runtime routing, which meant the split did not fully hide implementation
+mechanics from presentation code.
+
+Studio needs a middle layer that is UI-independent and product-shaped, but that
+actually owns the services below it. The same layer should be useful to web UI,
+future CLI/desktop shells, tests, and agents.
+
+## Decision
+
+Add `lpa-studio-ux` as the Studio UX layer:
+
+```text
+lpa-link / lpa-client / protocol services
+ owned by
+lpa-studio-ux
+ consumed by
+lpa-studio-web, future CLI, desktop, and agents
+```
+
+`Ux` means a resource-owning product surface. It owns lower-level services such
+as `lpa-link` and `lpa-client`, then exposes user-shaped state, semantic views,
+snapshots, typed operations, contextual actions, progress, issues, logs, and
+project summaries.
+
+The first implementation slice uses:
+
+- `StudioUx` as the top-level surface;
+- `LinkUx`, `ServerUx`, and `ProjectUx` as domain sub-surfaces;
+- `LinkUx` owns `lpa-link::LinkProviderRegistry` and opens provider sessions
+ through the registry;
+- `ServerUx` owns the connected `lpa-client` protocol client after a link
+ connection exposes server I/O;
+- `StudioSnapshot` and related snapshots as cloneable read models;
+- `StudioView`, `UxPaneView`, and `UxBody` as small UI-independent view
+ primitives for panes, status, body content, metrics, issues, and actions;
+- typed operations such as `LinkOp` and `ProjectOp`;
+- `UxNodeId` to address resource-owning UX nodes such as `studio.link` and
+ `studio.project`;
+- `UxAction` as the in-process user-facing action offering: target node id,
+ boxed concrete operation, and contextual labels, summaries, priorities,
+ enablement, and confirmation data;
+- `UxNode` helpers so each UX node can create actions with its own node id;
+- `UxContext` dispatch so `StudioUx` can route a `UxAction` to the owning node
+ and downcast to the concrete operation at the boundary;
+- async dispatch methods that perform the real operation and update the UX
+ state directly.
+
+Studio does not maintain a separate string `ActionKind` identity in the core
+model. Operation identity is the concrete enum type and variant. If tooling
+later needs string identities, those should be derived from the operation type
+instead of maintained as parallel tags.
+
+The first proof path is browser-worker simulation through the same provider
+registry entry point that future hardware and host providers use. The simulator
+provider is represented as an initial action; executing it auto-discovers and
+connects the single browser-worker endpoint, then attaches the server protocol.
+`lpa-link` owns the browser-worker provider/session. `lpa-studio-ux` owns the
+registry and adapts the connected link session into `lpa-client::LpClient`
+as an internal server transport detail.
+
+Browser Web Serial is also represented as an initial provider action when the
+web build enables that provider. Browser port selection and permission remain
+browser-owned behavior; Studio UX starts the access request and then models the
+resulting provider endpoint/session state. The web app renders `StudioView`
+panes and generic `UxAction` values; it does not route runtime providers, drain
+service effects, correlate protocol responses, or implement browser port
+selection itself.
+
+A fully dynamic `UxRegistry` is intentionally deferred. `StudioUx` manually owns
+and dispatches to its current nodes for now, while the `UxNodeId`/`UxContext`
+shape leaves room for a future UX tree such as `studio.project.node_tree`.
+
+These UX models are in-process client-side objects. They are meant for web UI,
+future CLI/desktop shells, tests, and agent-facing textual descriptions; they
+are not a new client/server serialization boundary.
+
+The older `lpa-studio-core` and `lpa-studio-runtime` crates were deleted after
+the vertical slice proved the new model could own link, server, and project
+resources directly.
+
+## Consequences
+
+- Studio behavior becomes easier to inspect through states, node ids, and
+ available actions.
+- Web UI, future CLI, tests, and agents can share the same view/action/snapshot
+ language.
+- Initial provider choices are renderable by generic action components instead
+ of special-cased web UI.
+- Service operations move out of the UI and out of an abstract effect/event
+ loop.
+- The first slice is smaller than the old provisioning UI; ESP32 flashing,
+ provisioning, and rich recovery states must be ported intentionally later.
+- Historical plan files and old ADRs may mention the deleted core/runtime split,
+ but the active workspace uses `lpa-studio-ux` directly.
+
+## Alternatives Considered
+
+- Keep the `lpa-studio-core` / `lpa-studio-runtime` split.
+ - Rejected for the active direction because it made real application
+ ownership look like external effects and still leaked runtime composition
+ into the web app.
+- Rename the old crates to backup directories immediately.
+ - Deferred during the experiment because keeping them compiling as references
+ made the slice easier to compare. They were later deleted instead of
+ renamed once the new slice was viable.
+- Start with a generic UX component tree.
+ - Deferred. Domain-specific `LinkUx`, `ServerUx`, and `ProjectUx` states are
+ clearer for this stage. Generic view concepts can emerge from repeated
+ needs.
+- Keep `ActionKind` as a parallel string identity for operations.
+ - Rejected because operation identity already exists in concrete operation
+ enum types and variants. Future string identities can be derived when
+ tooling needs them.
+
+## Follow-Ups
+
+- Port browser serial ESP32 and firmware flashing into the UX model.
+- Add a CLI or test harness that drives `StudioUx` directly.
+- Rebuild richer Studio visual stories on the new view/action model.
+- Add a concrete `UxRegistry` when dynamic UX nodes such as
+ `studio.project.node_tree` need registration and dispatch.
+- Add derive macros for operation metadata after the manual `UxOp` model has
+ more usage pressure.
diff --git a/docs/adr/2026-06-22-browser-esp32-device-controller.md b/docs/adr/2026-06-22-browser-esp32-device-controller.md
new file mode 100644
index 000000000..9a9e8d317
--- /dev/null
+++ b/docs/adr/2026-06-22-browser-esp32-device-controller.md
@@ -0,0 +1,83 @@
+# ADR 2026-06-22: Browser ESP32 Device Controller Boundary
+
+## Status
+
+Accepted
+
+## Context
+
+Studio's browser ESP32 path needs to do more than open a serial transport. The
+same user workflow may need to select a Web Serial port, open the normal
+LightPlayer JSON-lines protocol, reset the device, stream raw boot logs, detect
+blank flash or ROM downloader mode, flash firmware through `esptool-js`, wipe
+the device, and reconnect afterward.
+
+Earlier code split normal protocol attach into separate browser operations:
+open for reset, close, reopen for protocol, then wait for readiness in Rust.
+That made boot output easy to miss, made reset signal failures look like
+connection failures, and forced the standalone serial debug page to duplicate
+Web Serial lifecycle code.
+
+Web Serial lifecycle details are browser-native: `SerialPort` ownership,
+reader/writer locks, `setSignals`, close races, and `esptool-js` handoff are
+all JavaScript/browser concerns. `lpa-link` still needs to own the provider
+semantics, while Studio UX should only see link capabilities, logs, progress,
+and connection state.
+
+## Decision
+
+Use a browser-side `BrowserEsp32DeviceController` as the durable owner of the
+Web Serial ESP32 device session.
+
+The controller owns:
+
+- the selected `SerialPort`;
+- reader and writer locks;
+- raw serial log pumping;
+- best-effort normal reset signaling;
+- explicit debug reset sequences;
+- line buffering for the LightPlayer protocol adapter;
+- safe close/release behavior;
+- event/log/progress emission for browser consumers.
+
+The controller is served by the Studio web app at:
+
+```text
+/lpa-link/browser_esp32_device_controller.js
+```
+
+The wasm-bound `browser-serial-esp32` provider adapter imports that module and
+maps it into `lpa-link` sessions, logs, and protocol I/O. The standalone
+`serial-debug.html` page imports the same module directly, so hardware debug
+flows exercise the same Web Serial lifecycle as Studio.
+
+Normal protocol attach now opens the port once, starts reading immediately, and
+then attempts normal reset while serial output is already being captured. Reset
+signal failures are diagnostic, not the source of connection truth. Readiness
+is determined from raw serial output and LightPlayer protocol frames above this
+controller boundary.
+
+`esptool-js` remains the implementation mechanism for bootloader operations.
+Provider management operations release normal protocol ownership before
+flash/wipe takes bootloader ownership, then reconnect through the same
+controller-backed protocol attach path afterward.
+
+## Consequences
+
+There is one browser-native place to fix Web Serial lock handling, reset timing,
+close races, raw log capture, and small retry policy.
+
+Studio UX remains independent of Web Serial details. It consumes link/provider
+capabilities, logs, progress, and readiness results rather than owning browser
+ports or signal sequences.
+
+The debug page is more trustworthy because its normal connect/reset/read path
+uses the same primitive as the app.
+
+Apps using the browser serial ESP32 provider must serve the controller module at
+the expected same-origin path, or configure an equivalent packaging strategy in
+a later provider option.
+
+The current controller is intentionally browser/client-side. It is not a new
+client/server protocol boundary and is not meant to be serialized beyond logs
+or textual agent/debug views.
diff --git a/docs/adr/2026-06-22-studio-device-ux-workflow.md b/docs/adr/2026-06-22-studio-device-ux-workflow.md
new file mode 100644
index 000000000..e00811ba2
--- /dev/null
+++ b/docs/adr/2026-06-22-studio-device-ux-workflow.md
@@ -0,0 +1,94 @@
+# ADR: Studio Device UX Workflow
+
+- **Status:** Accepted
+- **Date:** 2026-06-22
+- **Deciders:** Photomancer
+- **Builds On:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md),
+ [2026-06-22 Studio Link Management Workflow](./2026-06-22-studio-link-management-workflow.md)
+
+## Context
+
+The first Studio UX slice exposed separate Link, Server, and Project panes. That
+matched lower-level service boundaries, but it did not match the user's mental
+model. A user chooses how to connect to a device, waits for that device to open,
+waits for LightPlayer firmware/server readiness, and then opens a project. Link
+and server are meaningful implementation layers, but they are not usually the
+primary product concepts.
+
+Studio also needs to handle blank ESP32 devices, firmware provisioning, reset to
+blank, boot logs, progress, and project attach in one discoverable flow. Keeping
+Link and Server as adjacent primary panes made those recovery states feel split:
+the action lived in one pane while the failure or boot output often lived in
+another.
+
+## Decision
+
+Make `DeviceUx` the user-facing controller for the connection workflow.
+`StudioUx` owns `DeviceUx` and `ProjectUx`.
+
+`DeviceUx` owns the lower-level `LinkUx` and `ServerUx` controllers internally.
+It exposes one semantic `UiStackView` with step sections for:
+
+- select connection;
+- connect device;
+- connect LightPlayer;
+- open project.
+
+Actions are attached to the view section that offers them. A UX node does not
+have a separate global action list; actions are part of the presented view.
+`StudioUx::actions()` derives the currently available actions from the current
+`StudioView`.
+
+Project remains separate from Device, but only after a project is loaded.
+Project attach, demo loading, and loaded-project selection are part of getting
+the device into a usable state, so those actions live in the Device
+open-project step. Project is expected to grow into the node tree, file tree,
+and project editing surface, so folding the loaded project surface into Device
+would mix two product concepts that will evolve differently.
+
+`UiStackView`, `UiStackSection`, and `UiStepState` are reusable UI-independent
+view primitives. They are intentionally small and client-side only. They are
+used for web rendering, textual agent rendering, tests, and future CLI/desktop
+surfaces, not as a serialized client/server protocol.
+
+## Consequences
+
+- The active Studio shell now presents a Device pane first, with Project shown
+ only once a project is loaded.
+- Link and Server remain real implementation controllers, but they are no
+ longer top-level user-facing panes.
+- Blank-device detection, provisioning, reset-to-blank, reconnect, boot logs,
+ LightPlayer server attach, and project opening appear in one continuous Device
+ workflow.
+- Web UI rendering becomes more generic: it renders stack sections, section
+ bodies, section-local actions, and terminal output without knowing link/server
+ policy.
+- Agents and future CLI shells can inspect the same tree and describe both the
+ state and the available requests in a product-shaped language.
+- Some transitional code still maps Link/Server progress updates into the
+ Device pane while lower-level progress emitters are migrated.
+
+## Alternatives Considered
+
+- Keep Link and Server as separate primary panes.
+ - Rejected. It preserves implementation boundaries at the cost of a fractured
+ user workflow.
+- Merge Project into Device.
+ - Rejected. Project will become a larger editing/navigation surface, and it
+ should remain its own Studio concept.
+- Build a highly generic component schema immediately.
+ - Rejected. A small stack/body/action vocabulary covers the current workflow
+ while leaving room to grow from real use.
+- Serialize the UX tree as a protocol.
+ - Rejected. This is an in-process client-side model. Textual rendering for
+ agents is useful, but Studio already has a real client/server boundary in
+ `lpa-client` and `lp-server`.
+
+## Follow-Ups
+
+- Migrate lower-level progress emission so Device actions publish section-aware
+ activity directly instead of relying on Link/Server node-id fallback mapping.
+- Continue shaping the loaded Project pane around the future project node tree
+ and file tree.
+- Add more focused tests for Device stack state/action placement as hardware
+ workflows settle.
diff --git a/docs/adr/2026-06-22-studio-link-management-workflow.md b/docs/adr/2026-06-22-studio-link-management-workflow.md
new file mode 100644
index 000000000..061a6f87e
--- /dev/null
+++ b/docs/adr/2026-06-22-studio-link-management-workflow.md
@@ -0,0 +1,135 @@
+# ADR: Studio Link Management Workflow
+
+- **Status:** Accepted
+- **Date:** 2026-06-22
+- **Deciders:** Photomancer
+- **Builds On:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md)
+
+## Context
+
+Studio now has a UI-independent UX layer that owns `lpa-link`, `lpa-client`,
+server state, project state, and action dispatch. The next missing hardware
+workflow is the blank-device lifecycle:
+
+- provision a blank ESP32-C6 with packaged LightPlayer firmware;
+- reset an existing ESP32-C6 back to blank.
+
+These operations are below the `lp-server` protocol. A blank device may not be
+running a server at all, and a full-device erase intentionally destroys the
+firmware and server filesystem. Modeling those operations as `lpa-client`
+requests or Dioxus component logic would put ownership in the wrong layer.
+
+## Decision
+
+Add a provider-neutral link management API to `lpa-link`:
+
+- providers advertise low-level support through `LinkCapabilities`;
+- callers execute session-scoped requests with `LinkProvider::manage`;
+- requests use `LinkManagementRequest`;
+- results use `LinkManagementResult` plus compact management progress/log data;
+- long-running providers can additionally publish `LinkManagementEvent` values
+ through `manage_with_events`;
+- unsupported providers return `LinkError::OperationUnsupported`.
+
+The initial implemented requests are:
+
+- `FlashFirmware`: write the provider-configured firmware image set to the
+ connected target;
+- `EraseDeviceFlash`: erase the whole device flash so the ESP32 returns to an
+ unprovisioned blank state.
+
+Do not overload reset/reboot vocabulary for destructive blanking.
+`LinkOperation::Reset` remains non-destructive runtime/device reset.
+`LinkOperation::EraseDeviceFlash` is the destructive full-device erase
+capability.
+
+Browser Web Serial ESP32 is the first concrete management provider. It owns Web
+Serial permission, port ownership, ESP32 bootloader access, firmware manifest
+loading, `esptool-js` integration, and protocol release/reopen behavior. Before
+probe, firmware flash, or full erase, it releases normal server/protocol serial
+ownership so bootloader tooling can take the port exclusively.
+
+Browser serial server protocol open/reopen also performs a hard reset before
+opening the JSON-lines protocol stream. The browser serial client then waits for
+the first valid protocol frame before sending the first request, so the initial
+project probe is not lost while firmware is still booting. If the readiness wait
+or probe still fails, Studio marks the server failed instead of leaving the
+server pane in a misleading connected state.
+
+Use the packaged Studio firmware manifest at
+`./firmware/esp32c6/manifest.json`. Use a pinned browser esptool module,
+`https://cdn.jsdelivr.net/npm/esptool-js@0.6.0/+esm`, as the default development
+path. A browser ESM CDN endpoint is required because the raw package ESM imports
+dependencies such as `pako` by bare specifier, which browsers cannot resolve
+directly. The selected endpoint has been checked against the ESP32-C6 stub
+decode path used by reset/provisioning. Applications can override the module
+path through `BrowserSerialEsp32Options` when they want to serve the dependency
+themselves.
+
+Expose the workflow through `lpa-studio-ux` actions:
+
+- `Provision firmware` is a primary link action when a connected link supports
+ firmware flashing and Studio is not attached to a server;
+- `Reset to blank` is a tertiary destructive link action when a connected link
+ supports whole-device erase;
+- both actions carry `ActionConfirmation` metadata and are rendered generically
+ by the web UI;
+- `StudioUx` clears server/project state before provisioning or erasing because
+ either operation invalidates the old server connection;
+- after provisioning, Studio attempts to reopen the server protocol and resume
+ the normal server/project workflow;
+- after reset-to-blank, Studio leaves server/project detached and keeps the
+ link context provisionable when the browser still holds the serial permission.
+- live management progress is surfaced as pane-scoped `UxActivity`, including
+ progress bars and raw esptool terminal output for browser serial flash/erase.
+
+Zip upload/download is out of this slice. If raw filesystem backup/restore is
+added later, it should read or write direct device/LittleFS image bytes through
+link-level management, not route through the running server filesystem API.
+
+## Consequences
+
+- Blank-device provisioning and reset-to-blank are now part of the same
+ `UxAction` language as simulator start, server attach, and project actions.
+- Web UI remains presentation-only: it renders action metadata, confirms
+ destructive actions, and dispatches the selected `UxAction`.
+- Agents and future CLI/desktop shells can inspect the same UX tree and see
+ link management actions without learning browser serial or esptool details.
+- `lpa-link` becomes the durable home for low-level device management, while
+ `lpa-client` remains the durable home for server protocol/project operations.
+- Flash/erase no longer need to leave the UI opaque while awaiting a single
+ final result; the UX layer can publish live activity updates without moving
+ provider ownership into the web UI.
+- The default esptool path depends on a pinned remote module. This is acceptable
+ for the current development slice and is explicitly overridable for packaged
+ deployments.
+
+## Alternatives Considered
+
+- Implement provisioning directly in Dioxus components.
+ - Rejected. The web app should not own Web Serial/esptool resource lifecycle or
+ decide server/project invalidation policy.
+- Treat reset-to-blank as a server filesystem operation.
+ - Rejected. A full blank reset destroys firmware and server state; it must be
+ below the server protocol.
+- Hide provisioning behind server connect failure handling.
+ - Rejected. Provisioning is a first-class capability of a connected management
+ link, not an error recovery side effect.
+- Implement zip filesystem backup/restore now.
+ - Deferred. The immediate product need is provisioning a blank device and
+ resetting an existing device to blank. Future filesystem backup/restore
+ should use raw LittleFS/device image bytes.
+- Self-host `esptool-js` immediately.
+ - Deferred. The pinned remote module keeps this slice small while preserving
+ an explicit override path for deployments.
+
+## Follow-Ups
+
+- Validate provision/reset on real ESP32-C6 hardware in the browser and record
+ any timing or reconnect refinements.
+- Self-host or vendor the browser esptool module for offline/deployed Studio
+ builds.
+- Add host-serial ESP32 management support using the same request/result model.
+- Add direct raw LittleFS image read/write if backup/restore becomes a priority.
+- Add cancellation/retry affordances for long-running management activity if
+ flash/erase failures need more recovery control.
diff --git a/docs/adr/2026-06-22-studio-pages-deployment.md b/docs/adr/2026-06-22-studio-pages-deployment.md
new file mode 100644
index 000000000..84c635087
--- /dev/null
+++ b/docs/adr/2026-06-22-studio-pages-deployment.md
@@ -0,0 +1,71 @@
+# ADR 2026-06-22: Studio Pages Deployment
+
+## Status
+
+Accepted
+
+## Context
+
+`lpa-studio-web` is now a static browser shell for LightPlayer Studio. It
+packages Dioxus wasm, the browser firmware runtime wasm, browser Web Serial
+support files, and an ESP32-C6 firmware image for provisioning.
+
+The project owns `lightplayer.app`. The repo already had a GitHub Pages-oriented
+web demo deploy path, so GitHub Pages is the lowest-friction first public
+hosting option.
+
+Studio browser assets currently assume root-hosted paths. The browser serial
+provider's generated JavaScript imports the app-served controller at
+`/lpa-link/browser_esp32_device_controller.js`. Path-hosted deployments such as
+`lightplayer.app/beta/` would require base-path-aware asset resolution first.
+
+GitHub Pages also associates custom domains with a Pages site. Root-hosted
+production, beta, and demo subdomains therefore need separate Pages sites or a
+more complex router/fronting layer.
+
+## Decision
+
+Use GitHub Pages for the first public static deployment:
+
+- `light-player/lightplayer` hosts production Studio at `lightplayer.app` using
+ GitHub Pages from Actions.
+- `light-player/lightplayer-beta-pages` hosts manually deployed beta Studio at
+ `beta.lightplayer.app`.
+- `light-player/lightplayer-demo-pages` hosts the GLSL web demo at
+ `demo.lightplayer.app`.
+
+The source repo remains the build authority. Production deploys from `main` via
+GitHub's Pages artifact flow. Beta and demo are built in the source repo by a
+manual workflow, then published as static commits to the corresponding Pages
+repository.
+
+Deployment tooling stages clean release-only artifacts under `target/pages/`
+and generates `version.json`, `.nojekyll`, and `CNAME` files. This avoids
+uploading stale debug wasm from `lp-app/lpa-studio-web/public/pkg`.
+
+## Consequences
+
+- Production deploys are automatic after `main` updates.
+- Beta and demo deploys are explicit and cannot overwrite production.
+- Each public hostname is root-hosted, which matches current browser asset path
+ assumptions.
+- The source repo needs GitHub App credentials for workflows that push to
+ beta/demo Pages repositories. The workflow mints a short-lived installation
+ token with `contents:write`.
+- DNS, GitHub Pages custom-domain checks, and HTTPS certificate provisioning
+ remain human-confirmed operational steps.
+- The app still depends on the pinned jsDelivr ESM endpoint for `esptool-js`
+ until a later self-hosting pass.
+- Path-hosted channels remain future work until Studio and `lpa-link` browser
+ assets are base-path aware.
+
+## Alternatives Considered
+
+- **Single Pages site with `/beta/` and `/demo/` paths.** Rejected for the first
+ pass because root-anchored browser serial assets would need additional work.
+- **Keep only `gh-pages` branch deployment.** Rejected for production because
+ GitHub Pages from Actions provides a cleaner artifact boundary and deployment
+ environment.
+- **Use a different static host immediately.** Deferred because GitHub Pages is
+ already close to the existing demo workflow and is sufficient for the current
+ static bundle size.
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 000000000..497b2efd7
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,43 @@
+# Architecture Decision Records
+
+Architecture Decision Records, or ADRs, capture durable architecture and process
+decisions for this repo.
+
+Use ADRs for decisions that choose a direction among plausible alternatives and
+have lasting architectural, operational, security, data-model, API, workflow,
+product, embedded, or cross-repo/process consequences.
+
+Do not create ADRs for ordinary feature work, bug fixes, UI copy/layout
+changes, mechanical refactors, tests, scripts, helpers, or phase sequencing
+unless they set a broader precedent.
+
+## Filename
+
+Use date-based filenames:
+
+```text
+YYYY-MM-DD-short-title.md
+```
+
+Date-based names keep files sortable and reduce conflicts between parallel
+branches.
+
+## Status
+
+Use one of:
+
+- `Proposed`
+- `Accepted`
+- `Superseded`
+- `Rejected`
+
+Treat ADRs as durable history. If a decision changes, create a new ADR that
+supersedes the old one instead of rewriting old context heavily.
+
+## Relationship To Shared Planning
+
+Plans, roadmap-level plans, reviews, reports, scratch notes, and phase prompts
+live in the personal planning workspace configured by `PHOTOMANCER_PLANNING_ROOT`
+or `~/.photomancer/planning`.
+
+Only durable decisions graduate into `docs/adr/`.
diff --git a/docs/adr/_template.md b/docs/adr/_template.md
new file mode 100644
index 000000000..f02b7364c
--- /dev/null
+++ b/docs/adr/_template.md
@@ -0,0 +1,17 @@
+# ADR: Title
+
+- **Status:** Proposed
+- **Date:** YYYY-MM-DD
+- **Deciders:** Photomancer
+- **Supersedes:** None
+- **Superseded by:** None
+
+## Context
+
+## Decision
+
+## Consequences
+
+## Alternatives Considered
+
+## Follow-ups
diff --git a/docs/deploy/studio-pages.md b/docs/deploy/studio-pages.md
new file mode 100644
index 000000000..8736c78c7
--- /dev/null
+++ b/docs/deploy/studio-pages.md
@@ -0,0 +1,136 @@
+# LightPlayer Pages Deployment
+
+LightPlayer Studio deploys as a static GitHub Pages site. The first deployment
+topology uses root-hosted domains rather than path-hosted channels because
+browser serial assets are currently resolved from root paths such as
+`/lpa-link/browser_esp32_device_controller.js`.
+
+## Sites
+
+| Channel | URL | Source |
+|---|---|---|
+| Production Studio | `https://lightplayer.app/` | `light-player/lightplayer`, GitHub Pages from Actions |
+| Beta Studio | `https://beta.lightplayer.app/` | `light-player/lightplayer-beta-pages`, branch Pages |
+| Web demo | `https://demo.lightplayer.app/` | `light-player/lightplayer-demo-pages`, branch Pages |
+
+## Automated Setup
+
+Create and configure the beta/demo Pages repositories with:
+
+```bash
+scripts/pages/setup-pages-repos.sh --dry-run
+scripts/pages/setup-pages-repos.sh --apply
+```
+
+The helper uses `gh repo create` and `gh api` where permissions allow. It does
+not change DNS and it may still leave GitHub Pages confirmation steps for a
+human when token scopes or organization settings block automation.
+
+The source repo needs GitHub App credentials for manual beta/demo deployments.
+The app should be installed only on:
+
+- `light-player/lightplayer-beta-pages`
+- `light-player/lightplayer-demo-pages`
+
+The app needs only repository **Contents: Read and write** permission. Store the
+credentials in `light-player/lightplayer` as Actions secrets:
+
+```text
+LIGHTPLAYER_PAGES_APP_ID
+LIGHTPLAYER_PAGES_PRIVATE_KEY
+```
+
+Production uses GitHub's native Pages deployment token and does not need these
+secrets.
+
+## DNS
+
+Use GitHub Pages records for the apex:
+
+```text
+A @ 185.199.108.153
+A @ 185.199.109.153
+A @ 185.199.110.153
+A @ 185.199.111.153
+```
+
+Optional IPv6 records:
+
+```text
+AAAA @ 2606:50c0:8000::153
+AAAA @ 2606:50c0:8001::153
+AAAA @ 2606:50c0:8002::153
+AAAA @ 2606:50c0:8003::153
+```
+
+Subdomain records:
+
+```text
+CNAME www light-player.github.io
+CNAME beta light-player.github.io
+CNAME demo light-player.github.io
+```
+
+Remove parking, forwarding, or old apex `A`/`AAAA`/`ALIAS`/`ANAME` records.
+Extra apex records can prevent GitHub from provisioning the Pages certificate.
+
+## GitHub Pages Settings
+
+Production:
+
+1. Open `https://github.com/light-player/lightplayer/settings/pages`.
+2. Set the Pages source to GitHub Actions.
+3. Set the custom domain to `lightplayer.app`.
+4. Wait for the DNS check and TLS certificate.
+5. Enable Enforce HTTPS.
+
+Beta and demo:
+
+1. Open each Pages repo's Pages settings.
+2. Confirm branch source is `main` and `/`.
+3. Confirm custom domains:
+ - `beta.lightplayer.app`
+ - `demo.lightplayer.app`
+4. Wait for DNS checks and TLS certificates.
+5. Enable Enforce HTTPS.
+
+## Build And Smoke Locally
+
+Studio production artifact:
+
+```bash
+just studio-web-deploy-dir production target/pages/studio lightplayer.app
+just studio-web-smoke target/pages/studio
+```
+
+Beta Studio artifact:
+
+```bash
+just studio-web-deploy-dir beta target/pages/studio beta.lightplayer.app
+just studio-web-smoke target/pages/studio
+```
+
+Web demo artifact:
+
+```bash
+just web-demo-deploy-dir demo target/pages/web-demo demo.lightplayer.app
+just web-demo-smoke target/pages/web-demo
+```
+
+The deploy-directory recipes generate `version.json`, `.nojekyll`, and `CNAME`
+when a domain is supplied. They also print total artifact size and the largest
+files so debug wasm artifacts are easy to spot.
+
+## Deploy
+
+Production deploys automatically from `main` through the `Deploy Studio Pages`
+workflow.
+
+Beta and demo deploy manually through the `Deploy Pages Channel` workflow:
+
+- `channel`: `beta` or `demo`
+- `ref`: branch, tag, or commit to build
+- `dry_run`: build and smoke-check without pushing
+
+The older `just web-demo-deploy` path remains available until
+`demo.lightplayer.app` is verified.
diff --git a/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md b/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md
index df9d25d01..7a9205743 100644
--- a/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md
+++ b/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md
@@ -140,12 +140,12 @@ proposal — local placement chosen).
#### `src/emu/executor/compressed.rs` — compressed jump classification
-| Mnemonic | Encoding | New class |
-| ------------ | ---------- | ---------------------------------------------- |
-| `c.j` | rd = x0 | `JalTail` |
-| `c.jal` | rd = x1 | `JalCall` |
-| `c.jr rs1` | rd = x0 | `JalrReturn` if `rs1 == 1` else `JalrIndirect` |
-| `c.jalr rs1` | rd = x1 | `JalrCall` |
+| Mnemonic | Encoding | New class |
+|--------------|----------|------------------------------------------------|
+| `c.j` | rd = x0 | `JalTail` |
+| `c.jal` | rd = x1 | `JalCall` |
+| `c.jr rs1` | rd = x0 | `JalrReturn` if `rs1 == 1` else `JalrIndirect` |
+| `c.jalr rs1` | rd = x1 | `JalrCall` |
#### `src/emu/executor/mod.rs` — `ExecutionResult`
@@ -204,12 +204,15 @@ impl Riscv32Emulator {
#### `src/profile/mod.rs` — trait + session updates
`InstClass` re-export from `cycle_model`:
+
```rust
pub use crate::emu::cycle_model::InstClass;
```
+
(Replaces the m1-era stub `pub struct InstClass {}`.)
`Collector` trait:
+
```rust
pub trait Collector: Send {
fn on_syscall(&mut self, _ctx: &mut EmuCtx, _id: u32, _args: &[u32; 8]) -> SyscallAction { SyscallAction::Forward }
@@ -223,6 +226,7 @@ pub trait Collector: Send {
```
`ProfileSession`:
+
```rust
impl ProfileSession {
pub fn dispatch_instruction(&mut self, pc: u32, target_pc: u32,
@@ -233,7 +237,7 @@ impl ProfileSession {
}
/// Extended on_perf_event: fans out to collectors, runs gate,
- /// fans out gate action, propagates Stop.
+ /// fans out gate ux, propagates Stop.
pub fn on_perf_event(&mut self, event: PerfEvent) {
for c in &mut self.collectors { c.on_perf_event(&event); }
let action = self.gate.as_mut()
@@ -478,6 +482,7 @@ impl CycleModelArg {
#### `src/commands/profile/handler.rs` — wiring
Two new chunks:
+
1. **Cycle model plumbing**:
`emu = emu.with_cycle_model(args.cycle_model.to_emu());`
2. **`cpu` collector validation + auto-include events**:
@@ -568,14 +573,15 @@ impl<'a> Symbolizer<'a> {
```
Algorithm:
+
1. Symbolize every PC in `func_stats` and assemble a deduplicated
`frames: Vec<{ name }>` with a `pc -> frame_idx` map.
2. DFS over `call_edges` keyed by caller, maintaining a running
`cursor: u64`. For each (caller, callee) in DFS order:
- - Emit `O` event for callee at `cursor`.
- - Recurse into callee's outgoing edges.
- - `cursor += call_edges[(caller, callee)].inclusive_cycles - `
- - Emit `C` event for callee at `cursor`.
+ - Emit `O` event for callee at `cursor`.
+ - Recurse into callee's outgoing edges.
+ - `cursor += call_edges[(caller, callee)].inclusive_cycles - `
+ - Emit `C` event for callee at `cursor`.
3. Wrap in the Speedscope envelope:
```json
{
@@ -680,13 +686,13 @@ lp-cli profile [--dir PROFILES_DIR] EXAMPLE_DIR
`profiles/2026-04-19T15-22-01--basic--steady-render/`
-| File | Source | Notes |
-| ------------------------------- | ----------------- | ---------------------------------- |
-| `meta.json` | handler | gains `cycle_model` field |
-| `events.jsonl` | EventsCollector | auto-included with cpu |
-| `cpu-profile.json` | output_cpu_json | canonical, m3's diff target |
-| `cpu-profile.speedscope.json` | output_speedscope | browser flame chart |
-| `report.txt` | output | gains `=== CPU summary ===` section|
+| File | Source | Notes |
+|-------------------------------|-------------------|-------------------------------------|
+| `meta.json` | handler | gains `cycle_model` field |
+| `events.jsonl` | EventsCollector | auto-included with cpu |
+| `cpu-profile.json` | output_cpu_json | canonical, m3's diff target |
+| `cpu-profile.speedscope.json` | output_speedscope | browser flame chart |
+| `report.txt` | output | gains `=== CPU summary ===` section |
`heap-trace.jsonl` shows up only when `alloc` is in `--collect`.
@@ -704,60 +710,60 @@ lp-cli profile [--dir PROFILES_DIR] EXAMPLE_DIR
values where it matters).
- `lp-riscv-emu/src/profile/cpu.rs#tests` — eight scenarios:
- 1. `gate_disabled_no_attribution`: events ignored when active=false.
- 2. `simple_call_return`: push, attribute, pop. Inclusive cycles
- match wall-clock between push and pop.
- 3. `nested_three_deep`: A→B→C→C-return→B-return→A-return.
- Inclusive cycles bubble correctly.
- 4. `tail_call_swaps_top`: A→B(tail)→C(tail)→return-from-A. Stack
- never deeper than 1 frame.
- 5. `orphaned_return_at_root`: extra return when stack is empty.
- No-op, no panic.
- 6. `root_self_cycles`: instructions before any call land under
- `` (PC=0).
- 7. `enable_disable_toggle`: gate flips on/off mid-run; cycles only
- attributed during `active=true` windows.
- 8. `call_edge_aggregation`: same (caller, callee) called 3 times;
- `call_edges[(caller, callee)].count == 3`.
+ 1. `gate_disabled_no_attribution`: events ignored when active=false.
+ 2. `simple_call_return`: push, attribute, pop. Inclusive cycles
+ match wall-clock between push and pop.
+ 3. `nested_three_deep`: A→B→C→C-return→B-return→A-return.
+ Inclusive cycles bubble correctly.
+ 4. `tail_call_swaps_top`: A→B(tail)→C(tail)→return-from-A. Stack
+ never deeper than 1 frame.
+ 5. `orphaned_return_at_root`: extra return when stack is empty.
+ No-op, no panic.
+ 6. `root_self_cycles`: instructions before any call land under
+ `` (PC=0).
+ 7. `enable_disable_toggle`: gate flips on/off mid-run; cycles only
+ attributed during `active=true` windows.
+ 8. `call_edge_aggregation`: same (caller, callee) called 3 times;
+ `call_edges[(caller, callee)].count == 3`.
- `lp-cli/src/commands/profile/symbolize.rs#tests`:
- - hit (PC inside known symbol)
- - miss in RAM → ``
- - miss elsewhere → ``
- - PC == 0 → ``
- - boundary: `pc == addr` (hit), `pc == addr + size - 1` (hit),
- `pc == addr + size` (miss).
+ - hit (PC inside known symbol)
+ - miss in RAM → ``
+ - miss elsewhere → ``
+ - PC == 0 → ``
+ - boundary: `pc == addr` (hit), `pc == addr + size - 1` (hit),
+ `pc == addr + size` (miss).
- `lp-cli/src/commands/profile/output_speedscope.rs#tests`:
- - 3-frame call graph fixture → output parses as JSON, has correct
- Speedscope shape (`$schema`, `profiles[0].type == "evented"`,
- `events.len() == call_edges.len() * 2`).
- - Sum of all event delta cycles equals `total_cycles_attributed`.
+ - 3-frame call graph fixture → output parses as JSON, has correct
+ Speedscope shape (`$schema`, `profiles[0].type == "evented"`,
+ `events.len() == call_edges.len() * 2`).
+ - Sum of all event delta cycles equals `total_cycles_attributed`.
- `lp-cli/src/commands/profile/output_cpu_json.rs#tests`:
- - Round-trip through `serde_json::from_str` / `to_string`.
- - `schema_version == 1` present.
- - Hex format matches `^0x[0-9a-f]{8}$` for all PC keys.
+ - Round-trip through `serde_json::from_str` / `to_string`.
+ - `schema_version == 1` present.
+ - Hex format matches `^0x[0-9a-f]{8}$` for all PC keys.
- `lp-cli/src/commands/profile/mode/{compile,startup,all}.rs#tests`:
- - New case: each gate returns `Enable` on `profile:start`.
- - (Existing m1 cases unchanged.)
+ - New case: each gate returns `Enable` on `profile:start`.
+ - (Existing m1 cases unchanged.)
### Integration tests
- `lp-cli/tests/profile_cpu.rs` (new):
- - `cpu_default_smoke`: `lp-cli profile examples/basic` produces
- `meta.json`, `events.jsonl`, `cpu-profile.json`,
- `cpu-profile.speedscope.json`, `report.txt`. `cpu-profile.json`
- parses; `total_cycles_attributed > 0`; `report.txt` contains
- "CPU summary".
- - `cpu_with_alloc`: `--collect cpu,alloc` produces both
- `cpu-profile.json` and `heap-trace.jsonl`.
- - `cpu_uniform_model`: `--cycle-model uniform` →
- `meta.json.cycle_model == "uniform"` and all instruction costs
- are 1 (sanity check on a tiny example).
- - `cpu_compile_mode`: `--mode compile` produces a non-empty
- flame chart (smoke test that gate Enable-on-profile-start works).
+ - `cpu_default_smoke`: `lp-cli profile examples/basic` produces
+ `meta.json`, `events.jsonl`, `cpu-profile.json`,
+ `cpu-profile.speedscope.json`, `report.txt`. `cpu-profile.json`
+ parses; `total_cycles_attributed > 0`; `report.txt` contains
+ "CPU summary".
+ - `cpu_with_alloc`: `--collect cpu,alloc` produces both
+ `cpu-profile.json` and `heap-trace.jsonl`.
+ - `cpu_uniform_model`: `--cycle-model uniform` →
+ `meta.json.cycle_model == "uniform"` and all instruction costs
+ are 1 (sanity check on a tiny example).
+ - `cpu_compile_mode`: `--mode compile` produces a non-empty
+ flame chart (smoke test that gate Enable-on-profile-start works).
## Out of scope
diff --git a/examples/basic3/project.json b/examples/basic3/project.json
new file mode 100644
index 000000000..83f8de292
--- /dev/null
+++ b/examples/basic3/project.json
@@ -0,0 +1,4 @@
+{
+ "uid": "2026.05.03-11.37.00-basic3",
+ "name": "basic3"
+}
\ No newline at end of file
diff --git a/examples/basic3/src/fixture.fixture/node.json b/examples/basic3/src/fixture.fixture/node.json
new file mode 100644
index 000000000..cdb7076c4
--- /dev/null
+++ b/examples/basic3/src/fixture.fixture/node.json
@@ -0,0 +1,31 @@
+{
+ "output_spec": "/src/strip.output",
+ "texture_spec": "/src/main.texture",
+ "mapping": {
+ "PathPoints": {
+ "paths": [
+ {
+ "RingArray": {
+ "center": [0.5, 0.5],
+ "diameter": 1.0,
+ "start_ring_inclusive": 0,
+ "end_ring_exclusive": 9,
+ "ring_lamp_counts": [1, 8, 12, 16, 24, 32, 40, 48, 60],
+ "offset_angle": 0.0,
+ "order": "InnerFirst"
+ }
+ }
+ ],
+ "sample_diameter": 2
+ }
+ },
+ "color_order": "Rgb",
+ "transform": [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0]
+ ],
+ "brightness": 255,
+ "gamma_correction": false
+}
diff --git a/examples/basic3/src/main.texture/node.json b/examples/basic3/src/main.texture/node.json
new file mode 100644
index 000000000..428db3e46
--- /dev/null
+++ b/examples/basic3/src/main.texture/node.json
@@ -0,0 +1,4 @@
+{
+ "width": 16,
+ "height": 16
+}
diff --git a/examples/basic3/src/rainbow.shader/main.glsl b/examples/basic3/src/rainbow.shader/main.glsl
new file mode 100644
index 000000000..31ae8fdef
--- /dev/null
+++ b/examples/basic3/src/rainbow.shader/main.glsl
@@ -0,0 +1,33 @@
+const int ITERS = 10;
+const float TAU = 6.28318;
+
+layout(binding = 0) uniform vec2 outputSize;
+layout(binding = 1) uniform float time;
+
+vec4 friendPattern(vec2 uv) {
+ vec2 v = vec2(1.0, 1.0);
+ vec2 p = (uv + uv - v) / 0.3;
+ vec4 color = vec4(0.0);
+ float phase = mod(time * 0.05 * TAU, TAU);
+
+ for (int i = 1; i < ITERS; i++) {
+ v = p;
+ for (int f = 1; f < ITERS; f++) {
+ float ff = float(f);
+ v += sin(v.yx * ff + float(i) + phase) / ff;
+ }
+
+ vec4 ramp = cos(float(i) + vec4(0.0, 1.0, 2.0, 3.0)) + 1.0;
+ color += ramp / 6.0 / max(length(v), 0.001);
+ }
+
+ vec4 mapped = color * color;
+ color = mapped / (1.0 + mapped);
+ color.a = 1.0;
+ return color;
+}
+
+vec4 render(vec2 pos) {
+ vec2 uv = pos / outputSize;
+ return friendPattern(uv);
+}
\ No newline at end of file
diff --git a/examples/basic3/src/rainbow.shader/node.json b/examples/basic3/src/rainbow.shader/node.json
new file mode 100644
index 000000000..22b838e89
--- /dev/null
+++ b/examples/basic3/src/rainbow.shader/node.json
@@ -0,0 +1,5 @@
+{
+ "glsl_path": "main.glsl",
+ "texture_spec": "/src/main.texture",
+ "render_order": 0
+}
\ No newline at end of file
diff --git a/examples/basic3/src/strip.output/node.json b/examples/basic3/src/strip.output/node.json
new file mode 100644
index 000000000..2c0646168
--- /dev/null
+++ b/examples/basic3/src/strip.output/node.json
@@ -0,0 +1,11 @@
+{
+ "GpioStrip": {
+ "pin": 4,
+ "options": {
+ "interpolation_enabled": true,
+ "dithering_enabled": false,
+ "lut_enabled": true,
+ "brightness": 0.125
+ }
+ }
+}
diff --git a/justfile b/justfile
index cf4b59fb4..6786687f3 100644
--- a/justfile
+++ b/justfile
@@ -9,6 +9,7 @@ rv32_firmware_packages := "fw-esp32"
# fw-esp32 uses release-esp32 (panic=unwind, nightly) for panic recovery
fw_esp32_profile := "release-esp32"
+fw_esp32_elf := "target/" + rv32_target + "/" + fw_esp32_profile + "/fw-esp32"
lps_dir := "lp-shader"
# Default recipe - show available commands
@@ -66,7 +67,7 @@ web-demo-build: install-wasm32-target
wasm-bindgen target/wasm32-unknown-unknown/release/web_demo.wasm \
--out-dir lp-app/web-demo/www/pkg --target web
mkdir -p lp-app/web-demo/www
- cp examples/basic/src/rainbow.shader/main.glsl lp-app/web-demo/www/rainbow-default.glsl
+ cp examples/basic/shader.glsl lp-app/web-demo/www/rainbow-default.glsl
echo "Artifacts: lp-app/web-demo/www/ (index.html, pkg/)"
# Build and serve the web demo (installs miniserve via cargo if missing)
@@ -80,6 +81,27 @@ web-demo: web-demo-build
echo "Serving http://127.0.0.1:2812 (Ctrl+C to stop)"
miniserve --index index.html -p 2812 lp-app/web-demo/www/
+# Build a clean GitHub Pages artifact for the web demo.
+web-demo-deploy-dir channel="local" out_dir="target/pages/web-demo" domain="":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ just web-demo-build
+ args=(--kind web-demo --channel "{{ channel }}" --out "{{ out_dir }}")
+ if [[ -n "{{ domain }}" ]]; then
+ args+=(--domain "{{ domain }}")
+ fi
+ node scripts/pages/prepare-pages-artifact.mjs "${args[@]}"
+
+# Smoke-check the staged web demo Pages artifact.
+web-demo-smoke out_dir="target/pages/web-demo":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ node scripts/pages/static-site-smoke.mjs \
+ --kind web-demo \
+ --dir "{{ out_dir }}" \
+ --port "${WEB_DEMO_SMOKE_PORT:-2831}" \
+ --server "${PAGES_SMOKE_SERVER:-required}"
+
# Deploy web demo to gh-pages branch
web-demo-deploy: web-demo-build
#!/usr/bin/env bash
@@ -121,6 +143,216 @@ web-demo-deploy: web-demo-build
cd -
git worktree remove --force "$tmp_dir/wt"
+# ============================================================================
+# fw-browser (browser/Web Worker runtime proof)
+# ============================================================================
+
+fw-browser-build: install-wasm32-target
+ #!/usr/bin/env bash
+ set -euo pipefail
+ echo "Building fw-browser for wasm32..."
+ cargo build -p fw-browser --target wasm32-unknown-unknown --release
+ if ! command -v wasm-bindgen >/dev/null 2>&1; then
+ echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114"
+ exit 1
+ fi
+ echo "Generating fw-browser JS glue..."
+ wasm-bindgen target/wasm32-unknown-unknown/release/fw_browser.wasm \
+ --out-dir lp-fw/fw-browser/www/pkg --target web
+ echo "Artifacts: lp-fw/fw-browser/www/ (smoke.html, pkg/)"
+
+fw-browser-test: install-wasm32-target
+ #!/usr/bin/env bash
+ set -euo pipefail
+ if ! command -v wasm-bindgen-test-runner >/dev/null 2>&1; then
+ echo "wasm-bindgen-test-runner not found. Install: cargo install wasm-bindgen-cli --version 0.2.114"
+ exit 1
+ fi
+ CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \
+ cargo test -p fw-browser --target wasm32-unknown-unknown
+
+fw-browser-smoke: fw-browser-build
+ #!/usr/bin/env bash
+ set -euo pipefail
+ port="${FW_BROWSER_SMOKE_PORT:-2819}"
+ echo "Serving fw-browser smoke page at http://127.0.0.1:${port}/smoke.html"
+ echo "Success: page shows ok and documentElement.dataset.smoke is 'ok'."
+ cd lp-fw/fw-browser/www
+ python3 -m http.server "${port}" --bind 127.0.0.1
+
+# ============================================================================
+# Studio web app
+# ============================================================================
+
+studio-web-dev-build: install-wasm32-target
+ #!/usr/bin/env bash
+ set -euo pipefail
+ echo "Building fw-browser for wasm32 debug..."
+ cargo build -p fw-browser --target wasm32-unknown-unknown
+ echo "Building lpa-studio-web for wasm32 debug with stories..."
+ cargo build -p lpa-studio-web --target wasm32-unknown-unknown --features stories
+ if ! command -v wasm-bindgen >/dev/null 2>&1; then
+ echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114"
+ exit 1
+ fi
+ echo "Generating fw-browser debug JS glue..."
+ wasm-bindgen target/wasm32-unknown-unknown/debug/fw_browser.wasm \
+ --out-dir lp-fw/fw-browser/www/pkg --target web
+ echo "Generating Studio web debug JS glue..."
+ mkdir -p lp-app/lpa-studio-web/public/pkg
+ wasm-bindgen target/wasm32-unknown-unknown/debug/lpa-studio-web.wasm \
+ --out-dir lp-app/lpa-studio-web/public/pkg --target web
+ echo "Copying fw-browser wasm artifacts..."
+ cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lpa-studio-web/public/pkg/fw_browser.js
+ cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lpa-studio-web/public/pkg/fw_browser_bg.wasm
+ echo "Artifacts: lp-app/lpa-studio-web/public/ (debug build)"
+
+studio-story-pngs: studio-web-dev-build
+ #!/usr/bin/env bash
+ set -euo pipefail
+ node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs
+
+studio-story-baselines: studio-web-dev-build
+ #!/usr/bin/env bash
+ set -euo pipefail
+ node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs baselines
+
+studio-story-check: studio-web-dev-build
+ #!/usr/bin/env bash
+ set -euo pipefail
+ node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs check
+
+studio-story-baselines-if-needed:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ tracked="$(git diff --name-only HEAD -- \
+ lp-app/lpa-studio-web \
+ ':!lp-app/lpa-studio-web/public/**' \
+ ':!lp-app/lpa-studio-web/story-images/**')"
+ untracked="$(git ls-files --others --exclude-standard -- lp-app/lpa-studio-web \
+ | grep -v '^lp-app/lpa-studio-web/public/' \
+ | grep -v '^lp-app/lpa-studio-web/story-images/' \
+ || true)"
+ changed="$(printf '%s\n%s\n' "$tracked" "$untracked" | sed '/^$/d' | sort -u)"
+ if [[ -z "$changed" ]]; then
+ echo "No Studio UI source changes; skipping story baseline generation."
+ exit 0
+ fi
+ echo "Studio UI source changed; updating story baselines:"
+ printf '%s\n' "$changed" | sed 's/^/ /'
+ just studio-story-baselines
+
+studio-dev: studio-web-dev-build
+ #!/usr/bin/env bash
+ set -euo pipefail
+ port="${STUDIO_WEB_PORT:-2820}"
+ echo "Serving LightPlayer Studio dev build at http://127.0.0.1:${port}/"
+ echo "Storybook: http://127.0.0.1:${port}/#/stories"
+ echo "Re-run just studio-dev after Rust changes; generated artifacts are ignored."
+ cd lp-app/lpa-studio-web/public
+ python3 -m http.server "${port}" --bind 127.0.0.1
+
+studio-firmware-package-esp32c6: install-rv32-target
+ #!/usr/bin/env bash
+ set -euo pipefail
+ if ! command -v espflash >/dev/null 2>&1; then
+ echo "espflash not found. Install it before packaging Studio firmware assets."
+ exit 1
+ fi
+
+ firmware_id="lightplayer-esp32c6-server"
+ display_name="LightPlayer ESP32-C6 server firmware"
+ features="esp32c6,server"
+ out_dir="lp-app/lpa-studio-web/public/firmware/esp32c6"
+ image_name="fw-esp32c6-server-merged.bin"
+ image_file="${out_dir}/${image_name}"
+ manifest_file="${out_dir}/manifest.json"
+
+ echo "Building ${display_name}..."
+ (cd lp-fw/fw-esp32 && cargo build --target {{ rv32_target }} --profile {{ fw_esp32_profile }} --features "${features}")
+
+ mkdir -p "${out_dir}"
+ rm -f "${out_dir}"/*.bin "${manifest_file}"
+
+ echo "Generating browser-flashable merged ESP32-C6 image..."
+ espflash save-image \
+ --chip esp32c6 \
+ --partition-table lp-fw/fw-esp32/partitions.csv \
+ --merge \
+ --skip-padding \
+ {{ fw_esp32_elf }} \
+ "${image_file}"
+
+ size_bytes="$(wc -c < "${image_file}" | tr -d ' ')"
+ sha256="$(shasum -a 256 "${image_file}" | awk '{print $1}')"
+ generated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ source_commit="$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"
+ source_dirty=false
+ if ! git diff --quiet --ignore-submodules -- || ! git diff --cached --quiet --ignore-submodules --; then
+ source_dirty=true
+ fi
+
+ MANIFEST_FIRMWARE_ID="${firmware_id}" \
+ MANIFEST_DISPLAY_NAME="${display_name}" \
+ MANIFEST_TARGET="{{ rv32_target }}" \
+ MANIFEST_PROFILE="{{ fw_esp32_profile }}" \
+ MANIFEST_SOURCE_COMMIT="${source_commit}" \
+ MANIFEST_SOURCE_DIRTY="${source_dirty}" \
+ MANIFEST_GENERATED_AT="${generated_at}" \
+ MANIFEST_IMAGE_PATH="${image_name}" \
+ MANIFEST_IMAGE_SIZE="${size_bytes}" \
+ MANIFEST_IMAGE_SHA256="${sha256}" \
+ node lp-app/lpa-studio-web/scripts/studio-firmware-manifest.mjs "${manifest_file}"
+ echo "Firmware manifest: ${manifest_file}"
+ echo "Firmware image: ${image_file} (${size_bytes} bytes, sha256=${sha256})"
+
+studio-web-build: install-wasm32-target fw-browser-build studio-firmware-package-esp32c6
+ #!/usr/bin/env bash
+ set -euo pipefail
+ echo "Building lpa-studio-web for wasm32..."
+ cargo build -p lpa-studio-web --target wasm32-unknown-unknown --release
+ if ! command -v wasm-bindgen >/dev/null 2>&1; then
+ echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114"
+ exit 1
+ fi
+ echo "Generating Studio web JS glue..."
+ mkdir -p lp-app/lpa-studio-web/public/pkg
+ wasm-bindgen target/wasm32-unknown-unknown/release/lpa-studio-web.wasm \
+ --out-dir lp-app/lpa-studio-web/public/pkg --target web
+ echo "Copying fw-browser wasm artifacts..."
+ cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lpa-studio-web/public/pkg/fw_browser.js
+ cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lpa-studio-web/public/pkg/fw_browser_bg.wasm
+ echo "Artifacts: lp-app/lpa-studio-web/public/ (index.html, pkg/)"
+
+# Build a clean GitHub Pages artifact for Studio.
+studio-web-deploy-dir channel="local" out_dir="target/pages/studio" domain="":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ just studio-web-build
+ args=(--kind studio --channel "{{ channel }}" --out "{{ out_dir }}")
+ if [[ -n "{{ domain }}" ]]; then
+ args+=(--domain "{{ domain }}")
+ fi
+ node scripts/pages/prepare-pages-artifact.mjs "${args[@]}"
+
+# Smoke-check the staged Studio Pages artifact.
+studio-web-smoke out_dir="target/pages/studio":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ node scripts/pages/static-site-smoke.mjs \
+ --kind studio \
+ --dir "{{ out_dir }}" \
+ --port "${STUDIO_WEB_SMOKE_PORT:-2830}" \
+ --server "${PAGES_SMOKE_SERVER:-required}"
+
+studio-web: studio-web-build
+ #!/usr/bin/env bash
+ set -euo pipefail
+ port="${STUDIO_WEB_PORT:-2820}"
+ echo "Serving LightPlayer Studio at http://127.0.0.1:${port}/"
+ cd lp-app/lpa-studio-web/public
+ python3 -m http.server "${port}" --bind 127.0.0.1
+
# ============================================================================
# Build commands - Workspace-wide
# ============================================================================
@@ -405,11 +637,6 @@ merge: check
demo example="basic":
cd lp-cli && cargo run -- dev ../examples/{{ example }}
-# Run firmware on ESP32-C6 device
-# Path to fw-esp32 ELF (release-esp32 profile)
-
-fw_esp32_elf := "target/" + rv32_target + "/" + fw_esp32_profile + "/fw-esp32"
-
# Requires: ESP32-C6 device connected via USB. Builds the default lps-glsl frontend path.
# Usage: just demo-esp32c6-host [example-name]
demo-esp32c6-host example="basic": install-rv32-target
diff --git a/lp-app/README.md b/lp-app/README.md
index e6b811f17..76a3a6aae 100644
--- a/lp-app/README.md
+++ b/lp-app/README.md
@@ -17,6 +17,12 @@ logic.
projects, and serving the `lpc-wire` API over app-provided transports.
- `lpa-client` — client-side transport/API layer for talking to a LightPlayer
server or firmware target.
+- `lpa-link` — low-level endpoint/link layer for discovery, status,
+ management, diagnostics, logs, and opening server/client connections.
+- `lpa-studio-ux` — UI-independent Studio UX/controller layer. It owns
+ lower-level services and exposes Studio views, node states, typed actions,
+ logs, and project summaries to UI shells.
+- `lpa-studio-web` — static Dioxus browser shell for the first Studio UI slice.
- `web-demo` — browser demo and tooling for the shader pipeline.
## Boundary
diff --git a/lp-app/lpa-client/Cargo.toml b/lp-app/lpa-client/Cargo.toml
index f4bf97156..3d3253171 100644
--- a/lp-app/lpa-client/Cargo.toml
+++ b/lp-app/lpa-client/Cargo.toml
@@ -9,24 +9,32 @@ rust-version.workspace = true
[dependencies]
lpc-model = { path = "../../lp-core/lpc-model" }
lpc-wire = { path = "../../lp-core/lpc-wire" }
-lpc-shared = { path = "../../lp-core/lpc-shared" }
-tokio = { version = "1", features = ["full"] }
-anyhow = "1"
+lpc-shared = { path = "../../lp-core/lpc-shared", optional = true }
+tokio = { version = "1", features = ["full"], optional = true }
+anyhow = { version = "1", optional = true }
tokio-tungstenite = { version = "0.21", optional = true }
-futures-util = "0.3"
+futures-util = { version = "0.3", optional = true }
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
lp-riscv-elf = { path = "../../lp-riscv/lp-riscv-elf", optional = true, features = ["std"] }
lp-riscv-emu = { path = "../../lp-riscv/lp-riscv-emu", optional = true, default-features = false }
-log = { workspace = true, features = ["std"] }
+log = { workspace = true, features = ["std"], optional = true }
serialport = { version = "4.8", optional = true }
hashbrown = { workspace = true, optional = true }
[features]
-default = []
-serial = ["lp-riscv-elf", "lp-riscv-emu", "lp-riscv-emu/std", "hashbrown", "serialport"]
-emu = ["lp-riscv-elf", "lp-riscv-emu", "lp-riscv-emu/std", "hashbrown"]
-ws = ["tokio-tungstenite"]
+default = ["host"]
+host = ["dep:anyhow", "dep:futures-util", "dep:log", "dep:lpc-shared", "dep:tokio"]
+serial = [
+ "host",
+ "lp-riscv-elf",
+ "lp-riscv-emu",
+ "lp-riscv-emu/std",
+ "hashbrown",
+ "serialport",
+]
+emu = ["host", "lp-riscv-elf", "lp-riscv-emu", "lp-riscv-emu/std", "hashbrown"]
+ws = ["host", "tokio-tungstenite"]
[dev-dependencies]
lp-riscv-elf = { path = "../../lp-riscv/lp-riscv-elf", features = ["std"] }
diff --git a/lp-app/lpa-client/README.md b/lp-app/lpa-client/README.md
index d010a34a7..708bed202 100644
--- a/lp-app/lpa-client/README.md
+++ b/lp-app/lpa-client/README.md
@@ -1,11 +1,88 @@
# lpa-client
-Application client layer for LightPlayer.
+`lpa-client` owns the typed client protocol for talking to a running
+LightPlayer `lp-server`.
-This crate owns client-side transports and request helpers for talking to a
-LightPlayer server or firmware target. It is app-facing: websocket, serial,
-emulator, and local-server client plumbing belong here.
+The crate is split into two layers:
-It uses `lpc-wire` for messages and may use `lpc-view` in tests or callers to
-maintain a local view of one engine, but it should not own core engine state or
-source/wire type definitions.
+- `LpClient` is the portable protocol client. It owns request ids,
+ response correlation, server errors, heartbeats/log events, and typed
+ filesystem/project/overlay operations. It depends on a small `ClientIo` trait
+ and does not require Tokio or `Send`.
+- Host adapters provide the current native ergonomics: cloneable shared
+ transports, Tokio timeouts, serial/websocket/local transports, and CLI-style
+ heartbeat/log rendering.
+
+This keeps Studio web, CLI, host runtimes, and future agents on one protocol
+model while allowing each runtime to bind its own I/O.
+
+## Feature Model
+
+| Feature | Purpose |
+|---|---|
+| `default` | Enables `host` for existing native callers. |
+| `host` | Tokio/shared transport adapter, local in-memory transport, host specifier parsing, logging, and `TokioLpClient`. |
+| `serial` | Host serial transport for ESP32/emulator-style JSON-lines links. Implies `host`. |
+| `emu` | Emulator serial transport support. Implies `host`. |
+| `ws` | Host websocket transport. Implies `host`. |
+
+Portable/browser-oriented consumers should depend on the core without defaults:
+
+```toml
+lpa-client = { path = "../lpa-client", default-features = false }
+```
+
+The core compile check is:
+
+```bash
+cargo check -p lpa-client --target wasm32-unknown-unknown --no-default-features
+```
+
+## Important Types
+
+- `ClientIo`: runtime-neutral send/receive/close trait for `lpc-wire` messages.
+- `LpClient`: typed protocol client over any `ClientIo`.
+- `ClientOutcome`: operation result plus protocol events observed while
+ waiting for the correlated response.
+- `ClientEvent`: heartbeat/log/uncorrelated-response events surfaced to the
+ caller.
+- `ProjectDeployFile`: one project file for shared stop/write/load deploy
+ helpers.
+- `TokioLpClient`: host wrapper that preserves the CLI/native shared-client API.
+- `ClientTransport`: host-only Tokio transport trait used by native providers.
+
+## Project Deploy Semantics
+
+Server-protocol project deploys should use this crate rather than open-coding
+request sequences. The shared deploy flow is currently:
+
+1. `StopAllProjects`
+2. write files under `/projects/{project_id}/...`
+3. `LoadProject { path: "projects/{project_id}" }`
+
+That ordering avoids the ESP32 trying to run multiple loaded projects during a
+replace-in-place upload. Direct bootloader/raw filesystem image access is not a
+server-protocol deploy; it belongs below this layer in `lpa-link` management.
+
+Use `deploy_project_files` for initial upload/load flows such as CLI upload,
+CLI dev startup, firmware demo checks, and browser hardware demo loading. Use
+`push_project_files` only when the caller intentionally wants write-only sync,
+such as an already-loaded file-watch update.
+
+## Relationship To lpa-link
+
+`lpa-link` owns device/runtime discovery, endpoint status, raw logs,
+diagnostics, reset, flashing, and raw filesystem access. When a link is
+connected to a running `lp-server`, it exposes a server connection that callers
+can wrap with this crate.
+
+Keep server protocol semantics here. Keep low-level device management in
+`lpa-link`.
+
+## Validation
+
+```bash
+cargo check -p lpa-client
+cargo test -p lpa-client
+cargo check -p lpa-client --target wasm32-unknown-unknown --no-default-features
+```
diff --git a/lp-app/lpa-client/src/client.rs b/lp-app/lpa-client/src/client.rs
index 4ac0bc95e..21624214f 100644
--- a/lp-app/lpa-client/src/client.rs
+++ b/lp-app/lpa-client/src/client.rs
@@ -1,603 +1,434 @@
-//! Standalone LpClient for communicating with LpServer
-//!
-//! Provides async methods for filesystem and project operations.
+//! Portable LightPlayer server protocol client.
-use anyhow::{Error, Result};
use lpc_model::{LpPath, LpPathBuf};
use lpc_wire::{
- ProjectReadRequest, ProjectReadResponse, WireOverlayCommitRequest, WireOverlayCommitResponse,
- WireOverlayMutationRequest, WireOverlayMutationResponse, WireOverlayReadRequest,
- WireOverlayReadResponse, WireProjectCommand, WireProjectCommandResponse,
- WireProjectHandle as ProjectHandle, WireProjectInventoryReadRequest,
- WireProjectInventoryReadResponse, WireServerMessage,
- messages::{ClientMessage, ClientRequest},
- server::{AvailableProject, FsResponse, LoadedProject, ServerMsgBody},
+ ClientMessage, ClientRequest, FsRequest, ProjectReadRequest, ProjectReadResponse,
+ WireOverlayCommitRequest, WireOverlayCommitResponse, WireOverlayMutationRequest,
+ WireOverlayMutationResponse, WireOverlayReadRequest, WireOverlayReadResponse,
+ WireProjectCommand, WireProjectCommandResponse, WireProjectHandle,
+ WireProjectInventoryReadRequest, WireProjectInventoryReadResponse, WireServerMessage,
+ WireServerMsgBody,
+ server::{AvailableProject, FsResponse, LoadedProject},
};
-use std::sync::Arc;
-use std::sync::atomic::{AtomicU64, Ordering};
-use std::time::Duration;
-use tokio::time::timeout;
-use crate::transport::ClientTransport;
+use crate::client_error::{ClientError, ClientResult};
+use crate::client_event::ClientEvent;
+use crate::client_io::ClientIo;
+use crate::project_deploy::{
+ ProjectDeployFile, project_deploy_requests, project_write_requests,
+ validate_project_deploy_response,
+};
+use crate::protocol_session::{ProtocolSession, ResponseDisposition};
-/// Standalone client for communicating with LpServer
-///
-/// Provides typed async methods for filesystem and project operations.
-/// Uses an async `ClientTransport` for communication.
-pub struct LpClient {
- /// Transport wrapped in Arc for sharing across async tasks
- transport: Arc>>,
- /// Next request ID to use
- next_request_id: Arc,
+/// Result value plus protocol events observed while waiting for it.
+#[derive(Debug)]
+pub struct ClientOutcome {
+ pub value: T,
+ pub events: Vec,
}
-impl Clone for LpClient {
- fn clone(&self) -> Self {
- Self {
- transport: Arc::clone(&self.transport),
- next_request_id: Arc::clone(&self.next_request_id),
+impl ClientOutcome {
+ pub fn new(value: T, events: Vec) -> Self {
+ Self { value, events }
+ }
+
+ pub fn map(self, f: impl FnOnce(T) -> U) -> ClientOutcome {
+ ClientOutcome {
+ value: f(self.value),
+ events: self.events,
}
}
+
+ pub fn into_value(self) -> T {
+ self.value
+ }
+}
+
+/// Runtime-neutral client for communicating with `LpServer`.
+///
+/// The core client owns request ids, response correlation, server errors, and
+/// typed server operations. It does not require Tokio or `Send`; host/native
+/// code should use `TokioLpClient` when it wants sharing, timeouts, and current
+/// CLI ergonomics.
+pub struct LpClient {
+ io: Io,
+ protocol: ProtocolSession,
}
-impl LpClient {
- /// Create a new LpClient with the given transport
- ///
- /// # Arguments
- ///
- /// * `transport` - The client transport (will be wrapped in Arc)
- ///
- /// # Returns
- ///
- /// * `Self` - The client
- #[allow(dead_code, reason = "Will be used in tests and other contexts")]
- pub fn new(transport: Box) -> Self {
+impl LpClient
+where
+ Io: ClientIo,
+{
+ pub fn new(io: Io) -> Self {
Self {
- transport: Arc::new(tokio::sync::Mutex::new(transport)),
- next_request_id: Arc::new(AtomicU64::new(1)),
+ io,
+ protocol: ProtocolSession::new(),
}
}
- /// Create a new LpClient with a shared transport
- ///
- /// # Arguments
- ///
- /// * `transport` - Shared transport (Arc>>)
- ///
- /// # Returns
- ///
- /// * `Self` - The client
- pub fn new_shared(transport: Arc>>) -> Self {
- Self {
- transport,
- next_request_id: Arc::new(AtomicU64::new(1)),
- }
+ pub fn into_io(self) -> Io {
+ self.io
}
- /// Send a request and wait for the response
- ///
- /// Helper method that generates a request ID, sends the request, and waits for the response.
- /// Correlates messages by ID to handle heartbeats and other interstitial messages.
- /// If the server returns an Error response, converts it to an Err.
- async fn send_request(&self, request: ClientRequest) -> Result {
- let id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
- let msg = ClientMessage { id, msg: request };
+ pub async fn close(&mut self) -> ClientResult> {
+ self.io.close().await.map_err(ClientError::from)?;
+ Ok(ClientOutcome::new((), Vec::new()))
+ }
- // Lock transport and send
- let mut transport = self.transport.lock().await;
- transport
- .send(msg)
+ pub async fn send_request(
+ &mut self,
+ request: ClientRequest,
+ ) -> ClientResult> {
+ let request_id = self.protocol.next_request_id();
+ self.io
+ .send(ClientMessage {
+ id: request_id,
+ msg: request,
+ })
.await
- .map_err(|e| Error::msg(format!("Transport error: {e}")))?;
-
- // Wait for response with matching ID (with timeout to avoid deadlock if server
- // never receives our request, e.g. host->device serial direction broken)
- // ESP32 compile and other heavy ops can take 30s+, so allow 60s
- const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
-
- let wait_response = async {
- loop {
- let response = transport
- .receive()
- .await
- .map_err(|e| Error::msg(format!("Transport error: {e}")))?;
-
- if response.id == id {
- if let ServerMsgBody::Error { error } = &response.msg {
- return Err(Error::msg(error.clone()));
+ .map_err(ClientError::from)?;
+
+ let mut events = Vec::new();
+ loop {
+ let response = self.io.receive().await.map_err(ClientError::from)?;
+ match self.protocol.response_disposition(&response, request_id) {
+ ResponseDisposition::Matched => {
+ if let WireServerMsgBody::Error { error } = &response.msg {
+ return Err(ClientError::Server(error.clone()));
}
- return Ok(response);
+ return Ok(ClientOutcome::new(response, events));
}
-
- if response.id == 0 {
- if let ServerMsgBody::Heartbeat {
- fps,
- frame_count,
- loaded_projects,
- uptime_ms,
- memory,
- } = &response.msg
- {
- Self::display_heartbeat(
- fps,
- *frame_count,
- loaded_projects.as_slice(),
- *uptime_ms,
- memory,
- );
+ ResponseDisposition::Unsolicited => {
+ if let Some(event) = ClientEvent::from_unsolicited_message(response) {
+ events.push(event);
}
- continue;
}
-
- log::warn!(
- "Received non-correlated message (id: {}, expected: {})",
- response.id,
- id
- );
+ ResponseDisposition::Uncorrelated {
+ response_id,
+ expected_id,
+ } => events.push(ClientEvent::UncorrelatedResponse {
+ response_id,
+ expected_id,
+ }),
}
- };
-
- match timeout(REQUEST_TIMEOUT, wait_response).await {
- Ok(Ok(response)) => Ok(response),
- Ok(Err(e)) => Err(e),
- Err(_) => Err(Error::msg(
- "Request timed out - server may not be receiving messages (check host->device serial)",
- )),
}
}
- /// Display heartbeat with colors and memory bar chart
- fn display_heartbeat(
- fps: &lpc_wire::server::SampleStats,
- _frame_count: u64,
- loaded_projects: &[lpc_wire::server::LoadedProject],
- uptime_ms: u64,
- memory: &Option,
- ) {
- const BOLD: &str = "\x1b[1m";
- const DIM: &str = "\x1b[90m";
- const CYAN: &str = "\x1b[36m";
- const GREEN: &str = "\x1b[32m";
- const YELLOW: &str = "\x1b[33m";
- const RED: &str = "\x1b[31m";
- const RESET: &str = "\x1b[0m";
-
- let uptime_secs = uptime_ms as f64 / 1000.0;
- let _projects_str = if loaded_projects.is_empty() {
- format!("{DIM}none{RESET}")
- } else {
- loaded_projects
- .iter()
- .map(|p| {
- p.path
- .file_name()
- .map(|n| n.to_string())
- .unwrap_or_else(|| p.path.as_str().to_string())
- })
- .collect::>()
- .join(", ")
- };
-
- let fps_color = if fps.avg >= 50.0 {
- GREEN
- } else if fps.avg >= 20.0 {
- YELLOW
- } else {
- RED
- };
-
- let mut line = format!(
- "{BOLD}{CYAN}[server]{RESET} {fps_color}FPS {:.0}{RESET} avg (σ{:.1} {:.0}-{:.0}) {DIM}|{RESET} \
- {DIM}Uptime {uptime_secs:.1}s{RESET}",
- fps.avg, fps.sdev, fps.min, fps.max
- );
-
- if let Some(mem) = memory {
- let total = mem.total_bytes as f32;
- let used_pct = if total > 0.0 {
- (mem.used_bytes as f32 / total) * 100.0
- } else {
- 0.0
- };
- let free_pct = 100.0 - used_pct;
-
- const BAR_WIDTH: usize = 16;
- let filled = if total > 0.0 {
- ((mem.used_bytes as f32 / total) * BAR_WIDTH as f32).round() as usize
- } else {
- 0
- };
- let filled = filled.min(BAR_WIDTH);
-
- let (bar_fill_color, bar_empty_color) = if free_pct >= 40.0 {
- (GREEN, DIM)
- } else if free_pct >= 15.0 {
- (YELLOW, DIM)
- } else {
- (RED, DIM)
- };
-
- let bar: String = (0..BAR_WIDTH)
- .map(|i| {
- if i < filled {
- format!("{bar_fill_color}█{RESET}")
- } else {
- format!("{bar_empty_color}░{RESET}")
- }
- })
- .collect();
-
- let free_kb = mem.free_bytes / 1024;
- let total_kb = mem.total_bytes / 1024;
-
- line.push_str(&format!(
- " {DIM}|{RESET} [{bar}] {bar_fill_color}{used_pct:.0}%{RESET} ({free_kb}k free / {total_kb}k total)"
- ));
- }
-
- eprintln!("{line}");
- }
-
- /// Read a file from the server filesystem
- ///
- /// # Arguments
- ///
- /// * `path` - Path to the file (relative to server root)
- ///
- /// # Returns
- ///
- /// * `Ok(Vec)` if the file was read successfully
- /// * `Err` if reading failed or transport error occurred
- pub async fn fs_read(&self, path: &LpPath) -> Result> {
- let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::Read {
- path: path.to_path_buf(),
- });
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::Filesystem(FsResponse::Read { data, error, .. }) => {
- if let Some(err) = error {
- return Err(Error::msg(format!("Server error: {err}")));
+ pub async fn fs_read(&mut self, path: &LpPath) -> ClientResult>> {
+ let response = self
+ .send_request(ClientRequest::Filesystem(FsRequest::Read {
+ path: path.to_path_buf(),
+ }))
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::Filesystem(FsResponse::Read { data, error, .. }) => {
+ if let Some(error) = error {
+ return Err(ClientError::Server(error));
}
- data.ok_or_else(|| Error::msg("No data in read response"))
+ data.map(|data| ClientOutcome::new(data, events))
+ .ok_or_else(|| ClientError::Protocol("no data in read response".to_string()))
}
- _ => Err(Error::msg(format!(
- "Unexpected response type for fs_read: {:?}",
- response.msg
- ))),
+ other => Err(ClientError::unexpected_response("fs.read", other)),
}
}
- /// Write a file to the server filesystem
- ///
- /// # Arguments
- ///
- /// * `path` - Path to the file (relative to server root)
- /// * `data` - File contents to write
- ///
- /// # Returns
- ///
- /// * `Ok(())` if the file was written successfully
- /// * `Err` if writing failed or transport error occurred
- pub async fn fs_write(&self, path: &LpPath, data: Vec) -> Result<()> {
- let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::Write {
- path: path.to_path_buf(),
- data,
- });
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::Filesystem(FsResponse::Write { error, .. }) => {
- if let Some(err) = error {
- return Err(Error::msg(format!("Server error: {err}")));
+ pub async fn fs_write(
+ &mut self,
+ path: &LpPath,
+ data: Vec,
+ ) -> ClientResult> {
+ let response = self
+ .send_request(ClientRequest::Filesystem(FsRequest::Write {
+ path: path.to_path_buf(),
+ data,
+ }))
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::Filesystem(FsResponse::Write { error, .. }) => {
+ if let Some(error) = error {
+ return Err(ClientError::Server(error));
}
- Ok(())
+ Ok(ClientOutcome::new((), events))
}
- _ => Err(Error::msg(format!(
- "Unexpected response type for fs_write: {:?}",
- response.msg
- ))),
+ other => Err(ClientError::unexpected_response("fs.write", other)),
}
}
- /// Delete a file from the server filesystem
- ///
- /// # Arguments
- ///
- /// * `path` - Path to the file (relative to server root)
- ///
- /// # Returns
- ///
- /// * `Ok(())` if the file was deleted successfully
- /// * `Err` if deletion failed or transport error occurred
- pub async fn fs_delete_file(&self, path: &LpPath) -> Result<()> {
- let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::DeleteFile {
- path: path.to_path_buf(),
- });
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::Filesystem(FsResponse::DeleteFile { error, .. }) => {
- if let Some(err) = error {
- return Err(Error::msg(format!("Server error: {err}")));
+ pub async fn fs_delete_file(&mut self, path: &LpPath) -> ClientResult> {
+ let response = self
+ .send_request(ClientRequest::Filesystem(FsRequest::DeleteFile {
+ path: path.to_path_buf(),
+ }))
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::Filesystem(FsResponse::DeleteFile { error, .. }) => {
+ if let Some(error) = error {
+ return Err(ClientError::Server(error));
}
- Ok(())
+ Ok(ClientOutcome::new((), events))
}
- _ => Err(Error::msg(format!(
- "Unexpected response type for fs_delete_file: {:?}",
- response.msg
- ))),
+ other => Err(ClientError::unexpected_response("fs.delete_file", other)),
}
}
- /// List directory contents from the server filesystem
- ///
- /// # Arguments
- ///
- /// * `path` - Path to the directory (relative to server root)
- /// * `recursive` - Whether to list recursively
- ///
- /// # Returns
- ///
- /// * `Ok(Vec)` - List of file/directory paths
- /// * `Err` if listing failed or transport error occurred
- pub async fn fs_list_dir(&self, path: &LpPath, recursive: bool) -> Result> {
- let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::ListDir {
- path: path.to_path_buf(),
- recursive,
- });
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::Filesystem(FsResponse::ListDir { entries, error, .. }) => {
- if let Some(err) = error {
- return Err(Error::msg(format!("Server error: {err}")));
+ pub async fn fs_list_dir(
+ &mut self,
+ path: &LpPath,
+ recursive: bool,
+ ) -> ClientResult>> {
+ let response = self
+ .send_request(ClientRequest::Filesystem(FsRequest::ListDir {
+ path: path.to_path_buf(),
+ recursive,
+ }))
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::Filesystem(FsResponse::ListDir { entries, error, .. }) => {
+ if let Some(error) = error {
+ return Err(ClientError::Server(error));
}
- Ok(entries)
+ Ok(ClientOutcome::new(entries, events))
}
- _ => Err(Error::msg(format!(
- "Unexpected response type for fs_list_dir: {:?}",
- response.msg
- ))),
+ other => Err(ClientError::unexpected_response("fs.list_dir", other)),
}
}
- /// Load a project on the server
- ///
- /// # Arguments
- ///
- /// * `path` - Path to the project file (relative to server root)
- ///
- /// # Returns
- ///
- /// * `Ok(ProjectHandle)` if the project was loaded successfully
- /// * `Err` if loading failed or transport error occurred
- pub async fn project_load(&self, path: &str) -> Result {
- let request = ClientRequest::LoadProject {
- path: path.to_string(),
- };
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::LoadProject { handle } => Ok(handle),
- _ => Err(Error::msg(format!(
- "Unexpected response type for project_load: {:?}",
- response.msg
- ))),
+ pub async fn project_load(
+ &mut self,
+ path: &str,
+ ) -> ClientResult> {
+ let response = self
+ .send_request(ClientRequest::LoadProject {
+ path: path.to_string(),
+ })
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::LoadProject { handle } => Ok(ClientOutcome::new(handle, events)),
+ other => Err(ClientError::unexpected_response("project.load", other)),
}
}
- /// Unload a project on the server
- ///
- /// # Arguments
- ///
- /// * `handle` - Project handle to unload
- ///
- /// # Returns
- ///
- /// * `Ok(())` if the project was unloaded successfully
- /// * `Err` if unloading failed or transport error occurred
- #[allow(dead_code, reason = "Will be used in future commands")]
- pub async fn project_unload(&self, handle: ProjectHandle) -> Result<()> {
- let request = ClientRequest::UnloadProject { handle };
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::UnloadProject => Ok(()),
- _ => Err(Error::msg(format!(
- "Unexpected response type for project_unload: {:?}",
- response.msg
- ))),
+ pub async fn project_unload(
+ &mut self,
+ handle: WireProjectHandle,
+ ) -> ClientResult> {
+ let response = self
+ .send_request(ClientRequest::UnloadProject { handle })
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::UnloadProject => Ok(ClientOutcome::new((), events)),
+ other => Err(ClientError::unexpected_response("project.unload", other)),
}
}
- /// Read the current project state using the stateless project read API.
pub async fn project_read(
- &self,
- handle: ProjectHandle,
+ &mut self,
+ handle: WireProjectHandle,
read: ProjectReadRequest,
- ) -> Result {
- let request = ClientRequest::ProjectRequest {
- handle,
- request: read,
- };
-
- let response = self.send_request(request).await?;
- match response.msg {
- ServerMsgBody::ProjectRequest { response } => Ok(response),
- _ => Err(Error::msg(format!(
- "Unexpected response type for project_read: {:?}",
- response.msg
- ))),
+ ) -> ClientResult> {
+ let response = self
+ .send_request(ClientRequest::ProjectRequest {
+ handle,
+ request: read,
+ })
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::ProjectRequest { response } => {
+ Ok(ClientOutcome::new(response, events))
+ }
+ other => Err(ClientError::unexpected_response("project.read", other)),
}
}
- /// Read the standard developer/debug project view.
pub async fn project_read_default_debug(
- &self,
- handle: ProjectHandle,
- ) -> Result {
+ &mut self,
+ handle: WireProjectHandle,
+ ) -> ClientResult> {
self.project_read(handle, ProjectReadRequest::default_debug(None))
.await
}
pub async fn project_command(
- &self,
- handle: ProjectHandle,
+ &mut self,
+ handle: WireProjectHandle,
command: WireProjectCommand,
- ) -> Result {
- let request = ClientRequest::ProjectCommand { handle, command };
- let response = self.send_request(request).await?;
- match response.msg {
- ServerMsgBody::ProjectCommand { response } => Ok(response),
- _ => Err(Error::msg(format!(
- "Unexpected response type for project_command: {:?}",
- response.msg
- ))),
+ ) -> ClientResult> {
+ let response = self
+ .send_request(ClientRequest::ProjectCommand { handle, command })
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::ProjectCommand { response } => {
+ Ok(ClientOutcome::new(response, events))
+ }
+ other => Err(ClientError::unexpected_response("project.command", other)),
}
}
pub async fn project_overlay_read(
- &self,
- handle: ProjectHandle,
- ) -> Result {
- match self
+ &mut self,
+ handle: WireProjectHandle,
+ ) -> ClientResult> {
+ let response = self
.project_command(
handle,
WireProjectCommand::ReadOverlay {
request: WireOverlayReadRequest,
},
)
- .await?
- {
- WireProjectCommandResponse::ReadOverlay { response } => Ok(response),
- other => Err(Error::msg(format!(
- "Unexpected project command response for overlay read: {other:?}"
- ))),
+ .await?;
+ match response.value {
+ WireProjectCommandResponse::ReadOverlay { response: value } => {
+ Ok(ClientOutcome::new(value, response.events))
+ }
+ other => Err(ClientError::unexpected_response(
+ "project.overlay_read",
+ other,
+ )),
}
}
pub async fn project_overlay_mutate(
- &self,
- handle: ProjectHandle,
+ &mut self,
+ handle: WireProjectHandle,
request: WireOverlayMutationRequest,
- ) -> Result {
- match self
+ ) -> ClientResult> {
+ let response = self
.project_command(handle, WireProjectCommand::MutateOverlay { request })
- .await?
- {
- WireProjectCommandResponse::MutateOverlay { response } => Ok(response),
- other => Err(Error::msg(format!(
- "Unexpected project command response for overlay mutate: {other:?}"
- ))),
+ .await?;
+ match response.value {
+ WireProjectCommandResponse::MutateOverlay { response: value } => {
+ Ok(ClientOutcome::new(value, response.events))
+ }
+ other => Err(ClientError::unexpected_response(
+ "project.overlay_mutate",
+ other,
+ )),
}
}
pub async fn project_overlay_commit(
- &self,
- handle: ProjectHandle,
- ) -> Result {
- match self
+ &mut self,
+ handle: WireProjectHandle,
+ ) -> ClientResult> {
+ let response = self
.project_command(
handle,
WireProjectCommand::CommitOverlay {
request: WireOverlayCommitRequest,
},
)
- .await?
- {
- WireProjectCommandResponse::CommitOverlay { response } => Ok(response),
- other => Err(Error::msg(format!(
- "Unexpected project command response for overlay commit: {other:?}"
- ))),
+ .await?;
+ match response.value {
+ WireProjectCommandResponse::CommitOverlay { response: value } => {
+ Ok(ClientOutcome::new(value, response.events))
+ }
+ other => Err(ClientError::unexpected_response(
+ "project.overlay_commit",
+ other,
+ )),
}
}
pub async fn project_inventory_read(
- &self,
- handle: ProjectHandle,
- ) -> Result {
- match self
+ &mut self,
+ handle: WireProjectHandle,
+ ) -> ClientResult> {
+ let response = self
.project_command(
handle,
WireProjectCommand::ReadInventory {
request: WireProjectInventoryReadRequest,
},
)
- .await?
- {
- WireProjectCommandResponse::ReadInventory { response } => Ok(response),
- other => Err(Error::msg(format!(
- "Unexpected project command response for inventory read: {other:?}"
- ))),
+ .await?;
+ match response.value {
+ WireProjectCommandResponse::ReadInventory { response: value } => {
+ Ok(ClientOutcome::new(value, response.events))
+ }
+ other => Err(ClientError::unexpected_response(
+ "project.inventory_read",
+ other,
+ )),
}
}
- /// List available projects on the server filesystem
- ///
- /// # Returns
- ///
- /// * `Ok(Vec)` - List of available projects
- /// * `Err` if listing failed or transport error occurred
- #[allow(dead_code, reason = "Will be used in future commands")]
- pub async fn project_list_available(&self) -> Result> {
- let request = ClientRequest::ListAvailableProjects;
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::ListAvailableProjects { projects } => Ok(projects),
- _ => Err(Error::msg(format!(
- "Unexpected response type for project_list_available: {:?}",
- response.msg
- ))),
+ pub async fn project_list_available(
+ &mut self,
+ ) -> ClientResult>> {
+ let response = self
+ .send_request(ClientRequest::ListAvailableProjects)
+ .await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::ListAvailableProjects { projects } => {
+ Ok(ClientOutcome::new(projects, events))
+ }
+ other => Err(ClientError::unexpected_response(
+ "project.list_available",
+ other,
+ )),
}
}
- /// List loaded projects on the server
- ///
- /// # Returns
- ///
- /// * `Ok(Vec)` - List of loaded projects
- /// * `Err` if listing failed or transport error occurred
- #[allow(dead_code, reason = "Will be used in future commands")]
- pub async fn project_list_loaded(&self) -> Result> {
- let request = ClientRequest::ListLoadedProjects;
-
- let response = self.send_request(request).await?;
-
- match response.msg {
- ServerMsgBody::ListLoadedProjects { projects } => Ok(projects),
- _ => Err(Error::msg(format!(
- "Unexpected response type for project_list_loaded: {:?}",
- response.msg
- ))),
+ pub async fn project_list_loaded(&mut self) -> ClientResult>> {
+ let response = self.send_request(ClientRequest::ListLoadedProjects).await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::ListLoadedProjects { projects } => {
+ Ok(ClientOutcome::new(projects, events))
+ }
+ other => Err(ClientError::unexpected_response(
+ "project.list_loaded",
+ other,
+ )),
}
}
- /// Stop all loaded projects on the server
- ///
- /// # Returns
- ///
- /// * `Ok(())` if all projects were stopped successfully
- /// * `Err` if the request failed or transport error occurred
- pub async fn stop_all_projects(&self) -> Result<()> {
- let request = ClientRequest::StopAllProjects;
+ pub async fn stop_all_projects(&mut self) -> ClientResult> {
+ let response = self.send_request(ClientRequest::StopAllProjects).await?;
+ let events = response.events;
+ match response.value.msg {
+ WireServerMsgBody::StopAllProjects => Ok(ClientOutcome::new((), events)),
+ other => Err(ClientError::unexpected_response("project.stop_all", other)),
+ }
+ }
- let response = self.send_request(request).await?;
+ pub async fn push_project_files(
+ &mut self,
+ project_id: &str,
+ files: impl IntoIterator- ,
+ ) -> ClientResult> {
+ let mut events = Vec::new();
+ for request in project_write_requests(project_id, files) {
+ let outcome = self.send_request(request.clone()).await?;
+ events.extend(outcome.events);
+ validate_project_deploy_response(&request, &outcome.value.msg)?;
+ }
+ Ok(ClientOutcome::new((), events))
+ }
- match response.msg {
- ServerMsgBody::StopAllProjects => Ok(()),
- _ => Err(Error::msg(format!(
- "Unexpected response type for stop_all_projects: {:?}",
- response.msg
- ))),
+ pub async fn deploy_project_files(
+ &mut self,
+ project_id: &str,
+ files: impl IntoIterator
- ,
+ ) -> ClientResult> {
+ let mut events = Vec::new();
+ let mut handle = None;
+ for request in project_deploy_requests(project_id, files) {
+ let outcome = self.send_request(request.clone()).await?;
+ events.extend(outcome.events);
+ handle = validate_project_deploy_response(&request, &outcome.value.msg)?.or(handle);
}
+ handle
+ .map(|handle| ClientOutcome::new(handle, events))
+ .ok_or_else(|| ClientError::Protocol("project deploy did not load project".into()))
}
}
diff --git a/lp-app/lpa-client/src/client_error.rs b/lp-app/lpa-client/src/client_error.rs
new file mode 100644
index 000000000..d11a65603
--- /dev/null
+++ b/lp-app/lpa-client/src/client_error.rs
@@ -0,0 +1,59 @@
+//! Portable errors for the LightPlayer server client.
+//!
+//! The core client avoids `anyhow` so browser and other non-Tokio runtimes can
+//! preserve structured protocol failures. Host adapters may convert these into
+//! application-local error types at their boundary.
+
+use std::error::Error;
+use std::fmt;
+
+use lpc_wire::TransportError;
+
+pub type ClientResult = Result;
+
+/// Error surfaced by the runtime-neutral `LpClient` core.
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum ClientError {
+ /// The underlying I/O channel failed.
+ Transport(String),
+ /// The server returned an explicit protocol error response.
+ Server(String),
+ /// The received stream violated the expected client protocol.
+ Protocol(String),
+ /// A valid response arrived, but not the one required for the operation.
+ UnexpectedResponse {
+ operation: &'static str,
+ response: String,
+ },
+}
+
+impl ClientError {
+ pub fn unexpected_response(operation: &'static str, response: impl fmt::Debug) -> Self {
+ Self::UnexpectedResponse {
+ operation,
+ response: format!("{response:?}"),
+ }
+ }
+}
+
+impl fmt::Display for ClientError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Transport(message) => write!(f, "transport error: {message}"),
+ Self::Server(message) => write!(f, "server error: {message}"),
+ Self::Protocol(message) => write!(f, "protocol error: {message}"),
+ Self::UnexpectedResponse {
+ operation,
+ response,
+ } => write!(f, "unexpected response for {operation}: {response}"),
+ }
+ }
+}
+
+impl Error for ClientError {}
+
+impl From for ClientError {
+ fn from(error: TransportError) -> Self {
+ Self::Transport(error.to_string())
+ }
+}
diff --git a/lp-app/lpa-client/src/client_event.rs b/lp-app/lpa-client/src/client_event.rs
new file mode 100644
index 000000000..c13589948
--- /dev/null
+++ b/lp-app/lpa-client/src/client_event.rs
@@ -0,0 +1,48 @@
+//! Protocol events observed while waiting for a request response.
+//!
+//! `lp-server` can emit heartbeats and logs between correlated responses. The
+//! portable client returns those events to callers instead of printing or
+//! mutating UI state directly.
+
+use lpc_wire::server::api::LogLevel;
+use lpc_wire::server::{LoadedProject, MemoryStats, SampleStats};
+use lpc_wire::{WireServerMessage, WireServerMsgBody};
+
+/// Side-channel protocol event surfaced by `LpClient`.
+#[derive(Debug)]
+pub enum ClientEvent {
+ /// Periodic server health and performance sample.
+ Heartbeat {
+ fps: SampleStats,
+ frame_count: u64,
+ loaded_projects: Vec,
+ uptime_ms: u64,
+ memory: Option,
+ },
+ /// Firmware/server log line carried by the protocol.
+ Log { level: LogLevel, message: String },
+ /// A response id arrived while another request id was expected.
+ UncorrelatedResponse { response_id: u64, expected_id: u64 },
+}
+
+impl ClientEvent {
+ pub fn from_unsolicited_message(message: WireServerMessage) -> Option {
+ match message.msg {
+ WireServerMsgBody::Heartbeat {
+ fps,
+ frame_count,
+ loaded_projects,
+ uptime_ms,
+ memory,
+ } => Some(Self::Heartbeat {
+ fps,
+ frame_count,
+ loaded_projects,
+ uptime_ms,
+ memory,
+ }),
+ WireServerMsgBody::Log { level, message } => Some(Self::Log { level, message }),
+ _ => None,
+ }
+ }
+}
diff --git a/lp-app/lpa-client/src/client_io.rs b/lp-app/lpa-client/src/client_io.rs
new file mode 100644
index 000000000..87c5b864d
--- /dev/null
+++ b/lp-app/lpa-client/src/client_io.rs
@@ -0,0 +1,34 @@
+use async_trait::async_trait;
+use lpc_wire::{ClientMessage, TransportError, WireServerMessage};
+
+/// Runtime-neutral I/O for the LightPlayer server protocol.
+///
+/// Core `LpClient` code uses this trait instead of depending on Tokio or
+/// requiring `Send`. Host/native adapters can add those requirements at their
+/// boundary.
+#[async_trait(?Send)]
+pub trait ClientIo {
+ async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError>;
+
+ async fn receive(&mut self) -> Result;
+
+ async fn close(&mut self) -> Result<(), TransportError>;
+}
+
+#[async_trait(?Send)]
+impl ClientIo for Box
+where
+ T: ClientIo + ?Sized,
+{
+ async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> {
+ (**self).send(msg).await
+ }
+
+ async fn receive(&mut self) -> Result {
+ (**self).receive().await
+ }
+
+ async fn close(&mut self) -> Result<(), TransportError> {
+ (**self).close().await
+ }
+}
diff --git a/lp-app/lpa-client/src/lib.rs b/lp-app/lpa-client/src/lib.rs
index 281e2cd73..a35f263f4 100644
--- a/lp-app/lpa-client/src/lib.rs
+++ b/lp-app/lpa-client/src/lib.rs
@@ -1,24 +1,45 @@
//! LightPlayer client library.
//!
-//! Provides client-side functionality for communicating with LpServer.
-//! Includes transport implementations and the main LpClient struct.
+//! Provides the typed client-side protocol for communicating with `LpServer`.
+//! The core `LpClient` is runtime-neutral; host transports and the
+//! cloneable Tokio wrapper live behind host-oriented feature flags.
pub mod client;
+pub mod client_error;
+pub mod client_event;
+pub mod client_io;
+#[cfg(feature = "host")]
pub mod local;
+pub mod project_deploy;
+pub mod protocol_session;
+#[cfg(feature = "host")]
pub mod specifier;
+#[cfg(feature = "host")]
+pub mod tokio_client;
+#[cfg(feature = "host")]
pub mod transport;
#[cfg(feature = "emu")]
pub mod transport_emu_serial;
+#[cfg(feature = "serial")]
pub mod transport_serial;
#[cfg(feature = "ws")]
pub mod transport_ws;
// Re-export main types
-pub use client::LpClient;
+pub use client::{ClientOutcome, LpClient};
+pub use client_error::{ClientError, ClientResult};
+pub use client_event::ClientEvent;
+pub use client_io::ClientIo;
+#[cfg(feature = "host")]
pub use local::{
AsyncLocalClientTransport, AsyncLocalServerTransport, create_local_transport_pair,
};
+pub use project_deploy::ProjectDeployFile;
+#[cfg(feature = "host")]
pub use specifier::HostSpecifier;
+#[cfg(feature = "host")]
+pub use tokio_client::{SharedClientTransport, TokioClientIo, TokioLpClient};
+#[cfg(feature = "host")]
pub use transport::ClientTransport;
#[cfg(feature = "ws")]
pub use transport_ws::WebSocketClientTransport;
diff --git a/lp-app/lpa-client/src/project_deploy.rs b/lp-app/lpa-client/src/project_deploy.rs
new file mode 100644
index 000000000..9b2770dfb
--- /dev/null
+++ b/lp-app/lpa-client/src/project_deploy.rs
@@ -0,0 +1,161 @@
+//! Helpers for planning project uploads over the server protocol.
+//!
+//! This module owns the common stop/write/load request order that Studio, CLI,
+//! and future agents should share when they deploy a project through a running
+//! `lp-server`.
+
+use lpc_model::AsLpPathBuf;
+use lpc_wire::{ClientRequest, FsRequest, WireProjectHandle, WireServerMsgBody};
+
+use crate::client_error::{ClientError, ClientResult};
+
+/// One file to write under `/projects/{project_id}`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ProjectDeployFile {
+ relative_path: String,
+ bytes: Vec,
+}
+
+impl ProjectDeployFile {
+ pub fn new(relative_path: impl Into, bytes: impl Into>) -> Self {
+ Self {
+ relative_path: normalize_relative_path(&relative_path.into()),
+ bytes: bytes.into(),
+ }
+ }
+
+ pub fn relative_path(&self) -> &str {
+ &self.relative_path
+ }
+
+ pub fn bytes(&self) -> &[u8] {
+ &self.bytes
+ }
+
+ fn into_write_request(self, project_id: &str) -> ClientRequest {
+ ClientRequest::Filesystem(FsRequest::Write {
+ path: project_file_path(project_id, &self.relative_path).as_path_buf(),
+ data: self.bytes,
+ })
+ }
+}
+
+pub fn project_load_path(project_id: &str) -> String {
+ format!("projects/{project_id}")
+}
+
+/// Build the absolute server filesystem path for a project file.
+pub fn project_file_path(project_id: &str, relative_path: &str) -> String {
+ format!(
+ "/projects/{project_id}/{}",
+ normalize_relative_path(relative_path)
+ )
+}
+
+/// Build write requests without changing project lifecycle.
+pub fn project_write_requests(
+ project_id: &str,
+ files: impl IntoIterator
- ,
+) -> Vec {
+ files
+ .into_iter()
+ .map(|file| file.into_write_request(project_id))
+ .collect()
+}
+
+/// Build the current deploy flow: stop loaded projects, write files, load.
+pub fn project_deploy_requests(
+ project_id: &str,
+ files: impl IntoIterator
- ,
+) -> Vec {
+ let mut requests = Vec::new();
+ requests.push(ClientRequest::StopAllProjects);
+ requests.extend(project_write_requests(project_id, files));
+ requests.push(ClientRequest::LoadProject {
+ path: project_load_path(project_id),
+ });
+ requests
+}
+
+/// Validate one deploy response and return the loaded project handle if present.
+pub fn validate_project_deploy_response(
+ request: &ClientRequest,
+ response: &WireServerMsgBody,
+) -> ClientResult