diff --git a/.github/workflows/_release-cli.yml b/.github/workflows/_release-cli.yml new file mode 100644 index 00000000..5bf24d97 --- /dev/null +++ b/.github/workflows/_release-cli.yml @@ -0,0 +1,99 @@ +name: "[reusable] CLI release" + +# Publishes @kyonru/feather to npm. Called by release-cli.yml, which fires on +# both `cli-v*` (CLI alone) and `v*` (full platform release). + +on: + workflow_call: + +jobs: + npm-cli-release: + runs-on: ubuntu-latest + name: npm CLI publish + permissions: + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install LÖVE + run: | + sudo apt-get update + sudo apt-get install -y love xvfb + + - name: Resolve version from tag + id: get_version + run: | + VERSION="$(bash scripts/release-tag-version.sh "$GITHUB_REF_NAME" --version)" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify CLI package version + run: | + CLI_VERSION="$(node -p "require('./apps/cli/package.json').version")" + if [ "$CLI_VERSION" != "${{ steps.get_version.outputs.version }}" ]; then + echo "Tag version ${{ steps.get_version.outputs.version }} does not match cli/package.json version $CLI_VERSION." + echo "Fix with: bash scripts/set-version.sh cli ${{ steps.get_version.outputs.version }}" + exit 1 + fi + + - name: Fail if CLI package version already exists + run: | + if npm view "@kyonru/feather@${{ steps.get_version.outputs.version }}" version --registry https://registry.npmjs.org >/dev/null 2>&1; then + echo "@kyonru/feather@${{ steps.get_version.outputs.version }} already exists on npm." + exit 1 + fi + + - name: Build CLI + run: pnpm run cli:build + + - name: Run CLI e2e + run: pnpm run test:cli:e2e + + - name: Run Lua e2e + run: pnpm run test:lua:e2e + + - name: Verify npm package contents + run: pnpm --filter @kyonru/feather pack --dry-run + + - name: Publish CLI to npm + run: pnpm --filter @kyonru/feather publish --access public --no-git-checks + + # Standalone Bun binaries. These are the hermetic install path: no Node + # toolchain, one download, which is what a lean CI runner wants. They are + # attached to the CLI's own release so a `cli-v*` patch is pinnable on its + # own — publishing them only on platform tags would have made hermetic + # users wait for a full platform release to pin a CLI fix. + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Build standalone binaries + run: | + pnpm --filter @kyonru/feather run bundle:lua + pnpm --filter @kyonru/feather run build:binary + VERSION="${{ steps.get_version.outputs.version }}" + mkdir -p release-assets + cp apps/cli/bin/feather "release-assets/feather-cli-darwin-arm64-${VERSION}.bin" + cp apps/cli/bin/feather-darwin-x64 "release-assets/feather-cli-darwin-x64-${VERSION}.bin" + cp apps/cli/bin/feather-linux-x64 "release-assets/feather-cli-linux-x64-${VERSION}.bin" + cp apps/cli/bin/feather-win-x64.exe "release-assets/feather-cli-windows-x64-${VERSION}.exe" + + - name: Attach binaries to the release + uses: ncipollo/release-action@v1 + with: + artifacts: release-assets/* + allowUpdates: true + omitBodyDuringUpdate: true + omitNameDuringUpdate: true diff --git a/.github/workflows/release.yml b/.github/workflows/_release-desktop.yml similarity index 53% rename from .github/workflows/release.yml rename to .github/workflows/_release-desktop.yml index a0a944ab..709f97e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/_release-desktop.yml @@ -1,122 +1,50 @@ -name: Feather release +name: "[reusable] Desktop release" + +# Builds and publishes the signed Tauri desktop app for macOS (arm64 + x64), +# Linux, and Windows. Called by release-desktop.yml, which fires on both +# `desktop-v*` (desktop alone) and `v*` (full platform release). on: - push: - tags: - - "*" + workflow_call: jobs: - npm-cli-release: + verify: runs-on: ubuntu-latest - name: npm CLI publish - permissions: - contents: read - id-token: write + name: Verify desktop version + outputs: + version: ${{ steps.get_version.outputs.version }} steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - package-manager-cache: false - - - name: Install dependencies - run: npm ci - - - name: Install LÖVE - run: | - sudo apt-get update - sudo apt-get install -y love xvfb + - uses: actions/checkout@v4 - - name: Get version from tag + - name: Resolve version from tag id: get_version run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="$(bash scripts/release-tag-version.sh "$GITHUB_REF_NAME" --version)" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Verify CLI package version - run: | - CLI_VERSION="$(node -p "require('./cli/package.json').version")" - if [ "$CLI_VERSION" != "${{ steps.get_version.outputs.version }}" ]; then - echo "Tag version ${{ steps.get_version.outputs.version }} does not match cli/package.json version $CLI_VERSION." - exit 1 - fi - - - name: Fail if CLI package version already exists + - name: Verify the desktop trio agrees with the tag run: | - if npm view "@kyonru/feather@${{ steps.get_version.outputs.version }}" version --registry https://registry.npmjs.org >/dev/null 2>&1; then - echo "@kyonru/feather@${{ steps.get_version.outputs.version }} already exists on npm." + VERSION="${{ steps.get_version.outputs.version }}" + PKG="$(node -p "require('./package.json').version")" + TAURI="$(node -p "require('./apps/inspector/src-tauri/tauri.conf.json').version")" + CARGO="$(sed -n '1,/^version = /s/^version = "\(.*\)"/\1/p' apps/inspector/src-tauri/Cargo.toml | head -n 1)" + FAIL=0 + for pair in "package.json:$PKG" "apps/inspector/src-tauri/tauri.conf.json:$TAURI" "apps/inspector/src-tauri/Cargo.toml:$CARGO"; do + FILE="${pair%%:*}" + FOUND="${pair#*:}" + if [ "$FOUND" != "$VERSION" ]; then + echo "$FILE is $FOUND, expected $VERSION" + FAIL=1 + fi + done + if [ "$FAIL" = "1" ]; then + echo "Fix with: bash scripts/set-version.sh desktop $VERSION" exit 1 fi - - name: Build CLI - run: npm run cli:build - - - name: Run CLI e2e - run: npm run test:cli:e2e - - - name: Run Lua e2e - run: npm run test:lua:e2e - - - name: Verify npm package contents - run: npm pack --workspace=cli --dry-run - - - name: Publish CLI to npm - run: npm publish --workspace=cli --access public - - luarocks-release: - runs-on: ubuntu-latest - name: LuaRocks upload - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Fail if changelog entry does not exist - run: grep -q "${{ github.ref_name }}" CHANGELOG.md - - - name: Get version from tag - id: get_version - run: | - VERSION="${GITHUB_REF_NAME#v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: LuaRocks Upload - uses: nvim-neorocks/luarocks-tag-release@v7 - with: - license: Feather License - labels: | - love2d - löve - debug - copy_directories: | - src-lua/feather - summary: Debug & Inspect Tool for LÖVE (love2d) - detailed_description: | - Feather is a debugger tool with a plugin system for Love2D projects. - fail_on_duplicate: true - env: - LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} - - - name: Zip feather folder - run: | - cd src-lua - zip -r ../feather-${{ steps.get_version.outputs.version }}.zip feather/ - - - name: GitHub Release - uses: ncipollo/release-action@v1 - with: - artifacts: | - feather-${{ steps.get_version.outputs.version }}-1.rockspec - feather-${{ steps.get_version.outputs.version }}-1.src.rock - feather-${{ steps.get_version.outputs.version }}.zip - allowUpdates: true - tauri-release: + name: Build desktop + needs: verify permissions: contents: write strategy: @@ -136,6 +64,8 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node uses: actions/setup-node@v4 @@ -155,7 +85,7 @@ jobs: sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - name: Install frontend dependencies - run: npm install + run: pnpm install --frozen-lockfile - name: Import Apple Developer Certificate if: matrix.platform == 'macos-latest' @@ -203,5 +133,6 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} with: + projectPath: apps/inspector tagName: ${{ github.ref_name }} args: ${{ matrix.args }} diff --git a/.github/workflows/_release-runtime.yml b/.github/workflows/_release-runtime.yml new file mode 100644 index 00000000..f0febe15 --- /dev/null +++ b/.github/workflows/_release-runtime.yml @@ -0,0 +1,119 @@ +name: "[reusable] Lua runtime release" + +# Publishes the Lua runtime to LuaRocks and attaches a zip to the GitHub release. +# Called by release-runtime.yml, which fires on both `runtime-v*` (runtime alone) +# and `v*` (full platform release). +# +# LuaRocks uploads are permanent and `fail_on_duplicate` is on, so a version +# burned here cannot be reused. The guard job below skips publishing entirely +# when packages/runtime-lua/feather is byte-identical to the previous runtime release, which +# is what makes it safe for a platform tag to fire this workflow unconditionally. + +on: + workflow_call: + inputs: + force: + description: "Publish even when the runtime is unchanged" + type: boolean + default: false + +jobs: + guard: + runs-on: ubuntu-latest + name: Check whether the runtime actually changed + outputs: + should_publish: ${{ steps.check.outputs.should_publish }} + version: ${{ steps.get_version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve version from tag + id: get_version + run: | + VERSION="$(bash scripts/release-tag-version.sh "$GITHUB_REF_NAME" --version)" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Compare runtime against the previous runtime release + id: check + run: | + if [ "${{ inputs.force }}" = "true" ]; then + echo "Forced publish requested." + echo "should_publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + PREV="$(git tag --list 'runtime-v*' 'v*' --sort=-creatordate \ + | grep -vxF "$GITHUB_REF_NAME" | head -n 1)" + + if [ -z "$PREV" ]; then + echo "No previous runtime release found; publishing." + echo "should_publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if git diff --quiet "$PREV" HEAD -- packages/runtime-lua/feather; then + echo "packages/runtime-lua/feather is unchanged since $PREV — skipping LuaRocks publish." + echo "should_publish=false" >> "$GITHUB_OUTPUT" + else + echo "packages/runtime-lua/feather changed since $PREV — publishing." + echo "should_publish=true" >> "$GITHUB_OUTPUT" + fi + + luarocks-release: + runs-on: ubuntu-latest + name: LuaRocks upload + needs: guard + if: needs.guard.outputs.should_publish == 'true' + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fail if changelog entry does not exist + run: grep -q "${{ github.ref_name }}" CHANGELOG.md + + - name: Verify runtime version + run: | + RUNTIME_VERSION="$(sed -n 's/^local FEATHER_VERSION_NAME = "\([^"]*\)".*/\1/p' packages/runtime-lua/feather/init.lua | head -n 1)" + if [ "$RUNTIME_VERSION" != "${{ needs.guard.outputs.version }}" ]; then + echo "Tag version ${{ needs.guard.outputs.version }} does not match packages/runtime-lua/feather/init.lua version $RUNTIME_VERSION." + echo "Fix with: bash scripts/set-version.sh runtime ${{ needs.guard.outputs.version }}" + exit 1 + fi + + - name: LuaRocks Upload + uses: nvim-neorocks/luarocks-tag-release@v7 + with: + version: ${{ needs.guard.outputs.version }} + license: Feather License + labels: | + love2d + löve + debug + copy_directories: | + packages/runtime-lua/feather + summary: Debug & Inspect Tool for LÖVE (love2d) + detailed_description: | + Feather is a debugger tool with a plugin system for Love2D projects. + fail_on_duplicate: true + env: + LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} + + - name: Zip feather folder + run: | + # Two levels up: the release step below looks for the zip at the repo root. + cd packages/runtime-lua + zip -r ../../feather-${{ needs.guard.outputs.version }}.zip feather/ + + - name: GitHub Release + uses: ncipollo/release-action@v1 + with: + artifacts: | + feather-${{ needs.guard.outputs.version }}-1.rockspec + feather-${{ needs.guard.outputs.version }}-1.src.rock + feather-${{ needs.guard.outputs.version }}.zip + allowUpdates: true diff --git a/.github/workflows/app-e2e.yml b/.github/workflows/app-e2e.yml index 5adfbba1..9c797bcf 100644 --- a/.github/workflows/app-e2e.yml +++ b/.github/workflows/app-e2e.yml @@ -2,22 +2,8 @@ name: App E2E on: pull_request: - paths: - - "src/**" - - "e2e/**" - - "playwright.config.ts" - - "package.json" - - "package-lock.json" - - ".github/workflows/app-e2e.yml" push: branches: [ main, next ] - paths: - - "src/**" - - "e2e/**" - - "playwright.config.ts" - - "package.json" - - "package-lock.json" - - ".github/workflows/app-e2e.yml" workflow_dispatch: permissions: @@ -33,18 +19,42 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.21.1 - cache: "npm" + cache: "pnpm" + - name: Restore Turborepo cache + uses: actions/cache@v4 + with: + path: .turbo + # Keyed on the lockfile, not the SHA: a per-commit key mints a new + # entry every push and churns the 10 GB Actions cache quota. Turbo's + # own input hashing decides what is stale inside the restored cache. + key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + turbo-${{ runner.os }}- - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Run app E2E - run: npm run test:app:e2e + run: pnpm run test:inspector:e2e + + - name: Studio end-to-end + run: pnpm run test:studio:e2e + + # The lint guards catch source imports; this catches what actually lands + # in a bundle, which a transitive or dynamic import can change without any + # single file importing across the boundary. + - name: Packaged artifact boundary + run: | + pnpm run build + pnpm --filter @feather/studio run build + pnpm run check:artifact-boundary diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index d9a84aec..06f620e0 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -2,22 +2,8 @@ name: CLI E2E on: pull_request: - paths: - - "cli/**" - - "src-lua/**" - - "scripts/bundle-lua.sh" - - "package.json" - - "package-lock.json" - - ".github/workflows/cli-e2e.yml" push: branches: [main] - paths: - - "cli/**" - - "src-lua/**" - - "scripts/bundle-lua.sh" - - "package.json" - - "package-lock.json" - - ".github/workflows/cli-e2e.yml" workflow_dispatch: permissions: @@ -38,12 +24,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.21.1 - cache: "npm" + cache: "pnpm" - name: Install LÖVE (Linux) if: runner.os == 'Linux' @@ -60,9 +48,19 @@ jobs: sudo ln -sf /Applications/love.app/Contents/MacOS/love /usr/local/bin/love love --version brew install squashfs + - name: Restore Turborepo cache + uses: actions/cache@v4 + with: + path: .turbo + # Keyed on the lockfile, not the SHA: a per-commit key mints a new + # entry every push and churns the 10 GB Actions cache quota. Turbo's + # own input hashing decides what is stale inside the restored cache. + key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + turbo-${{ runner.os }}- - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Run CLI E2E - run: npm run test:cli:e2e + run: pnpm run test:cli:e2e diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1a801d34..6a1c8455 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,24 +16,45 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.21.1 - cache: "npm" + cache: "pnpm" + - name: Restore Turborepo cache + uses: actions/cache@v4 + with: + path: .turbo + # Keyed on the lockfile, not the SHA: a per-commit key mints a new + # entry every push and churns the 10 GB Actions cache quota. Turbo's + # own input hashing decides what is stale inside the restored cache. + key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + turbo-${{ runner.os }}- - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile + + - name: Check protocol version sync + run: pnpm run check:protocol + + - name: Bridge contract tests + run: pnpm run test:bridge + + - name: Unit tests + run: pnpm run test:unit - name: Run TypeScript checks - run: npm run typecheck:web + run: pnpm run typecheck:web - name: Run linting - run: npm run lint + run: pnpm run lint - name: Build and test VS Code extension - run: npm run extension:test + run: pnpm run extension:test lint-lua: name: Run Lua (Love2D) Linting @@ -41,7 +62,7 @@ jobs: timeout-minutes: 10 if: | contains(github.event_name, 'pull_request') || - contains(github.event.head_commit.message, 'src-lua/') || + contains(github.event.head_commit.message, 'packages/runtime-lua/') || github.event_name == 'push' steps: - name: Checkout code @@ -59,4 +80,4 @@ jobs: run: echo "$HOME/.luarocks/bin" >> $GITHUB_PATH - name: Run luacheck with Love2D globals - run: luacheck src-lua --globals love + run: luacheck packages/runtime-lua --globals love diff --git a/.github/workflows/lua-e2e.yml b/.github/workflows/lua-e2e.yml index 017aeb28..7779218c 100644 --- a/.github/workflows/lua-e2e.yml +++ b/.github/workflows/lua-e2e.yml @@ -2,20 +2,8 @@ name: Lua E2E on: pull_request: - paths: - - "src-lua/**" - - "scripts/lua-e2e.mjs" - - "package.json" - - "package-lock.json" - - ".github/workflows/lua-e2e.yml" push: branches: [ main, next ] - paths: - - "src-lua/**" - - "scripts/lua-e2e.mjs" - - "package.json" - - "package-lock.json" - - ".github/workflows/lua-e2e.yml" workflow_dispatch: permissions: @@ -31,20 +19,32 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.21.1 - cache: "npm" + cache: "pnpm" - name: Install LÖVE run: | sudo apt-get update sudo apt-get install -y love xvfb + - name: Restore Turborepo cache + uses: actions/cache@v4 + with: + path: .turbo + # Keyed on the lockfile, not the SHA: a per-commit key mints a new + # entry every push and churns the 10 GB Actions cache quota. Turbo's + # own input hashing decides what is stale inside the restored cache. + key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + turbo-${{ runner.os }}- - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Run Lua E2E - run: npm run test:lua:e2e + run: pnpm run test:lua:e2e diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml index e4802c2d..018b0219 100644 --- a/.github/workflows/registry.yml +++ b/.github/workflows/registry.yml @@ -4,7 +4,7 @@ on: push: branches: [ main ] paths: - - "packages/**" + - "catalog/packages/**" workflow_dispatch: jobs: @@ -24,12 +24,14 @@ jobs: run: | git config --local user.email "github-actions@github.com" git config --local user.name "GitHub Actions" + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' + cache: 'pnpm' - name: Install Love2D run: | @@ -37,13 +39,13 @@ jobs: sudo apt-get install -y love xvfb - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Generate registry run: node scripts/generate-registry.mjs - name: Build CLI - run: npm run cli:build + run: pnpm run cli:build - name: Run package e2e run: node scripts/package-e2e.mjs @@ -59,12 +61,12 @@ jobs: # Set up a worktree pointing at the packages branch. # If the branch doesn't exist yet, create it as an orphan. if git ls-remote --exit-code origin packages > /dev/null 2>&1; then - git worktree add /tmp/registry-branch packages + git worktree add /tmp/registry-branch refs/heads/packages else git worktree add --orphan -b packages /tmp/registry-branch fi - cp cli/src/generated/registry.json /tmp/registry-branch/registry.json + cp apps/cli/src/generated/registry.json /tmp/registry-branch/registry.json cd /tmp/registry-branch git add registry.json diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 00000000..e4cc7406 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,19 @@ +name: Release CLI + +# `cli-v*` -> CLI alone (this is the decoupled path) +# `v*` -> CLI as part of a full platform release +on: + push: + tags: + - "cli-v*" + - "v*" + workflow_dispatch: + +jobs: + cli: + name: CLI + permissions: + contents: read + id-token: write + uses: ./.github/workflows/_release-cli.yml + secrets: inherit diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 00000000..b2dfe3d8 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,18 @@ +name: Release desktop app + +# `desktop-v*` -> desktop alone +# `v*` -> desktop as part of a full platform release +on: + push: + tags: + - "desktop-v*" + - "v*" + workflow_dispatch: + +jobs: + desktop: + name: Desktop + permissions: + contents: write + uses: ./.github/workflows/_release-desktop.yml + secrets: inherit diff --git a/.github/workflows/release-platform.yml b/.github/workflows/release-platform.yml new file mode 100644 index 00000000..75f5d82b --- /dev/null +++ b/.github/workflows/release-platform.yml @@ -0,0 +1,87 @@ +name: Release platform manifest + +# `v*` — the coherence point. +# +# The individual trains (cli-v*, runtime-v*, desktop-v*, ext-v*) each publish to +# their own home. This job publishes the record of which versions were validated +# together, so someone landing on a v4.x release page gets a working set without +# matching components up themselves. +# +# It deliberately re-uploads nothing that npm, LuaRocks or the Marketplace +# already host. The only attachments are artifacts with no other home: the +# desktop installers, attached to this same release by release-desktop.yml. The +# standalone CLI binaries are attached by the CLI train, which fires on this tag +# too, so they land here without this job copying them. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + manifest: + name: Publish platform manifest + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22.21.1 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve version from tag + id: get_version + run: | + VERSION="$(bash scripts/release-tag-version.sh "$GITHUB_REF_NAME" --version)" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify every component agrees with the tag + run: | + VERSION="${{ steps.get_version.outputs.version }}" + PKG="$(node -p "require('./package.json').version")" + CLI="$(node -p "require('./apps/cli/package.json').version")" + RUNTIME="$(sed -n 's/^local FEATHER_VERSION_NAME = "\([^"]*\)".*/\1/p' packages/runtime-lua/feather/init.lua | head -n 1)" + FAIL=0 + for pair in "package.json:$PKG" "cli/package.json:$CLI" "runtime:$RUNTIME"; do + if [ "${pair#*:}" != "$VERSION" ]; then + echo "${pair%%:*} is ${pair#*:}, expected $VERSION" + FAIL=1 + fi + done + # A platform tag asserts these three shipped together, so they must + # agree. The extension is a satellite and is recorded, not enforced. + if [ "$FAIL" = "1" ]; then + echo "Fix with: bash scripts/set-version.sh platform $VERSION" + exit 1 + fi + + - name: Build the manifest and release notes + run: | + node scripts/build-platform-manifest.mjs "${{ steps.get_version.outputs.version }}" \ + --out feather-platform.json \ + --notes platform-notes.md + + - name: Stage the manifest + run: | + mkdir -p release-assets + cp feather-platform.json release-assets/ + + - name: Publish the platform release + uses: ncipollo/release-action@v1 + with: + artifacts: release-assets/* + bodyFile: platform-notes.md + allowUpdates: true + # release-desktop.yml attaches the installers to this same release. + omitBodyDuringUpdate: false + omitNameDuringUpdate: false diff --git a/.github/workflows/release-runtime.yml b/.github/workflows/release-runtime.yml new file mode 100644 index 00000000..6ad15800 --- /dev/null +++ b/.github/workflows/release-runtime.yml @@ -0,0 +1,29 @@ +name: Release Lua runtime + +# `runtime-v*` -> runtime alone +# `v*` -> runtime as part of a full platform release +# +# The reusable workflow skips the LuaRocks publish when packages/runtime-lua/feather is +# unchanged since the previous runtime release, so a platform tag never burns a +# rock version for an unmodified runtime. +on: + push: + tags: + - "runtime-v*" + - "v*" + workflow_dispatch: + inputs: + force: + description: "Publish even when the runtime is unchanged" + type: boolean + default: false + +jobs: + runtime: + name: Lua runtime + permissions: + contents: write + uses: ./.github/workflows/_release-runtime.yml + with: + force: ${{ inputs.force == true }} + secrets: inherit diff --git a/.github/workflows/release-studio.yml b/.github/workflows/release-studio.yml new file mode 100644 index 00000000..a659e86d --- /dev/null +++ b/.github/workflows/release-studio.yml @@ -0,0 +1,158 @@ +name: Release Feather Studio + +# `studio-v*` only. +# +# Feather Studio is a separately installed application on its own cadence. This +# workflow publishes Studio artifacts and nothing else — no CLI, no Lua runtime, +# no Inspector, no extension. Those trains do not trigger on this tag, which is +# what makes a Studio release genuinely independent. +# +# Releases land on the `studio-v*` tag, a distinct feed from the platform's `v*` +# releases, so a Studio update never appears as an Inspector update. + +on: + push: + tags: + - "studio-v*" + workflow_dispatch: + +jobs: + verify: + name: Verify Studio version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve version from tag + id: get_version + run: | + VERSION="$(bash scripts/release-tag-version.sh "$GITHUB_REF_NAME" --version)" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify Studio's version trio agrees with the tag + run: | + VERSION="${{ steps.get_version.outputs.version }}" + PKG="$(node -p "require('./apps/studio/package.json').version")" + TAURI="$(node -p "require('./apps/studio/src-tauri/tauri.conf.json').version")" + CARGO="$(sed -n '1,/^version = /s/^version = "\(.*\)"/\1/p' apps/studio/src-tauri/Cargo.toml | head -n 1)" + FAIL=0 + for pair in "apps/studio/package.json:$PKG" "tauri.conf.json:$TAURI" "Cargo.toml:$CARGO"; do + if [ "${pair#*:}" != "$VERSION" ]; then + echo "${pair%%:*} is ${pair#*:}, expected $VERSION" + FAIL=1 + fi + done + if [ "$FAIL" = "1" ]; then + echo "Fix with: bash scripts/set-version.sh studio $VERSION" + exit 1 + fi + + - name: Assert this tag publishes nothing but Studio + run: | + # A Studio release must not be able to ship Inspector, the CLI, the + # runtime or the extension. Those workflows filter on other tag + # prefixes; this fails loudly if one ever starts matching studio-v*. + for wf in release-cli release-desktop release-runtime release-platform vscode-extension; do + if grep -qE '^\s+- "studio-v\*"' ".github/workflows/${wf}.yml"; then + echo "${wf}.yml triggers on studio-v* — a Studio release would ship other products." + exit 1 + fi + done + echo "Only Studio publishes on this tag." + + build: + name: Build Studio + needs: verify + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - platform: macos-latest + args: "--target aarch64-apple-darwin" + - platform: macos-latest + args: "--target x86_64-apple-darwin" + - platform: ubuntu-22.04 + args: "" + - platform: windows-latest + args: "" + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: "pnpm" + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform == 'macos-latest' && + 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: Install dependencies Ubuntu only + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Import Apple Developer Certificate + if: matrix.platform == 'macos-latest' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + + CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -n 1) + CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') + + if [ -z "$CERT_ID" ]; then + echo "No Developer ID Application certificate found." + exit 1 + fi + + echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV + + - name: Write Apple API key + if: matrix.platform == 'macos-latest' + run: | + mkdir -p "$RUNNER_TEMP/private_keys" + echo "${{ secrets.APPLE_API_KEY_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/private_keys/AuthKey_${{ secrets.APPLE_API_KEY }}.p8" + echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/private_keys/AuthKey_${{ secrets.APPLE_API_KEY }}.p8" >> $GITHUB_ENV + + - name: Build and release Feather Studio + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} + with: + projectPath: apps/studio + tagName: ${{ github.ref_name }} + releaseName: "Feather Studio v${{ needs.verify.outputs.version }}" + args: ${{ matrix.args }} diff --git a/.github/workflows/tauri-e2e.yml b/.github/workflows/tauri-e2e.yml index 0ad11986..60f9e1a0 100644 --- a/.github/workflows/tauri-e2e.yml +++ b/.github/workflows/tauri-e2e.yml @@ -2,18 +2,8 @@ name: Tauri E2E on: pull_request: - paths: - - "src-tauri/**" - - "package.json" - - "package-lock.json" - - ".github/workflows/tauri-e2e.yml" push: branches: [ main, next ] - paths: - - "src-tauri/**" - - "package.json" - - "package-lock.json" - - ".github/workflows/tauri-e2e.yml" workflow_dispatch: permissions: @@ -39,4 +29,4 @@ jobs: sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - name: Run Tauri E2E - run: npm run test:tauri:e2e + run: pnpm run test:tauri:e2e diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index 20043180..5db087c0 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -1,9 +1,12 @@ name: VS Code extension release +# `ext-v*` only. The extension is a satellite: it vendors its own platform +# snapshot into bundled-bin/, so it never needs to ship alongside a platform tag. on: push: tags: - - "*" + - "ext-v*" + workflow_dispatch: jobs: build: @@ -14,6 +17,8 @@ jobs: vsixFile: ${{ steps.package.outputs.vsixFile }} steps: - uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -22,41 +27,42 @@ jobs: - uses: oven-sh/setup-bun@v2 - - run: npm ci + - run: pnpm install --frozen-lockfile - name: Install icon rasterizer run: sudo apt-get update && sudo apt-get install -y --no-install-recommends librsvg2-bin - - name: Get version from tag + - name: Resolve version from tag id: get_version run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="$(bash scripts/release-tag-version.sh "$GITHUB_REF_NAME" --version)" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Verify extension package version run: | - EXT_VERSION="$(node -p "require('./vscode-extension/package.json').version")" + EXT_VERSION="$(node -p "require('./apps/vscode-extension/package.json').version")" if [ "$EXT_VERSION" != "${{ steps.get_version.outputs.version }}" ]; then echo "Tag version ${{ steps.get_version.outputs.version }} does not match vscode-extension/package.json version $EXT_VERSION." + echo "Fix with: bash scripts/set-version.sh ext ${{ steps.get_version.outputs.version }}" exit 1 fi - name: Test extension - run: npm run extension:test + run: pnpm run extension:test - name: Package extension id: package run: | - npm run extension:package + pnpm run extension:package VERSION="${{ steps.get_version.outputs.version }}" - VSIX_PATH="$(ls vscode-extension/*.vsix | head -n 1)" + VSIX_PATH="$(ls apps/vscode-extension/*.vsix | head -n 1)" VSIX_FILE="feather-cli-vscode-${VERSION}.vsix" mkdir -p release-assets cp "$VSIX_PATH" "release-assets/$VSIX_FILE" - cp cli/bin/feather "release-assets/feather-cli-darwin-arm64-${VERSION}.bin" - cp cli/bin/feather-darwin-x64 "release-assets/feather-cli-darwin-x64-${VERSION}.bin" - cp cli/bin/feather-linux-x64 "release-assets/feather-cli-linux-x64-${VERSION}.bin" - cp cli/bin/feather-win-x64.exe "release-assets/feather-cli-windows-x64-${VERSION}.exe" + # The four Bun-compiled CLI binaries (342 MB) are NOT uploaded here. + # prepare.mjs already places them inside the VSIX under bundled-bin/, + # so attaching them to every extension release stored them twice. + # Standalone binaries are published once, on the platform release. echo "vsixPath=release-assets/$VSIX_FILE" >> "$GITHUB_OUTPUT" echo "vsixFile=$VSIX_FILE" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 5c46e70b..3bc208a5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,14 @@ pnpm-debug.log* lerna-debug.log* node_modules +.turbo +.feather-dev/ dist + +# Rust build output. Each Tauri crate also carries its own ignore, but a +# belt-and-braces rule here stops a hand-made crate from committing 2.5 GB of +# target/ the way apps/studio nearly did. +target/ dist-showcase .showcase-vendor .showcase-dev @@ -28,7 +35,6 @@ dist-ssr *.sln *.sw? -docs/_site/ .venv/ site/ @@ -41,6 +47,10 @@ vendor/ feather.build.json # VS Code extension build outputs -vscode-extension/bundled-*/ -vscode-extension/out/ -vscode-extension/*.vsix +apps/vscode-extension/bundled-*/ +apps/vscode-extension/out/ +apps/vscode-extension/*.vsix + +# Handed to another agent to implement; deliberately not part of this repo's +# history. Listed here so a `git add -A` cannot sweep it back in. +V4-PARTICLES-GPU.md diff --git a/.husky/commit-msg b/.husky/commit-msg index e3c338a0..5ac18014 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -5,7 +5,7 @@ message_file="$1" subject="$(sed -n '1p' "$message_file")" case "$subject" in - ci:*|cli:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|shadergraph:*|docs:*|vscode-extension:*|particles:*|mcp:*) + ci:*|cli:*|package:*|plugin:*|app:*|lua:*|tauri:*|feather:*|shadergraph:*|docs:*|vscode-extension:*|particles:*|mcp:*|studio:*|inspector:*|showcase:*|protocol:*|host:*|ui:*) exit 0 ;; esac @@ -23,10 +23,16 @@ Commit message must start with one of: tauri: feather: shadergraph: - particles: docs: - mcp: vscode-extension: + particles: + mcp: + studio: + inspector: + showcase: + protocol: + host: + ui: Examples: ci: update repo workflows diff --git a/.husky/pre-commit b/.husky/pre-commit index cdfeaa40..d8c68c78 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,20 +1,30 @@ -npm run typecheck -npm run lint +# Turbo caches these, so a re-commit after a trivial fix is near-instant. +pnpm exec turbo run //#lint //#typecheck:web //#typecheck:protocol //#typecheck:lua bash scripts/generate-manifest.sh -if ! git diff --exit-code src-lua/manifest.txt; then +if ! git diff --exit-code packages/runtime-lua/manifest.txt; then echo "[feather] manifest.txt is out of date — it has been updated, please stage it and commit again." exit 1 fi -npm run generate:plugin-catalog -if ! git diff --exit-code cli/src/generated/plugin-catalog.ts; then +pnpm run generate:plugin-catalog +if ! git diff --exit-code apps/cli/src/generated/plugin-catalog.ts; then echo "[feather] plugin catalog is out of date — it has been updated, please stage it and commit again." exit 1 fi -bash scripts/set-version.sh -if ! git diff --exit-code package.json src-lua/feather/init.lua src-tauri/Cargo.toml src-tauri/tauri.conf.json cli/package.json vscode-extension/package.json; then - echo "[feather] Version files are out of sync — they have been updated, please stage them and commit again." +# The Lua runtime and the desktop app negotiate a wire protocol version at +# feather:hello. They live in different languages and cannot import each other, +# so this guard is what stops the two declarations from drifting apart. +pnpm run check:protocol + +# Feather v4: versions are per release train (see V4.md section 6). The trains +# deliberately drift apart — a CLI patch must NOT drag the desktop app, runtime, +# or extension along with it. The only cross-file constraint left is the desktop +# trio, which is one artifact described in three files and must agree with itself. +bash scripts/set-version.sh desktop +if ! git diff --exit-code package.json apps/inspector/src-tauri/Cargo.toml apps/inspector/src-tauri/tauri.conf.json; then + echo "[feather] Desktop version files are out of sync — they have been updated, please stage them and commit again." + echo "[feather] Note: cli, runtime, and ext version independently. See V4.md." exit 1 fi diff --git a/.husky/pre-push b/.husky/pre-push index 4d5a02ac..7377f0d6 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -6,7 +6,8 @@ if [ "${SKIP_PRE_PUSH_TESTS:-}" = "1" ]; then exit 0 fi -npm run test:cli:e2e -npm run test:lua:e2e -npm run test:app:e2e -npm run test:showcase:e2e +# One definition of "the test suite", shared with `pnpm run test`, so what you run +# locally is exactly what gates the push. It goes through turbo, so an unchanged +# suite is a cache hit rather than a rerun, and runs the lanes serially because +# the browser and CLI suites time each other out when run at once. +pnpm run test diff --git a/.luacheckrc b/.luacheckrc index 50507dd1..658ec394 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -14,8 +14,8 @@ globals = { exclude_files = { './lua_install/*', - './src-lua/feather/lib/*', - "./src-lua/demo/lib/*" + './packages/runtime-lua/feather/lib/*', + "./packages/runtime-lua/demo/lib/*" } ignore = { diff --git a/.prettierignore b/.prettierignore index 04da7cd3..b6fb3878 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,4 +24,4 @@ postcss.config.js src-tauri/ ## Lua -src-lua/ \ No newline at end of file +packages/runtime-lua/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a85ba61e..210e0e22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,17 +4,46 @@ This is the canonical repo guide for coding agents. Keep subsystem-specific deta Feather is a CLI debugger, inspector, desktop devtool, VS Code companion, and package manager for Love2D games. It injects a Lua runtime through the CLI and shows a live React + Tauri desktop app with logs, variables, performance, plugins, build tools, and creative inspectors. +## Active Architecture Work + +Feather is mid-migration to a v4 architecture organized around release boundaries. **Read `V4.md` +before starting any structural, build, release, or packaging work.** It carries the goals, the +invariants that hold for every commit, the phase order (which is load-bearing), and a status board +showing what is done and what is next. + +**Read [`V4-ENHANCED.md`](./V4-ENHANCED.md) before changing any existing feature's behaviour or +UI.** It is the companion track: V4.md moves things, V4-ENHANCED refines them. It carries the +evaluation lens, the cross-cutting findings, and a per-surface status board. Its one hard rule is +**refine, do not add** — a change that introduces a new capability does not belong to that work. + +**Read [`V4-STUDIO.md`](./V4-STUDIO.md) before touching `apps/studio/`.** Feather Studio ships +25,500 lines of creative tools its shell never renders, so it cannot currently be opened and used. +That document is the specification for fixing it, with the file-level detail to start from. + +`V4.md` governs *what to build and in what order*. `V4-ENHANCED.md` governs *how good the existing +features have to be*. `V4-STUDIO.md` governs *making Studio exist*. This file governs *how to work +in this repo*. All four apply. + ## Project Layout -- `cli/` - TypeScript CLI using Commander. Workspace package `@kyonru/feather`. -- `src/` - React desktop app using Vite, Tailwind, Radix/Base UI, and Tauri APIs. -- `src-tauri/` - Rust/Tauri shell and WebSocket server. -- `src-lua/feather/` - Embedded Lua runtime loaded into running games. -- `src-lua/plugins/` - Built-in Lua plugins. -- `vscode-extension/` - VS Code extension workspace package. -- `packages/` - Curated Love2D package catalog entries. +Everything that ships lives under `apps/`; `packages/` holds only what those +applications consume. + +- `apps/inspector/` - Tauri desktop debugger. `src/` (React) and `src-tauri/` (Rust WS server). +- `apps/studio/` - Feather Studio: shader graph, texture lab, particle playground. Separate + application with its own binary, bundle identifier, version and release feed. +- `apps/showcase/` - Thin shell hosting Studio for the web. No page code of its own. +- `apps/cli/` - TypeScript CLI using Commander. Workspace package `@kyonru/feather`. +- `apps/vscode-extension/` - VS Code extension. Vendors a pinned platform snapshot. +- `apps/docs/` - Zensical documentation site. +- `packages/protocol/` - Wire message registry and envelope. +- `packages/host/` - Environment capabilities, with Tauri and browser implementations. +- `packages/session-bridge/` - Versioned Inspector↔Studio process contract. +- `packages/ui/` - Shared interface primitives. +- `packages/runtime-lua/` - The Lua runtime and built-in plugins. Publishes to LuaRocks. +- `catalog/packages/` - Curated Love2D package catalog entries (`catalog/packages/*.json`). + Published to the `packages` branch by `.github/workflows/registry.yml`. - `scripts/` - Registry/catalog generators, checksum tools, bundle helpers. -- `docs/` - Zensical documentation site. - `skills/` - Repo-local agent skills. Read the matching skill before editing a subsystem. ## Pick The Right Skill @@ -39,18 +68,18 @@ For package or plugin work that changes generated files, read the relevant skill New packages: - Read `skills/feather-package-catalog/SKILL.md` and its workflow reference before editing catalog data. -- Prefer `npm run package:add` for GitHub-hosted packages and `npm run package:add-url` for direct file URL packages. -- Commit both the source `packages/.json` file and the generated `cli/src/generated/registry.json` update. -- Verify with `npm run check:registry`, `npm run cli:build`, `npm run feather -- package info `, and a package install into a temp or fixture project. +- Prefer `pnpm run package:add` for GitHub-hosted packages and `pnpm run package:add-url` for direct file URL packages. +- Commit both the source `catalog/packages/.json` file and the generated `cli/src/generated/registry.json` update. +- Verify with `pnpm run check:registry`, `pnpm run cli:build`, `pnpm run feather -- package info `, and a package install into a temp or fixture project. - Update package docs, e2e coverage, and `CHANGELOG.md` when the package is user-visible. New plugins: - Read `skills/feather-plugin-authoring/SKILL.md` and its catalog/testing reference before adding plugin files. -- Built-in plugins should usually include `src-lua/plugins//init.lua`, `manifest.lua`, and `README.md`. +- Built-in plugins should usually include `packages/runtime-lua/plugins//init.lua`, `manifest.lua`, and `README.md`. - Declare capabilities, default options, `optIn`, and `disabled` deliberately; risky or development-only plugins stay opt-in and disabled. -- Add Lua e2e coverage under `src-lua/e2e/plugins/` for runtime behavior, and desktop/showcase e2e when React-rendered plugin UI changes. -- Run `bash scripts/generate-manifest.sh`, `npm run generate:plugin-catalog`, `npm run cli:build`, `npm run typecheck:lua`, and `npm run test:lua:e2e`. +- Add Lua e2e coverage under `packages/runtime-lua/e2e/plugins/` for runtime behavior, and desktop/showcase e2e when React-rendered plugin UI changes. +- Run `bash scripts/generate-manifest.sh`, `pnpm run generate:plugin-catalog`, `pnpm run cli:build`, `pnpm run typecheck:lua`, and `pnpm run test:lua:e2e`. - Update plugin docs, any docs symlink targets, and `CHANGELOG.md`. New features: @@ -58,22 +87,22 @@ New features: - Pick the primary subsystem skill first, then load adjacent skills for protocol, CLI, desktop, package, plugin, or extension effects. - Start from existing command/page/hook/store/runtime patterns before adding new abstractions. - Add or update e2e coverage in the subsystem that proves the user workflow, not only unit coverage for helper code. -- Update docs in the same change. Prefer canonical docs beside the subsystem, exposed through `docs/` with symlinks when practical. +- Update docs in the same change. Prefer canonical docs beside the subsystem, exposed through `apps/docs/` with symlinks when practical. - Update `CHANGELOG.md` for user-visible behavior and add compare-link bookkeeping when preparing a release section. - Regenerate catalogs, registries, manifests, or bundled assets whenever the source files they represent change. ## Dev Setup ```sh -npm install -npm run cli:build -npm run feather -- --help -npm run dev -npm run tauri dev -npm run docs +pnpm install +pnpm run cli:build +pnpm run feather --help +pnpm run dev +pnpm run tauri dev +pnpm run docs ``` -`npm run cli:build` is required before local `npm run feather -- ...` smoke checks. +`pnpm run cli:build` is required before local `pnpm run feather -- ...` smoke checks. ## Generated Files @@ -81,8 +110,8 @@ Do not edit generated files by hand. | File | Generator/check | | ------------------------------------- | ------------------------------------------------------------------ | -| `cli/src/generated/registry.json` | `npm run generate:registry` / `npm run check:registry` | -| `cli/src/generated/plugin-catalog.ts` | `npm run generate:plugin-catalog` / `npm run check:plugin-catalog` | +| `cli/src/generated/registry.json` | `pnpm run generate:registry` / `pnpm run check:registry` | +| `cli/src/generated/plugin-catalog.ts` | `pnpm run generate:plugin-catalog` / `pnpm run check:plugin-catalog` | When source catalog or manifest files change, run the generator and include the generated result. @@ -90,16 +119,16 @@ When source catalog or manifest files change, run the generator and include the Update docs when a user-facing command, config field, plugin option, build behavior, package behavior, extension behavior, or safety check changes. -Prefer canonical documentation beside the subsystem it describes, then expose it through `docs/` with a symlink when practical. Edit the source-side file, not only the symlink path. +Prefer canonical documentation beside the subsystem it describes, then expose it through `apps/docs/` with a symlink when practical. Edit the source-side file, not only the symlink path. Docs symlink source files: -- `docs/cli.md` points to `cli/README.md`. -- `docs/vscode-extension.md` points to `vscode-extension/README.md`. -- `docs/packages.md` points to `packages/README.md`. -- `docs/plugins.md` points to `src-lua/plugins/README.md`. -- `docs/plugins-ui.md` points to `src-lua/plugins/plugins-ui.md`. -- Plugin pages under `docs/plugins/` usually point to `src-lua/plugins//README.md`. +- `apps/docs/cli.md` points to `apps/cli/README.md`. +- `apps/docs/vscode-extension.md` points to `apps/vscode-extension/README.md`. +- `apps/docs/packages.md` points to `catalog/packages/README.md`. +- `apps/docs/plugins.md` points to `packages/runtime-lua/plugins/README.md`. +- `apps/docs/plugins-ui.md` points to `packages/runtime-lua/plugins/plugins-ui.md`. +- Plugin pages under `apps/docs/plugins/` usually point to `packages/runtime-lua/plugins//README.md`. Edit the source file, not only the docs path. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2106e198..b06fe7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `pnpm run down` to stop all process trees owned by active `pnpm run up` sessions while leaving reused apps running. +- Feather Studio is now a separate application. The shader graph, texture lab and particle playground install, update and version independently of Feather Inspector, so a Studio update no longer means reinstalling your debugger and a debugger fix no longer touches your creative tools. Install either without the other from the releases page, or use Studio in a browser with no install. +- Studio works fully on its own. Authoring, saving and loading all work with no Inspector running; "Not connected" is a state rather than an error. +- Studio can pair with a running Inspector to push shaders, textures and particle systems straight into a live game. Pairing is explicit, loopback-only, gated on a short-lived key Inspector issues, and version-negotiated first so a mismatched pair tells you which application to update instead of failing obscurely. +- Existing texture recipes, saved workspaces and timeline settings move across on first pairing. The copy inside Inspector is kept, so importing twice is harmless and nothing is lost if you go back. +- The Lua runtime now advertises a wire protocol version at connect, and the desktop app checks it against the range it supports. A game running a protocol the desktop cannot speak now says so directly, with what to do about it, instead of behaving unpredictably. Session detail shows both the game's protocol version and the desktop's. +- A game running a Feather runtime from before protocol negotiation still connects and stays healthy; it is reported as `legacy` rather than treated as a fault. + +### Changed + +- Release tags are now per-train instead of repo-wide. `cli-v*` publishes the CLI alone, `runtime-v*` the Lua runtime alone, `desktop-v*` the desktop app alone, and `ext-v*` the VS Code extension alone. `v*` still performs a full platform release of runtime, CLI, and desktop together. Previously every tag fired all four publish paths. +- `scripts/set-version.sh` now takes a release train (`cli`, `runtime`, `desktop`, `ext`, or `platform`) and only writes the files that train owns, so bumping one artifact no longer rewrites the others. It is also portable now, where it previously assumed macOS `sed`. +- The LuaRocks publish is skipped when the Lua runtime is unchanged since the previous runtime release, so a platform tag no longer burns a rock version for an unmodified runtime. +- Session health no longer reports a mismatch when the connected Lua runtime and the desktop app report different versions. The trains ship on separate cadences, so a difference is expected; both versions are still shown, and plugin API compatibility is still checked. + +### Added + + - Added `feather mcp` with stdio and localhost Streamable HTTP transports for token-protected MCP access to live Feather desktop sessions. - Added `feather mcp setup --client codex|claude` to install or refresh the Feather MCP server entry in Codex or Claude Code config without copying MCP tokens into client config. - Added desktop Settings → Security → MCP Access controls for enabling the local MCP bridge, copying client config, and regenerating the bridge token. @@ -19,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `feather skills` for installing bundled Feather agent skills into project-local `.agents/skills` directories. - Added a desktop CLI action bridge with bundled sidecar resolution, Settings → CLI & Project Actions workflows, typed job events, dry-run previews, and confirmation-gated project mutations. - Added stable JSON output for CLI package/plugin/config/init commands used by the desktop project-action bridge. +- Added a Storm Rain particle template with layered rain streaks, background sheet, ground splashes, mist, and Ambient timeline playback. - Added `feather skills --client` and `--global` targeting so bundled skills can be installed into Codex, Claude, neutral `.agents`, or user-level skill directories. - Added bundled Shader Graph skill references for graph schema, node selection, effect recipes, and visual QA guidance. - Added workflow references for the remaining bundled Feather skills so installed skills include focused subsystem guidance beyond `SKILL.md`. @@ -30,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Tests +- Added dev-session lifecycle coverage for multiple tracked process groups, malformed records, and stale PID command guards. - Added MCP CLI, Tauri bridge, and Settings coverage for token auth, sanitized session snapshots, command routing, and visible MCP controls. - Added CLI coverage for Codex and Claude MCP setup, dry-runs, idempotent config updates, and stale Feather MCP block replacement. - Added MCP creative-tool coverage for plugin resources, creative tool discovery, bridge request handling, and Texture Lab generation routing. @@ -41,11 +61,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added CLI coverage for MCP-free agent session status, logs export, replay list refresh, and command help. - Added CLI/Rust coverage for desktop-backed JSON project actions, dry-run behavior, sidecar resolution precedence, typed action argv mapping, and secret redaction. - Added MCP CLI coverage for resource discovery when the desktop bridge is unavailable. +- Added Lua e2e coverage proving inherited no-op plugin callbacks do not register runtime draw/input handlers. +- Added Lua e2e coverage for In-Game Overlay controller polling throttling and cheaper draw-state handling. ### Fixed - Fixed MCP `resources/list` so static plugin catalog and creative resource URIs remain discoverable when the desktop bridge is unavailable. - Fixed the Shader Graph GLSL code input by replacing the mirrored textarea/highlighter stack with a CodeMirror editor that keeps selection, wrapping, caret positioning, and richer keyword/function/variable/number/parameter highlighting in one editing surface. +- Fixed CLI-managed `feather run` shims and the direct `love src-lua --test-cli` example route so game-local Lua modules and bundled particle assets resolve. +- Fixed Particle Playground Storm Rain exports so ambient weather reuses its active instance, lowers the default particle budget, and renders mist with game-safe alpha blending. +- Fixed Particle Playground connected-game previews so previewed scratch effects use a lighter runtime particle budget and clear their plugin-owned systems as soon as the in-game preview is hidden. +- Fixed Particle Playground exports so timeline lanes compile into per-effect playback plans with static/linear/generic lane paths, dirty setter checks, and low-frequency Ambient property updates. +- Fixed connected-game callback overhead by skipping inherited no-op plugin hooks and avoiding callback dispatch allocations when no snapshot is needed. +- Fixed In-Game Overlay overhead by throttling hidden controller polling correctly and avoiding per-frame full graphics state snapshots while drawing. +- Updated the `test_cli` example to load the generated `Effect_125` rain export by default, with hotkeys for comparing it against the older explosion effect. +- Updated the `test_cli` example HUD and observers to show FPS while testing connected runtime performance. ## [v3.3.1] - 2026-06-14 - The one with fixed versions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a7a87da..b431e0ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ If you are an automated coding agent, follow these extra rules: - Read the relevant files before editing. Use `rg` and focused file reads to understand the existing pattern. - Check `git status --short` before and after. Never revert unrelated changes; assume they belong to the user. -- Do not modify `package-lock.json` unless dependencies actually changed. +- Do not modify `pnpm-lock.yaml` unless dependencies actually changed. - Use `apply_patch` or normal editor-style edits. Avoid broad rewrites and formatting churn. - Run the narrowest useful tests first, then broader checks when the change crosses boundaries. - If a check cannot run because local tooling is missing, say so plainly and include the exact failure. @@ -27,49 +27,213 @@ If you are an automated coding agent, follow these extra rules: ## Development Setup -Install dependencies from the repo root: +Feather is a pnpm + Turborepo monorepo. Install once from the repo root: ```sh -npm install +pnpm install ``` -Build the CLI before using local `npm run feather` commands: +Node 22 or newer is required. Everything below runs from the **repo root** — you should rarely need +to `cd` into an app. + +### Bring up a whole session + +Most debugging work needs Inspector *and* a game connected to it, which is two terminals and an +ordering constraint — a game launched before Inspector's WebSocket server is listening never +connects, and that looks like a bug rather than a race. One command does it: + +```sh +pnpm run up # pick an example interactively +pnpm run up demo # or name one +pnpm run up test_cli --studio # Inspector + Studio + a connected game +pnpm run up --no-game # just the apps +pnpm run up --list # what can I run? +``` + +It builds the CLI, starts Inspector, **waits for port 4004 to accept a connection**, then launches +the game through the CLI with `--session-name` set to the example. Ctrl+C stops everything. + +Anything already listening is reused rather than started twice, so you can leave Inspector open and +re-run `pnpm run up ` to swap games. A partial name works (`single` finds +`session_replay/single_player`); an ambiguous one lists the matches. + +To stop every process tree owned by any active `pnpm run up` command from another terminal, run: + +```sh +pnpm run down +``` + +`down` validates tracked process commands before stopping them. Inspector or Studio processes that +`up` found already running are reused, not owned, and are deliberately left running. + +### Run one application + +The two desktop applications are separate products with separate release trains. Each has a web dev +server and a Tauri shell: + +```sh +pnpm run inspector:dev # Inspector web UI -> http://localhost:1420 +pnpm run inspector:tauri # Inspector desktop app (Tauri) + +pnpm run studio:dev # Studio web UI -> http://localhost:1430 +pnpm run studio:tauri # Studio desktop app (Tauri) + +pnpm run showcase:dev # public showcase site +pnpm run docs # documentation site +``` + +The two dev servers use different ports on purpose, so you can run Inspector and Studio side by +side and exercise the bridge between them. + +Do not run `*:dev` and `*:tauri` for the **same** app at once. The Tauri shell starts its own dev +server on that app's port, so the second one fails with `Port 1420 is already in use` (or 1430 for +Studio). Stop the standalone server first, or just use the Tauri command, which gives you both. + +`pnpm run dev` is still Inspector's web server, and `inspector:dev` is an alias for it. + +**Do not use a bare `pnpm run tauri ...` any more.** The Tauri CLI discovers a project by searching +for a `src-tauri/` directory, and there are now two — so from the repo root it resolves to Studio, +whatever you meant. The `inspector:*` and `studio:*` scripts pin the project directory, and CI does +the same with an explicit `projectPath`. Use them instead. + +### Run the CLI + +The CLI is built before it runs, so use the wrapper rather than a stale `dist/`: + +```sh +pnpm run feather --help +pnpm run feather run packages/runtime-lua/example/test_cli +pnpm run feather doctor packages/runtime-lua/example/test_cli +``` + +`pnpm run feather` rebuilds the Lua bundle and the CLI first, then forwards the rest to it. + +Note the missing `--`. A subcommand works either way (`pnpm run feather -- run ` is fine), but +when the first forwarded token is a flag, pnpm mangles it — `pnpm run feather -- --help` fails with +`unknown command '--help'`. Leaving `--` out works in both cases, so prefer it. + +To skip the rebuild and iterate faster, run `pnpm run cli:build` once and then call +`node apps/cli/dist/index.js ` directly. + +## Testing + +Two aggregate commands cover most of what you want, and both run through Turborepo, so an unchanged +lane is a cache hit instead of a rerun: + +```sh +pnpm run verify # typecheck (all projects), lint, protocol + generated-file drift (~5s warm) +pnpm run test # unit, bridge, CLI e2e, Lua e2e, and the three Playwright suites +``` + +`pnpm run verify` is the fast one — run it constantly. `pnpm run test` is what the pre-push hook +runs, so a clean `pnpm run test` means your push will not be rejected. + +`pnpm run test` runs its lanes **one at a time** (`--concurrency=1`), which takes about three +minutes. That is deliberate: three Playwright suites and the CLI e2e suite all spawn real browsers, +dev servers and child processes, and running them at once made a different lane time out on +roughly every run. If you want the parallel version for a quick local check, run +`pnpm exec turbo run //#test:studio:e2e //#test:showcase:e2e` with the lanes you actually need. + +### Run one lane ```sh -npm run cli:build -npm run feather -- --help +pnpm run test:unit # plain unit tests (scripts/tests) +pnpm run test:bridge # Inspector <-> Studio bridge contract +pnpm run test:cli:e2e # CLI end-to-end (builds the CLI first) +pnpm run test:lua:e2e # Lua runtime against real LÖVE +pnpm run test:inspector:e2e # Playwright, Inspector +pnpm run test:studio:e2e # Playwright, Studio +pnpm run test:showcase:e2e # Playwright, showcase +pnpm run test:tauri:e2e # Rust, Inspector WebSocket server +pnpm run test:packages:e2e # package-catalog install flows +pnpm run extension:test # VS Code extension ``` -Run the desktop app: +### Testing while a dev server is already running + +Playwright starts its own server and refuses the port if something already holds it — which it +usually does, because you have `pnpm run studio:tauri` or `inspector:tauri` open in another terminal: + +``` +Error: http://127.0.0.1:1430 is already used, make sure that nothing is running on the port/url +``` + +Reuse the server you already have instead of killing it: ```sh -npm run tauri dev +PLAYWRIGHT_REUSE_SERVER=1 pnpm run test # the whole suite +PLAYWRIGHT_REUSE_SERVER=1 pnpm run test:studio:e2e # one lane ``` -Serve docs: +The tests then run against your live dev server, so they pick up whatever is on disk without a +rebuild — which also makes this the fast way to iterate on a single failing test. + +### Run one file or one test ```sh -./scripts/docs.sh +pnpm run cli:build +node --test apps/cli/test/commands/run.test.mjs # one CLI file +node --test --test-name-pattern "adb reverse" apps/cli/test/commands/run.test.mjs + +pnpm exec playwright test -c apps/studio/playwright.config.ts -g "texture lab" +pnpm exec playwright test -c apps/inspector/playwright.config.ts --headed --debug ``` +Playwright suites start their own dev server, so do not have `inspector:dev` or `studio:dev` running +on the same port when you launch them. + +### Typecheck or lint one project + ```sh -npm run docs +pnpm run typecheck # every project, chained +pnpm run typecheck:studio # or :web (Inspector), :protocol, :host, :session-bridge, :ui, :lua +pnpm run lint # eslint across apps/ and packages/ +pnpm exec eslint apps/studio/src/store/studio-preferences.ts # one file ``` +### Turborepo notes + +Task caching is keyed on declared `inputs` in `turbo.json`. If you add a task, or a task starts +reading files it did not read before, **update its `inputs`** — an under-declared task reports a +cache hit and silently skips work that would have failed. Force a lane to re-run with +`pnpm exec turbo run //#lint --force`. + ## Project Layout -- `src/` contains the React desktop app. -- `src-tauri/` contains the Tauri shell and WebSocket server. -- `cli/` contains the Feather CLI, build/upload/release logic, package manager, and Ink workflows. -- `cli/test/commands/` contains CLI end-to-end tests using Node's built-in test runner. -- `src-lua/feather/` contains the embedded Lua runtime. -- `src-lua/plugins/` contains built-in Lua plugins. -- `src-lua/example/` contains runnable LÖVE examples. -- `vscode-extension/` contains the VS Code extension. -- `docs/` contains the Zensical documentation site. -- `packages/` contains curated package registry entries. +```txt +apps/ + inspector/ Inspector desktop app: React UI + src-tauri/ shell and WebSocket server + studio/ Feather Studio: shader graph, particle playground, texture lab (+ src-tauri/) + showcase/ public web showcase + cli/ the Feather CLI: build/upload/release, package manager, Ink workflows + vscode-extension/ the VS Code extension + docs/ the Zensical documentation site +packages/ + protocol/ the wire contract shared by desktop and runtime (message names, envelope) + session-bridge/ the versioned, authenticated Inspector <-> Studio process bridge + host/ host abstraction so UI code runs under Tauri or the web + ui/ shared UI components and hooks + runtime-lua/ the embedded Lua runtime, built-in plugins, and runnable LÖVE examples +catalog/packages/ curated package registry entries +scripts/ generators, release tooling, and cross-cutting checks +``` + +Inspector is served by the **root** `vite.config.ts` (its `root` points at `apps/inspector`), which +is why it has no `package.json` of its own. Studio owns `apps/studio/package.json` and its own Vite +config. Both are real Tauri applications with their own identity, version, and release train. + +Tests live beside what they cover: `apps/cli/test/commands/` for the CLI, `apps/*/e2e/` for +Playwright, `packages/session-bridge/test/` for the bridge, and `scripts/tests/` for plain units. -`docs/cli.md` is a symlink to `cli/README.md`, and `docs/vscode-extension.md` is a symlink to `vscode-extension/README.md`. Edit the source file directly when your editor or tool has trouble writing through symlinks. +`apps/docs/cli.md` is a symlink to `apps/cli/README.md`, and `apps/docs/vscode-extension.md` is a +symlink to `apps/vscode-extension/README.md`. Edit the source file directly when your editor has +trouble writing through symlinks. + +Architecture, release trains, and the reasoning behind the split are in [`V4.md`](./V4.md). +Feature-level refinement work — what each existing surface has to be good at, and where it is not +yet — is in [`V4-ENHANCED.md`](./V4-ENHANCED.md). Feather Studio has its own specification in +[`V4-STUDIO.md`](./V4-STUDIO.md): it currently renders none of the tools it ships, and that document +is the plan for making it usable. ## CLI-Managed Examples @@ -78,7 +242,7 @@ Most examples should be plain LÖVE projects. Do not add direct `require("feathe Prefer this shape: ```txt -src-lua/example/my_example/ +packages/runtime-lua/example/my_example/ main.lua conf.lua feather.config.lua @@ -87,7 +251,7 @@ src-lua/example/my_example/ Run it with: ```sh -npm run feather -- run src-lua/example/my_example +pnpm run feather -- run packages/runtime-lua/example/my_example ``` Example game code may use guarded runtime APIs: @@ -122,40 +286,40 @@ The CLI shim must preserve `pluginOptions` for any plugin, including IDs with da Run the CLI: ```sh -npm run cli:build -npm run feather -- doctor src-lua/example/test_cli -npm run feather -- run src-lua/example/test_cli +pnpm run cli:build +pnpm run feather -- doctor packages/runtime-lua/example/test_cli +pnpm run feather -- run packages/runtime-lua/example/test_cli ``` Run a focused CLI test file: ```sh -npm run cli:build -node --test cli/test/commands/run.test.mjs +pnpm run cli:build +node --test apps/cli/test/commands/run.test.mjs ``` Run Lua examples: ```sh -npm run feather -- run src-lua/example/test_cli -npm run feather -- run src-lua/example/session_replay/single_player -npm run feather -- run src-lua/example/session_replay/multiplayer -npm run feather -- run src-lua/example/session_replay/adapter +pnpm run feather -- run packages/runtime-lua/example/test_cli +pnpm run feather -- run packages/runtime-lua/example/session_replay/single_player +pnpm run feather -- run packages/runtime-lua/example/session_replay/multiplayer +pnpm run feather -- run packages/runtime-lua/example/session_replay/adapter ``` Run Android development builds: ```sh -npm run feather -- build vendor add android --dir src-lua/example/test_cli -npm run feather -- build android --dir src-lua/example/test_cli --verbose -npm run feather -- run src-lua/example/test_cli --target android --verbose +pnpm run feather -- build vendor add android --dir packages/runtime-lua/example/test_cli +pnpm run feather -- build android --dir packages/runtime-lua/example/test_cli --verbose +pnpm run feather -- run packages/runtime-lua/example/test_cli --target android --verbose ``` Use cache controls when testing build behavior: ```sh -npm run feather -- build android --dir src-lua/example/test_cli --no-cache --verbose -npm run feather -- build android --dir src-lua/example/test_cli --clean --verbose +pnpm run feather -- build android --dir packages/runtime-lua/example/test_cli --no-cache --verbose +pnpm run feather -- build android --dir packages/runtime-lua/example/test_cli --clean --verbose ``` ## Golden Workflow Checklist @@ -163,7 +327,7 @@ npm run feather -- build android --dir src-lua/example/test_cli --clean --verbos Use this smoke path when validating Feather for day-to-day LÖVE development: ```bash -npm run feather -- run src-lua/example/test_cli --verbose --config src-lua/example/test_cli/feather.config.lua +pnpm run feather -- run packages/runtime-lua/example/test_cli --verbose --config packages/runtime-lua/example/test_cli/feather.config.lua ``` 1. **Connect And Session Health**: the running game appears as the active session, stale **Connecting game** entries disappear, **Session** shows the runtime/config details, and suspend/resume keeps the socket available without leaving the app stuck. @@ -176,40 +340,40 @@ This is the developer-preview acceptance bar: Feather should let an external LÖ ## Verification -Run the checks that match your change. For broad changes, run more than one lane. +Start with the two aggregates — see [Testing](#testing) for the full command list: ```sh -npm run typecheck:web -npm run typecheck:lua -npm run lint -npm run cli:build -npm run test:cli:e2e -npm run test:lua:e2e -npm run test:app:e2e -npm run test:tauri:e2e -npm run extension:build -npm run extension:test +pnpm run verify # typecheck, lint, protocol and generated-file drift +pnpm run test # every test lane the pre-push hook gates on +``` + +Two lanes are deliberately outside `pnpm run test` because they need toolchains that are not always +present. Run them when your change touches them: + +```sh +pnpm run test:tauri:e2e # needs a Rust toolchain +pnpm run extension:test # needs the VS Code test harness ``` Focused guidance: -- React desktop changes: `npm run typecheck:web`, `npm run lint`, and Playwright if visible behavior changed. -- CLI changes: `npm run cli:build` and the relevant `node --test cli/test/commands/*.test.mjs` file. -- Lua runtime or plugin changes: `npm run typecheck:lua`, `npm run test:lua:e2e`, and any focused Lua e2e path if available. +- Inspector UI changes: `pnpm run typecheck:web`, `pnpm run lint`, and `pnpm run test:inspector:e2e` if visible behavior changed. +- Studio UI changes: `pnpm run typecheck:studio`, `pnpm run lint`, and `pnpm run test:studio:e2e`. +- Shared package changes (`protocol`, `host`, `ui`, `session-bridge`): run `pnpm run verify` plus both app suites — these cross the application boundary. +- CLI changes: `pnpm run cli:build` and the relevant `node --test apps/cli/test/commands/*.test.mjs` file. +- Lua runtime or plugin changes: `pnpm run typecheck:lua`, `pnpm run test:lua:e2e`, and any focused Lua e2e path if available. - Build/upload/release safety changes: run targeted build, doctor, upload-safety, and release tests. -- Tauri/WebSocket changes: `npm run test:tauri:e2e`. -- VS Code extension changes: `npm run extension:build` and `npm run extension:test`. -- Docs-only changes: read the edited Markdown and run `npm run docs` when practical. +- Tauri/WebSocket changes: `pnpm run test:tauri:e2e`. +- VS Code extension changes: `pnpm run extension:build` and `pnpm run extension:test`. +- Docs-only changes: read the edited Markdown and run `pnpm run docs` when practical. If `love`, `luacheck`, Android SDK, Xcode, Fastlane, or other local tooling is missing, document that in the PR or final handoff. Do not pretend the check passed. -The pre-push hook runs the heavier local test lanes before pushing: +The pre-push hook runs the same lanes as `pnpm run test`, through Turborepo, so unchanged suites +are cache hits rather than reruns: ```sh -npm run test:cli:e2e -npm run test:lua:e2e -npm run test:app:e2e -npm run test:showcase:e2e +pnpm run test ``` When you need to push from a machine that cannot run those tools, use `SKIP_PRE_PUSH_TESTS=1 git push` and call out the skipped checks. @@ -218,23 +382,28 @@ When you need to push from a machine that cannot run those tools, use `SKIP_PRE_ Some files are generated and must stay in sync: -- `src-lua/manifest.txt` -- `cli/src/generated/plugin-catalog.ts` -- `cli/src/generated/registry.json` -- version fields in `package.json`, `src-lua/feather/init.lua`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` +- `packages/runtime-lua/manifest.txt` +- `apps/cli/src/generated/plugin-catalog.ts` +- `apps/cli/src/generated/registry.json` +- `packages/runtime-lua/feather/protocol_messages.lua` (generated from `packages/protocol`) +- each application's version trio — Inspector is `package.json` + `apps/inspector/src-tauri/{Cargo.toml,tauri.conf.json}`, Studio is `apps/studio/package.json` + `apps/studio/src-tauri/{Cargo.toml,tauri.conf.json}` Refresh them with: ```sh bash scripts/generate-manifest.sh -npm run generate:plugin-catalog -npm run generate:registry -bash scripts/set-version.sh +pnpm run generate:plugin-catalog +pnpm run generate:registry +pnpm run generate:protocol-lua +bash scripts/set-version.sh # cli | runtime | desktop | ext | studio | platform ``` -`npm run check:plugin-catalog` and `npm run check:registry` intentionally fail when generated output differs from git. If the generated diff is expected, commit it. +Each train versions independently — that is the point of the v4 split, so do not bump them together +out of habit. `bash scripts/set-version.sh desktop` is Inspector; `studio` is Studio. + +`pnpm run check:plugin-catalog`, `pnpm run check:registry`, and `pnpm run check:protocol` intentionally fail when generated output differs from git. If the generated diff is expected, commit it. All three run as part of `pnpm run verify`. -Generated or local-only folders such as `vscode-extension/bundled-cli/`, `vscode-extension/bundled-bin/`, build outputs, caches, and replay/debug artifacts should not be committed. +Generated or local-only folders such as `apps/vscode-extension/bundled-cli/`, `apps/vscode-extension/bundled-bin/`, `src-tauri/target/`, build outputs, caches, and replay/debug artifacts should not be committed. ## Lua Runtime Guidelines @@ -248,10 +417,10 @@ Generated or local-only folders such as `vscode-extension/bundled-cli/`, `vscode ## Plugin Guidelines -Built-in plugins live under `src-lua/plugins//` and should usually include: +Built-in plugins live under `packages/runtime-lua/plugins//` and should usually include: ```txt -src-lua/plugins// +packages/runtime-lua/plugins// init.lua manifest.lua README.md @@ -261,10 +430,10 @@ After adding or changing a built-in plugin: ```sh bash scripts/generate-manifest.sh -npm run generate:plugin-catalog -npm run cli:build -npm run typecheck:lua -npm run test:lua:e2e +pnpm run generate:plugin-catalog +pnpm run cli:build +pnpm run typecheck:lua +pnpm run test:lua:e2e ``` Plugin contribution tips: @@ -295,6 +464,46 @@ Plugin contribution tips: - When adding setup options, update `cli/README.md`, `docs/configuration.md`, and generated config templates. - Build and upload commands must run production safety checks before shipping user-facing artifacts. +## Release Trains + +Feather v4 releases each artifact on its own train. There is no single repo-wide +version — a CLI patch must not drag the desktop app, Lua runtime, or extension +along with it. See `V4.md` for the full model. + +| Tag | Ships | Publishes to | +| --- | --- | --- | +| `v` | Platform: runtime + CLI + desktop together | LuaRocks, npm, signed desktop binaries | +| `cli-v` | CLI alone | npm | +| `runtime-v` | Lua runtime alone | LuaRocks + GitHub release | +| `desktop-v` | Desktop app alone | 4 signed binaries | +| `ext-v` | VS Code extension alone | Marketplace + Open VSX | + +Set a train's version before tagging. The script only touches the files owned by +the train you name: + +```sh +bash scripts/set-version.sh cli 4.0.1 # then: git tag cli-v4.0.1 +bash scripts/set-version.sh runtime 4.0.1 # then: git tag runtime-v4.0.1 +bash scripts/set-version.sh desktop 4.0.1 # then: git tag desktop-v4.0.1 +bash scripts/set-version.sh ext 4.0.1 # then: git tag ext-v4.0.1 +bash scripts/set-version.sh platform 4.1.0 # then: git tag v4.1.0 +``` + +Notes: + +- Each release workflow verifies that its train's version matches the tag and + fails with the exact `set-version.sh` command to fix it. +- The runtime workflow skips the LuaRocks publish when `packages/runtime-lua/feather` is + unchanged since the previous runtime release, so a platform tag never burns a + rock version for an unmodified runtime. Override with the `force` input on + workflow dispatch. +- `bash scripts/release-tag-version.sh ` parses a tag into its train and + version, and rejects tags outside this vocabulary. +- Trains are expected to drift apart. The desktop app displays both its own + version and the connected runtime's version; a difference is normal and is not + a fault. Compatibility is signalled by the API version, which Phase 02 of the + v4 migration replaces with a negotiated protocol version. + ## Build, Upload, And Release Safety Production paths should be boringly strict. @@ -317,34 +526,34 @@ Production paths should be boringly strict. - User-facing CLI behavior belongs in `cli/README.md` and, when broader, in `docs/usage.md` or a dedicated page. - Runtime config belongs in `docs/configuration.md`. -- Plugin behavior belongs in `src-lua/plugins//README.md`; major workflows may also need a `docs/.md` page and a `zensical.toml` nav entry. +- Plugin behavior belongs in `packages/runtime-lua/plugins//README.md`; major workflows may also need a `docs/.md` page and a `zensical.toml` nav entry. - VS Code extension behavior belongs in `vscode-extension/README.md`. - Prefer copy-pasteable commands and small guarded Lua examples. - Be explicit about what Feather does not do. For example, Session Replay records inputs and developer-selected state; it does not serialize an entire game. ## Package Catalog Contributions -Use helper scripts instead of hand-writing package entries whenever possible. They fetch metadata, pin source commits or URLs, calculate SHA-256 checksums, write `packages/.json`, and regenerate `cli/src/generated/registry.json`. +Use helper scripts instead of hand-writing package entries whenever possible. They fetch metadata, pin source commits or URLs, calculate SHA-256 checksums, write `catalog/packages/.json`, and regenerate `cli/src/generated/registry.json`. For GitHub-hosted packages: ```sh -npm run package:add +pnpm run package:add ``` For direct file URLs: ```sh -npm run package:add-url +pnpm run package:add-url ``` After the wizard finishes: ```sh -npm run check:registry -npm run cli:build -npm run feather -- package info -npm run feather -- package install --dir /tmp/feather-package-test +pnpm run check:registry +pnpm run cli:build +pnpm run feather -- package info +pnpm run feather -- package install --dir /tmp/feather-package-test ``` Package contribution tips: @@ -353,7 +562,7 @@ Package contribution tips: - Keep install targets narrow and predictable, usually under `lib//`. - Include a realistic `require` path and a small usage example. - Use `verified` only for packages reviewed and pinned with checksums. Use `known` for checksum-pinned sources that still need extra review. -- Commit both `packages/.json` and `cli/src/generated/registry.json`. +- Commit both `catalog/packages/.json` and `cli/src/generated/registry.json`. ## Commit Messages diff --git a/README.md b/README.md index 3a8f2e41..10275d24 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ The goal is to make the day-to-day loop of writing and testing a LÖVE game fast --- -![log tab](docs/images/logs.png) -![performance tab](docs/images/performance.png) -![observability tab](docs/images/observable.png) -![!assets tab](docs/images/assets.png) -![!debugger tab](docs/images/debugger.png) +![log tab](apps/docs/images/logs.png) +![performance tab](apps/docs/images/performance.png) +![observability tab](apps/docs/images/observable.png) +![!assets tab](apps/docs/images/assets.png) +![!debugger tab](apps/docs/images/debugger.png) --- @@ -103,7 +103,7 @@ feather --help feather run --help ``` -See the [CLI docs](docs/cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload`. +See the [CLI docs](apps/docs/cli.md) for `feather run`, `feather doctor`, `feather build`, and `feather upload`. --- @@ -118,20 +118,39 @@ feather package audit # verify checksums of installed files feather package list # browse the catalog ``` -Available libraries include anim8, bump, hump, lume, flux, inspect, middleclass, classic, push, sti, and windfield. See [packages/README.md](packages/README.md) for the full list and command reference. +Available libraries include anim8, bump, hump, lume, flux, inspect, middleclass, classic, push, sti, and windfield. See [catalog/packages/README.md](catalog/packages/README.md) for the full list and command reference. --- ## [Documentation](https://kyonru.github.io/feather) -- [Installation](docs/installation.md) -- [CLI](docs/cli.md) -- [Configuration](docs/configuration.md) -- [Usage](docs/usage.md) — observers, logging, console, step debugger -- [Session Replay](docs/session-replay.md) -- [Plugins](docs/plugins.md) -- [Packages](packages/README.md) -- [Recommendations](docs/recommendations.md) — security, performance, release builds +- [Installation](apps/docs/installation.md) +- [CLI](apps/docs/cli.md) +- [Configuration](apps/docs/configuration.md) +- [Usage](apps/docs/usage.md) — observers, logging, console, step debugger +- [Session Replay](apps/docs/session-replay.md) +- [Plugins](apps/docs/plugins.md) +- [Packages](catalog/packages/README.md) +- [Recommendations](apps/docs/recommendations.md) — security, performance, release builds + +--- + +## Contributing + +Feather is a pnpm + Turborepo monorepo. Everything runs from the repo root: + +```sh +pnpm install +pnpm run up # Inspector + an example game connected to it +pnpm run inspector:tauri # or just the Inspector desktop app +pnpm run studio:tauri # or just Feather Studio +pnpm run feather --help # the CLI +pnpm run verify # typecheck, lint, and drift checks +pnpm run test # every test lane +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development guide, and [V4.md](V4.md) for the +architecture and release trains. --- @@ -163,7 +182,7 @@ Available libraries include anim8, bump, hump, lume, flux, inspect, middleclass, The license applies to products that directly replicate the logic or purpose of this tool. It does not apply to games built using it as a development tool. -See [LICENSE.md](LICENSE.md). +See [LICENSE](LICENSE). ## AI usage diff --git a/V4-ENHANCED.md b/V4-ENHANCED.md new file mode 100644 index 00000000..e8c66e1a --- /dev/null +++ b/V4-ENHANCED.md @@ -0,0 +1,1194 @@ +# V4 Enhanced — refining what Feather already does + +**Status:** active · **Started:** 2026-09-09 · **Companion to:** [`V4.md`](./V4.md) + +V4.md is about structure: separating products so they can ship independently. This document is +about the opposite axis — leaving the feature set exactly where it is and making each existing +feature better at its job. + +--- + +## 1. Why this exists + +Feather is a tool developers reach for while they are trying to do something else. Nobody opens +Inspector because they want to use Inspector; they open it because a sprite is in the wrong place +and they need to know why. That framing decides everything below. + +A tool in that position is not judged on how much it can do. It is judged on whether it costs less +attention than the problem it is helping with. Every re-typed filter, every panel that might be +showing five-minute-old data, every error that names a failure without naming a fix — each one is +small, and each one is a reason to alt-tab back to `print()` and never come back. + +**The thesis:** Feather's feature set is already broad enough. What decides whether people keep +using it is whether the features they already rely on stay out of their way. + +### The rule + +> **Refine. Do not add.** + +A change belongs in this document if it makes an existing feature better at what it already does. +A change that introduces a new capability, panel, tool, or concept does not, however good the idea +— write it down somewhere else and come back to it when this work is finished. + +This rule is strict because the failure mode is well known: a tool grows a tenth surface while the +first nine stay 80% done, and every one of those nine has a rough edge that makes someone give up. +Breadth is not the problem. Follow-through is. + +**Amended 2026-09-09 by the owner:** a small addition that completes something already half-present +is in scope. The test is whether the feature already makes a promise it does not keep. A debugger +with F8/F10/F11 bound and no way to set a breakpoint by keyboard is not missing a feature; it is +half-delivering the one it has, and a user who reaches for F9 out of habit finds nothing. Closing +that is refinement. A tenth panel is still not. + +--- + +## 2. The lens + +Judge every feature against these. They are ordered: a failure high in this list makes the ones +below it irrelevant. + +**L1 — Does it tell the truth?** +The data shown is current, or the interface says plainly that it is not. A debugging tool that +silently shows stale state is worse than no tool, because it sends you down a wrong path with +confidence. This is the one non-negotiable. + +**L2 — Does it survive the loop?** +Development is: change code, restart game, look again. Dozens of times an hour. Anything the user +configured — a filter, a search, an expanded row, a toggle — should still be there on the other +side of a restart or a trip to another panel. Re-typing is the most common tax this tool charges. + +**L3 — Does it say what to do next?** +An error or a warning should end with an action. "Session disconnected" is a diagnosis. "Session +disconnected — restart the game, or run `feather run `" is help. + +**L4 — Is the common path short?** +Count the clicks and keystrokes for the thing people do most in that panel. Then count them for the +thing they do second-most. Those two numbers are the panel's real ergonomics. + +**L5 — Does it hold up at real scale?** +Not 12 log lines: 40,000. Not 5 assets: an atlas-heavy project. Not a 20-line file: a 3,000-line +`main.lua`. Features are usually built against toy data and used against real data. + +**L6 — Does it stay out of the way?** +No toast for something the user can already see. No confirmation for something reversible. No +animation on the path someone walks fifty times a day. + +--- + +## 3. What is already right + +Refinement work goes wrong when it treats a good codebase as a broken one. These are load-bearing +and should be preserved — several are the model for fixing the rest. + +- **Log retention is genuinely engineered.** `store/log-history.ts` caps at 1000 entries across 16 + buckets, truncates individual entries at 12,000 chars, and has a quota-exceeded retry path that + compacts rather than throwing away. Someone thought about the browser storage limit before it + bit a user. +- **Console history is session-scoped and persisted.** `store/console-history.ts` keeps 200 inputs + and 100 outputs, deduplicated, and `pages/console/index.tsx:476-486` scopes history, outputs and + saved snippets to the active session. **This is the pattern the other panels should copy.** +- **Breakpoints and profiler probes survive restarts.** `store/debugger.ts:257-263` persists + `breakpoints`, `profilerProbes`, `defaultEnabled` and `rootPaths`, while correctly leaving + `pausedState`, `enabled` and `status` transient. The durable/ephemeral split is right. +- **The empty state does real work.** `router.tsx:134-175` shows the actual command to run — + `feather run ` — with the user's own configured directory in it, plus routes to + settings and install docs. That is L3 done properly. +- **The protocol-mismatch warning is deliberately narrow.** `pages/session/index.tsx:345-355` + refuses to degrade session health for a runtime that merely predates protocol negotiation, + because that would have shown "Needs attention" to every existing user on upgrade. The comment + explaining why is as valuable as the code. +- **Logs and the data table are virtualized.** `pages/log/LogTable.tsx` and + `components/data-table.tsx` already handle volume. + +--- + +## 4. Design direction — an instrument, not an interface + +Refining the features raises a question the codebase has never answered explicitly: what should +Feather *look* like? Left unanswered it gets answered by accident, one Tailwind class at a time, +which is exactly what has happened (**C0** below). + +### Why the obvious candidates are wrong + +Worth recording, because both will be proposed again by someone who has not read this. + +**Bento** optimizes for glanceable *variety* — heterogeneous cards at equal visual weight. +Inspector's core surfaces are deep uniform lists: logs, assets, frames, observers. Those need +hierarchy and vertical density, and bento's padding and gaps spend precisely the space §6.1 is +short of. Equal weight is also the wrong claim to make when one row is a fatal error and the +other four hundred are debug noise. + +**Neo-brutalism** spends contrast on decoration — hard borders, offset shadows, saturated blocks. +That is the same budget the semantic colors need. If the chrome shouts, red stops meaning "this +crashed", because everything is already loud. It is also fatiguing across the eight-hour sessions +this tool is actually used in. + +The deeper reason both fail is the same: they are styles built to *attract* attention. Inspector +must never want attention. The user's attention belongs on their bug. + +### The direction + +The reference class is not web design, it is **measurement instruments** — DevTools, Instruments, +Grafana, Linear. Four rules, in priority order. + +**D1 — Color is a reserved channel.** +Chrome is achromatic: greys carrying a slight hue bias toward the existing rose accent, so they +read as chosen rather than defaulted. Saturation is spent *only* on the four semantic states and on +interaction. The accent (`--primary`, `#ad5b68`) means "you can act here" — selection, focus, +primary action — and never encodes data. The payoff is that a spot of color anywhere in the UI +carries meaning by construction, which is worth more than any amount of styling. + +**D2 — Typography carries the hierarchy.** +One sans for chrome, one mono for data, and `font-variant-numeric: tabular-nums` everywhere digits +line up in a column — FPS, memory, timings, byte counts, durations. Without it a changing number +reflows its neighbours, which reads as jitter in exactly the panels you are watching *for* change. +Rank comes from weight and size so it does not have to come from color. + +**D3 — Rules, not cards.** +Dividers and one spacing scale instead of elevation. Cards imply peers; this data has hierarchy. +Elevation stays at zero except for things that genuinely float — command palette, popovers, +dialogs. This also directly buys back the vertical density §6.1 needs. + +**D4 — Motion only for causality.** +Animate to show that something *arrived* or *changed* — a new log line, a value updating, a panel +going stale. Never for decoration, and never on a path someone walks fifty times a day. Everything +respects `prefers-reduced-motion`. + +None of this is a restyle. The existing Radix/shadcn foundation and the Night Owl-ish palette are +already most of the way there; this is mostly consolidation, and D1 is the part with teeth. + +### C0 — The semantic color channel is broken *(verified — do this first)* + +Listed as a cross-cutting finding because it is one, but placed here because it is what D1 exists +to fix, and because it is the prerequisite for **C2**: a staleness indicator needs a color that +already means something. + +Inspector expresses four meanings with **fifty-four different colors**, as raw Tailwind literals: + +| Meaning | Distinct colors in use | Examples | +|---|---|---| +| ok | **12** | `emerald-100/300/400/500/600/700/950`, `green-300/400/500/600/700` | +| warning | **17** | `amber-100…950`, `orange-400/500/600/700`, `yellow-400/500/600/950` | +| danger | **9** | `red-100/400/500/600/700/950`, `rose-100/500/950` | +| info | **16** | `blue-300…700`, `cyan-100…950`, `sky-100…950` | + +158 raw literals against 338 uses of proper semantic tokens. Two consequences, both verified: + +**They ignore the user's theme.** Inspector ships **79 themes** across five registries +(GitHub, Noctis, Tokyo Night, Rainglow, Visual Studio C++), and +`assets/theme/registry/types.ts` lets each override 36 `THEME_CSS_VARIABLES` — chrome, sidebar, +charts, even the four `plugin-*` colors. **Not one semantic state color is in that list.** So a +user on Noctis Light gets their theme's chrome and Tailwind's stock palette for the colors that +actually carry meaning. + +**They fail contrast on the themes we ship.** Measured against real theme backgrounds: + +| Literal | Noctis dark | Tokyo Night dark | app `.dark` | app light | +|---|---|---|---|---| +| `amber-700` | 3.21 ✗ | 3.07 ✗ | 3.77 ✗ | 4.54 ✓ | +| `green-700` | 3.21 ✗ | 3.07 ✗ | 3.77 ✗ | 4.54 ✓ | +| `red-600` | 3.34 ✗ | 3.19 ✗ | 3.92 ✗ | **4.37 ✗** | +| `blue-700` | 2.40 ✗ | 2.30 ✗ | 2.82 ✗ | 6.06 ✓ | +| `green-400` | 9.25 ✓ | 8.85 ✓ | 10.86 ✓ | **1.58 ✗** | +| `amber-300` | 11.18 ✓ | 10.69 ✓ | 13.12 ✓ | **1.30 ✗** | + +WCAG AA is 4.5:1. The dark-leaning literals are near-invisible on light themes; the light-leaning +ones fail on every dark theme; `red-600` fails on Inspector's *own* default light background. The +colors carrying "this crashed" are unreadable on roughly half the themes the product ships. + +This is not a style preference. It is a correctness bug in the signal channel, which is why it +outranks everything else in §5. + +**Refinement.** Four semantic ramps, tokenized like everything else, each with a foreground, a +surface and a border so panels can use them for chips, rows and banners without inventing values: + +```css +/* :root — light. Verified against --background #f0f4f8 and --card #f8fbff. */ +--ok: #0a6c46; --ok-surface: #e6f4ec; --ok-border: #b7ddc8; +--warn: #8a5a00; --warn-surface: #fdf3e0; --warn-border: #e8cf9e; +--danger: #b3261e; --danger-surface: #fbeae9; --danger-border: #eebbb7; +--info: #1a5fa8; --info-surface: #e8f0fa; --info-border: #b9d0ea; + +/* .dark — verified against --background #0d1117 and --card #161b22. + --danger is the existing --destructive, so it aliases rather than diverging. */ +--ok: #5ddba4; --warn: #e5b567; +--danger: #f85149; --info: #79b8ff; +``` + +Contrast of every value above, measured on both the page background and the card surface in its +own mode: + +| Token | light on bg / card | dark on bg / card | +|---|---|---| +| ok | 5.85 / 6.23 | 10.93 / 10.00 | +| warn | 5.36 / 5.71 | 10.04 / 9.17 | +| danger | 5.91 / 6.30 | 5.65 / 5.16 | +| info | 5.85 / 6.23 | 9.11 / 8.33 | + +All pass AA. Then three steps, in order: + +1. Register the ramps in `@theme inline` as `--color-ok` … `--color-info`, so `text-ok`, + `bg-warn-surface` and `border-danger-border` exist as ordinary utilities. +2. **Add them to `THEME_CSS_VARIABLES`** and give every theme values. This is the step that makes + the channel real; without it the tokens are just better-named hardcoding. Hand-authoring is + not viable at 79 themes, so they are derived per theme from its own background. +3. Replace the 158 literals, panel by panel, deciding what each one *meant* rather than + transliterating the hue. Some will turn out to have meant nothing, and should become + `muted-foreground`. + +One inconsistency to settle while in here: `--primary` is the rose `#ad5b68` in light and a plain +grey `#8b949e` in dark, so the interaction accent D1 depends on simply vanishes in dark mode. + +**Acceptance.** No raw Tailwind color literal remains in `apps/inspector/src`; a lint rule keeps it +that way. Every theme renders all four states at AA or better on its own background. Switching theme changes the state colors coherently instead of leaving them fixed. + +--- + +## 5. Cross-cutting findings + +These were found by reading Inspector's 25,146 lines across 13 surfaces. They are listed first +because each one is a single fix applied in many places, which is cheaper and more consistent than +fixing the same thing thirteen times with thirteen different solutions. + +**C0 is in §4**, with the design direction it implements. It comes before everything here: it is a +correctness bug in the color channel, and C2 depends on it. + +### C1 — Panel state does not survive navigation *(verified)* + +The highest-frequency tax in the app. Inspector is a tabbed tool people bounce between constantly, +and most panels forget everything the moment you leave. + +On the **default route**, `pages/log/LogTable.tsx:86-88`: + +```ts +const [search, setSearch] = useState(''); +const [typeFilter, setTypeFilter] = useState('all'); +const [followTail, setFollowTail] = useState(true); +``` + +Filter the log down to the one subsystem you care about, jump to Performance to check a number, +come back — you are staring at unfiltered output again and typing it a second time. + +Confirmed elsewhere: + +| Surface | Lost on navigation | Reference | +|---|---|---| +| Logs | search, type filter, follow-tail | `pages/log/LogTable.tsx:86-88` | +| Assets | search, and per-asset zoom/pan | `pages/assets/index.tsx:708`, `:522-523` | +| Performance | disk-usage toggle, follow-tail, paused | `pages/performance/index.tsx:313-315` | +| Compare, Time Travel, Session Replay | all local `useState`, no persistence or URL sync | — | + +Console and Debugger already do better, so the codebase contains its own answer. + +**Refinement.** Give panel state a home. Decide per piece of state which of three it is: **URL** +(shareable and back-button friendly — filters, search, selected item), **persisted per session** +(follows the game — expanded rows, per-asset zoom), or **genuinely ephemeral** (a dialog being +open). Then apply it uniformly. Console's session-scoped store is the working precedent; the +question to answer once, not per panel, is whether filters key off the session or are global. + +**Acceptance.** Set a filter in every panel, navigate away and back, and find it intact. Restart +the game and find the session-scoped ones intact. + +### C2 — Freshness is invisible *(verified)* + +Inspector has **45 refresh affordances** and essentially **zero** indications of when data was last +updated — `assets`, `observable`, `performance`, `plugins`, `compare` and `session-replay` have no +"last updated", "as of", or staleness marker at all. + +This directly contradicts **L1**, and it interacts badly with a deliberate design choice: panels +request fresh data when opened and then go dormant when you leave. That dormancy is correct — it is +what keeps Feather's runtime overhead low — but it means a panel you return to may be showing state +from ten minutes and three game restarts ago, rendered identically to live data. + +The user is given a button to fix a problem they cannot see. + +**Refinement.** Every panel that displays runtime state shows when that state arrived, and marks +itself visibly stale once it is dormant and the session has moved on. Prefer making the answer +obvious over adding another button — if a panel can cheaply tell it is stale, it should either +refresh or say so, not wait to be asked. + +**Acceptance.** Open a panel, leave it, restart the game, come back: it is unambiguous within one +second whether you are looking at the new run or the old one. + +### C3 — Diagnoses without a next action *(verified)* + +`pages/session/index.tsx:328-335`: + +> **Session disconnected** — "This session is selected but no longer receiving live runtime data." + +True, and it stops exactly where the user's question starts. Contrast with the empty state in §3, +which hands over a runnable command. The empty state proves the team knows how to do this; it just +has not been applied to every terminal state. + +**Refinement.** Audit every warning, error toast and empty state for a next action. Where one +exists, name it — ideally as something clickable or copyable, matching the empty state's approach +of embedding the user's real project directory rather than a placeholder. + +### C5 — Third-party data reaching first-party code *(verified)* + +Two crashes this work has produced came from the same root: a value typed as one thing and never +checked. The log search threw on `log.str.toLowerCase()` because `Log` promised a string that +nothing validated. `use-ws-connection` carries **18 casts** of inbound payloads — `data as Log`, +`data as PausedState`, `data as ProfilerState` — none of them checked. + +Auditing the consumers of those casts was more reassuring than expected, and the reason is worth +recording. Feather's own runtime is disciplined: the Lua debugger falls back to `"?"` rather than +sending a frame with no `file`, and filters C frames out entirely. Compare already coerces with +`String(item.key)`. The plugin content renderer guards with `Array.isArray` and `.every`. Three +candidate crash sites, two already defended. + +The one that was not defended is the one where the value comes from **outside this repo**. A plugin's +`tabName` is written in its own Lua manifest, and `if (value.tabName)` admits any truthy value — +so `tabName = 1234` gave the sidebar an item whose name was a number, and `.localeCompare` and +`.toLowerCase()` both ran on it. That list *is* the navigation, so a single odd manifest took the +whole app's sidebar down. + +**The rule this suggests:** blanket payload schemas are already declined (V4.md **D14**), and the +audit says that decision is holding — the runtime is careful with its own messages. The seam to be +strict at is narrower and clearer: **wherever a value authored outside this repository reaches +first-party rendering.** Plugin manifests are that seam. Coerce there, not everywhere. + +### C4 — Failures the user never learns about *(verified, needs triage)* + +44 instances of swallowed errors across Inspector. Some are correct — a clipboard write that fails +is not worth interrupting anyone. Others are not: + +```ts +// router.tsx:126,128 — runtime interest and refresh commands +}).catch(() => {}); +sendCommand(sessionId, command).catch(() => {}); +``` + +If those fail, panels quietly stop receiving the data they asked for and the user sees an empty +panel with no explanation — an L1 failure reached by a different route than C2. + +**Refinement.** Triage all 44. Each becomes one of: genuinely ignorable (leave it, with a comment +saying why), worth surfacing to the user, or worth surfacing only in a diagnostics view. The point +is that each one is a decision rather than a default. + +--- + +## 6. Feature-by-feature — Inspector + +Thirteen surfaces. Each gets its own pass, and a pass is not finished until its acceptance line is +demonstrably true. + +**Honesty about status.** §5 was derived from reading the whole app, so it applies everywhere. +The per-feature notes below are **starting points, not completed audits** — where something is +marked *verified* it was checked directly in this pass; everything else is the question to answer, +not a finding. Do not treat an unverified line as a defect until you have reproduced it. + +### The template + +Work each surface in this shape, and record the result in place: + +1. **Use it for real.** Run a game with `pnpm run up`, do the thing this panel is for, and note + every moment of friction before reading any code. This is the most valuable step and the easiest + to skip. +2. **Score it against L1–L6.** Name the specific failure, not a general impression. +3. **Check it at scale** (L5) with real data volumes. +4. **Propose refinements, ranked** by friction removed per unit of risk. +5. **Write the acceptance line** — how someone else confirms it is better. + +### 6.1 Logs — the default route + +The first thing every user sees and the highest-traffic surface in the product. + +- **Verified:** virtualized; retention engineered (§3); filters ephemeral (**C1**). +- Ask: is search substring-only, and is that enough at 40k lines? Can you get *out* of the tool — + copy a line, copy a stack, export a filtered view — without leaving the keyboard? When a log + arrives while you are scrolled up, what happens, and is follow-tail's behaviour obvious? +- **Acceptance:** find one line among tens of thousands, act on it, and return to a live tail + without re-typing anything. + +### 6.2 Session — health and identity + +Where users go when something is wrong, so its own clarity matters more than most. + +- **Verified:** the warning set is thoughtful; the protocol-mismatch nuance (§3) is a highlight; + the disconnected warning has no remedy (**C3**). +- Ask: are warnings ordered by what to do first, or by construction order? Is "Needs attention" + actionable at a glance? +- **Acceptance:** a user with a broken session reaches the right fix from this page alone. + +### 6.3 Performance and Profiler — 1,794 LOC, 4 files + +The largest surface after settings. + +- **Verified:** disk-usage, follow-tail and paused are ephemeral (**C1**); no freshness marker + (**C2**). +- Ask: is a capture's cost obvious *before* starting it? Are captures comparable across runs, given + that comparing before/after is the entire point of profiling? Does the panel distinguish "cheap + to leave open" from "actively measuring"? +- **Acceptance:** profile a change, compare against the previous run, and reach a conclusion without + exporting anything by hand. + +### 6.4 Debugger — 1,531 LOC + +- **Verified:** breakpoints and probes persist (§3); F10/F11 stepping and Cmd+F search are bound + (`pages/debugger/index.tsx:287,882-885`); the fewest empty-state strings of any large surface, + which is worth a look given how many states a debugger has. +- Ask: when source cannot be resolved, does it explain the root-path problem and offer the fix? + What does a breakpoint in a file the runtime never loads look like? Is the paused/running state + unmissable from anywhere in the app? +- **Acceptance:** set a conditional breakpoint in a 3,000-line file, hit it, inspect a frame, and + step out — mostly from the keyboard. + +### 6.5 Console + +Currently the best-behaved surface, and the reference for the others. + +- **Verified:** session-scoped persisted history, outputs and snippets; 200/100 caps. +- Ask: is multi-line editing comfortable? Are long results readable, or do they flood the pane? Is + the security gate explained where it is enforced, or only in settings? +- **Acceptance:** iterate on a snippet across a game restart without losing it. + +### 6.6 Assets + +- **Verified:** search and per-asset zoom/pan are ephemeral (**C1**); no freshness marker (**C2**). +- Ask: how does it behave on an atlas-heavy project (**L5**)? Is there a path from "this texture is + wrong" to "this is the file on disk"? +- **Acceptance:** locate a suspect asset and identify its source file without leaving the panel. + +### 6.7 Plugins — 1,529 LOC + +Renders generic UI for arbitrary Lua plugins, so its failure modes are other people's plugins. + +- Ask: what does a plugin that returns a malformed action or an unexpected control render as? + Is enable/disable state legible per session? Does the catalog distinguish "not installed", + "installed and off", and "incompatible"? +- **Acceptance:** a plugin author can tell from this page alone why their plugin is not appearing. + +### 6.8 Observability + +- **Verified:** no freshness marker (**C2**), which matters more here than anywhere — observed + values are exactly the thing you must not read stale. +- Ask: what happens when a value changes faster than the panel updates? Is history available, or + only the current value? +- **Acceptance:** watch a value across a state change and trust what you see. + +### 6.9 Time Travel · 6.10 Session Replay + +Grouped because they share the recorded-state model. + +- **Verified:** both keep all state in `useState` (**C1**); Session Replay carries the most toasts + of any panel (6) — worth checking against **L6**. +- Ask: is it obvious what is being recorded, at what cost, and how to get back to live? Can you + scrub to a moment quickly, or only step? +- **Acceptance:** reproduce a bug from a recording without re-running the game. + +### 6.11 Compare + +- **Verified:** all local state (**C1**); needs two connected sessions, and the command palette + already explains that ("Direct links show an empty state until two sessions are connected") — + check the panel itself is as clear. +- **Acceptance:** two sessions, one meaningful difference, found fast. + +### 6.12 Settings — 1,789 LOC, the largest single file + +- **Verified:** carries the most toasts (10); the MCP surface copy was corrected in this pass. +- Ask: is it findable — can someone locate a setting without scrolling all of it? Does every + security-relevant toggle explain its consequence at the point of decision? +- **Acceptance:** change a security-relevant setting and correctly predict what it did. + +### 6.13 About + +Small, and the only page that is purely informational. Check it is accurate after the v4 split +rather than aspirational, then leave it alone. + +--- + +## 7. Method + +**Use the tool before reading its code.** `pnpm run up ` brings up Inspector with a +connected game in one command. Friction you feel in ninety seconds of real use is worth more than +an hour of reading, and it is the only way to find the things that are annoying rather than wrong. + +**One surface per change.** A pass over Logs is a reviewable unit. A pass over "the UI" is not. + +**Cross-cutting fixes come first.** C1 and C2 touch most surfaces; solving them once, well, beats +thirteen local solutions that drift apart. + +**Write the acceptance line before the code.** If you cannot state how someone else would confirm +the improvement, the improvement is not defined yet. + +**`pnpm run verify:enhanced` is the gate.** It checks the property each finding claims to have +fixed — measured, not asserted — and prints the shortest manual route to seeing each one. A finding +without a check in `scripts/verify-enhancements.mjs` is not finished. A green run still does not +tell you whether the tool *feels* better, which is what the manual routes are for. + +**Every refinement is testable.** These are user-visible behaviours, which means Playwright can +hold them. Note the trap this repo already hit (V4.md **D43**): a test that asserts current +behaviour will lock in the very thing you are trying to change. Assert the property you want, not +the markup you have. + +**Watch for regressions in the things §3 lists.** They are good; leave them better or leave them +alone. + +--- + +## 8. Status board + +| # | Surface | Pass | Notes | +|---|---|---|---| +| D | Design direction (§4) | AGREED, NOT APPLIED | D1-D4; the rules the passes below are held to | +| C0 | Semantic color channel | **DONE** | Ramps derived per theme, AA-verified across all 79; literals replaced; lint rule holds it | +| C1 | Panel state persistence | **DONE** | 13 filters persisted globally; `pnpm run verify:enhanced` | +| C2 | Freshness and staleness | **DONE** | `useFreshness` + ``; judged against the session, not the clock | +| C3 | Next-action audit | **DONE** | Session warnings and the filtered-empty states across panels | +| C5 | Third-party data safety | **DONE (plugins)** | 18 casts audited; the one unguarded seam was plugin manifests | +| C4 | Swallowed-error triage | **MOSTLY DONE** | 44 → 27; 11 surface, 10 named deliberate; 27 left, mostly clipboard/invoke | +| 6.1 | Logs | **MOSTLY DONE** | follow-tail fixed; scale measured; export of a filtered view still open | +| 6.2 | Session | **DONE** | 9/9 warnings actionable (C3); ordered by severity, not construction order | +| 6.3 | Performance / Profiler | **DONE** | baseline survives a restart; recording state explicit and consistent with Time Travel | +| 6.4 | Debugger | **DONE** | problem counts navigable; source failure names the path and the fix; keyboard stepping pinned | +| 6.5 | Console | **REVIEWED, NO CHANGE** | long output already bounded with an expand; still the reference | +| 6.6 | Assets | **PARTLY DONE** | Missing filter now truthful at scale; source-file path still open | +| 6.7 | Plugins | **PARTLY DONE** | manifest values coerced (C5); generic renderer already well guarded | +| 6.8 | Observability | **REVIEWED, NO CHANGE** | runtime pushes while interested; C2 covers dormancy | +| 6.9 | Time Travel | **REVIEWED** | cost is shown and bounded; C0 regression in its indicators fixed | +| 6.10 | Session Replay | **REVIEWED** | the 6 toasts are export/import feedback, not noise | +| 6.11 | Compare | **DONE** | empty state names the command; stale-filter escape hatch | +| 6.12 | Settings | **DONE** | findable from the palette; security toggles already explained their consequence | +| 6.13 | About | **REVIEWED, NO CHANGE** | all four claims accurate after the split | + +**Inspector is closed.** Every surface above is either changed or reviewed and deliberately left +alone with the reason recorded. + +### Feather Studio (§9) + +| # | Step | Pass | Notes | +|---|---|---|---| +| S1 | Mount the providers | **DONE** | plus `@theme inline`, which was missing too — see V4-STUDIO §4 | +| S2 | Shell, and mount the tools | **DONE** | rail + tool surface; **Studio is usable** | +| S3 | Invert the session model | **DONE** | `useIsLocalMode`; creative vocabulary gone; `standalone` prop gone | +| S3b | Workbench layout for the other two tools | NOT STARTED | Texture Lab is a page, Particles are cards | +| S4 | Durable workspaces | NOT STARTED | losing a shader graph is not losing a filter | +| S5 | Apply C0–C5 | NOT STARTED | 86 raw color literals waiting | + +Order is dependency order: S2 is what makes Studio exist, and it needs S1 to look like anything. +V4-STUDIO §5 is the visual direction — *a workbench, not an instrument* — and S2 is where most of +it lands. It deliberately departs from §4's direction for Inspector, because Studio's users are +judging color they made and an interface with opinions about color lies to them. C0 first because it is a +correctness bug and because C2 needs a working color channel to build on. C1 and C2 then remove the +most friction per unit of work. Logs, Debugger and Performance are where users spend their time. + +--- + +## 9. Feather Studio — making it a tool you can use + +Inspector's board is closed, so Studio gets its section, as §10 said it would. The full +specification is [`V4-STUDIO.md`](./V4-STUDIO.md); this section is the summary. It needs a different +kind of work: Inspector was a working product with rough edges, and Studio is not yet a product at +all. + +### 9.1 What is actually there + +Four facts, each verified, and together they explain why Studio cannot be used today. + +**The shell never got built.** `StudioApp.tsx` is 60 lines and still the Phase 05b placeholder. It +renders a header, an environment panel, and this: + +> "The shader graph, texture lab and particle playground move here in Phase 05c. They are still +> served by Feather Inspector until then." + +They moved. **25,500 lines of them** sit in `apps/studio/src/tools/`, and nothing in Studio's shell +imports any of it. Phase 05c moved the code and never wired the app — and because the move itself +was verified (the LOC landed, the boundary held, the tests passed), nothing reported the gap. + +**The providers were never mounted.** *(Corrected 2026-09-10 while writing V4-STUDIO.md — the +first version of this paragraph said Studio had no design tokens. It has them.)* Studio owns a full +theme registry and a `ThemeProvider` that writes every token onto the root element. What +`main.tsx` mounts is `HostProvider` and nothing else — no `ThemeProvider`, no `QueryClientProvider`, +no `UiProvider`, all three of which the showcase mounts to render these same tools. So the tools +would come up with their 394 token references unresolved, and their React Query hooks throwing. +The fix is a provider stack, not a token system. + +**The tools still think like Inspector panels.** They branch on session state **24 times**, through +`isCreativeSession`, `sessionSupportsRuntime` and `activeSession`. That model came from Inspector, +where a "creative session" was the *exception*: a deliberately created gameless workspace, sitting +among real game sessions. + +**And Studio never creates one.** `createCreativeSessionId` exists in `session/index.ts` and is +called from nowhere; the store is only ever filled by `syncFromBridge` from Inspector. So in +standalone Studio `activeSession` is `null`, which makes `isCreativeSession` false and +`sessionSupportsRuntime` false — and the tools read that as *live mode with no game*, not as *local +authoring*. `ParticleSystemPlaygroundPage` computes `localMode = standalone || creativeSession`, +gets `false`, and reaches for the live controller that needs a game. The showcase works around this +by injecting its own controller and passing `standalone` by hand. + +### 9.2 The design + +**Studio is a local authoring tool that can optionally reach a running game.** + +That sentence is the whole design, and it is the inverse of what the code currently assumes. Today +"no game" is a degraded state the tools tolerate. In Studio it is the **normal, fully capable** +state — someone opens Studio to build a shader, and whether a game is running is beside the point +until they want to see it in one. + +Three consequences follow, and they settle most of the smaller questions: + +**D-S1 — Local is the default, not the fallback.** Studio always has a workspace. It never asks for +a session before letting you work, never renders a session-required empty state, and never disables +a control because no game is attached. The only things a game gates are the things that genuinely +need one: pushing to a live preview, and reading back from a running system. + +**D-S2 — A game connection is an addition, and its absence is not an error.** "Not connected" is a +neutral fact, styled like chrome, never `danger`. This is already right in the placeholder shell — +*"Optional. Studio works as a local editor without it"* — and that sentence should survive into the +real one, because it is the correct framing and it was written by someone who understood the product. + +**D-S3 — Work is kept, because losing it is unforgivable in an authoring tool.** Inspector can lose +a filter and cost you a retype. Studio losing a shader graph costs you an afternoon. Authoring state +is durable by default, per workspace, and survives restarts and crashes. This raises the bar above +anything in Inspector's C1. + +### 9.3 What to build, in order + +Each step is usable on its own, which matters because Studio is unusable until step 2 lands and +every step after that should keep it usable. + +**S1 — Mount the providers.** +`main.tsx` mounts `HostProvider` alone. `showcase/providers.tsx` renders the same tools successfully +with four providers, and is the working reference. Studio's own `ThemeProvider` already exists and +already writes the tokens; it is simply never mounted. + +*Acceptance:* a tool mounted in Studio renders with real card backgrounds, borders and muted text, +in light and dark. + +**S2 — Build the shell and mount the three tools.** +Navigation across Shader Graph, Particle Playground and Texture Lab. Texture Lab and Particle +Playground already export page components; Shader Graph is assembled from panels, and the showcase's +composition is the working reference for how. + +*Acceptance:* open Studio with no Inspector and no game, reach all three tools, and use each one. + +**S3 — Invert the session model.** +Replace the 24 `isCreativeSession` / `sessionSupportsRuntime` branches with one explicit mode +derived from the bridge: `local` (always available) or `attached` (a game is reachable). Delete +`createCreativeSessionId` and the creative-session vocabulary from Studio — it described Inspector's +exception, and in Studio it names the default, which is why the code reads backwards. The +`standalone` props and the showcase's controller injection go too: they are workarounds for the +model being wrong, and they disappear when it is right. + +*Acceptance:* no tool asks whether a session is "creative"; standalone Studio uses the local +controller because that is the default path, not because a prop said so. + +**S4 — Make workspaces real and durable.** +Texture Lab already has a workspace concept; the other two do not. One shared notion: named, listed, +switchable, saved on change. This is D-S3, and it is the difference between a demo and a tool. + +*Acceptance:* build something, force-quit Studio, reopen, and find it exactly as you left it. + +**S5 — Apply Inspector's answers without rediscovering them.** +C0 (semantic color) arrives with S1, and Studio has **86 raw Tailwind literals** waiting for the same +treatment. C1, C2, C3 and C5 all apply; the modules are already shared or trivially portable. +`verify:enhanced` gains a Studio section. + +*Acceptance:* `pnpm run verify:enhanced` covers Studio, and the lint rule that bans raw color +literals covers `apps/studio/src` too. + +### 9.4 What this is not + +- **Not new creative features.** No new node types, generators or emitters. Studio's feature set is + what moved out of Inspector; the work is making it reachable and dependable. +- **Not a redesign of the tools.** Their internals are 25,500 lines that work. The shell around them + is what is missing. +- **Not 79 themes.** See S1. + +--- + +## 10. Out of scope + +Recording these so they stay decided rather than being relitigated each time someone reads this. + +- **New features, panels and tools.** The rule in §1, as amended: completing a promise the + feature already makes is in scope; a new surface is not. +- **Redesigns.** Refinement means the user's existing muscle memory keeps working. If a change + requires relearning the panel, it is not this. +- **The v4 split.** V4.md owns it, and it is essentially complete. +- **Studio, CLI, and the extension.** They get their own sections in this document once Inspector + is done. Inspector is first because it is where users spend the most time and because it is the + product most likely to be abandoned over friction. + +--- + +## 11. Log + +Append findings, decisions and completed passes here so the next person inherits the reasoning and +not just the diff. + +**2026-09-09 — Document created.** Cross-cutting findings C1–C4 derived from a read of all 25,146 +lines of Inspector across 13 surfaces. Per-feature sections are starting points, explicitly not +completed audits. Nothing has been changed in the application yet. + +**2026-09-09 — C0 done.** The channel exists and is enforced. + +Rather than hand-author four ramps across 79 themes, they are derived: +`assets/theme/registry/semantic.ts` solves each state's lightness against the theme's own +background until it clears the contrast target, and `index.ts` applies that to every theme as it is +assembled. A new theme gets correct state colors for free; a theme that wants a specific value +still wins, because derivation never overwrites an authored one. + +Deriving was not sufficient on its own, and the test proved it. Solving against the page background +alone left `--danger` at 4.15:1 on `--danger-surface` — the chip pairing, which is a harder ground +because it is a tint of the same hue. 66 theme/state combinations failed that way. Foregrounds are +now solved against every ground they render on at once: background, card, and their own surface. +`scripts/tests/themeSemanticColors.test.ts` holds all of it, including that no two states collapse +to the same value. Across all 79 themes the minimum contrast is now 5.23:1 against AA's 4.5. + +Then the 311 literals. Most were mechanical, but the pass surfaced five places where color was +claiming something untrue, and those were the point of the exercise: + +- Plugin capability badges coloured a taxonomy as states — `filesystem` read as a warning, + `audio` as "ok". Replaced with the distinction that matters when auditing a plugin: filesystem + and network reach outside the game, everything else is chrome. +- The four debugger flow buttons were four different colors for four adjacent controls of the same + kind, and two had become state tokens claiming "Continue" is an ok state. All now chrome; the + icon and label already distinguish them. +- Profiler probe kinds likewise, where "stop" had become danger. `ProfilerProbeIcon` already gives + each kind its own icon. +- Performance metric cards carried six decorative hues shown only while selected, so a selected + frame-time card looked like a failure and disk usage like a warning whatever the numbers said. + Selection is interaction, so it is the accent, once. +- Lua value types and stack-trace highlighting are syntax, not state. They keep literals behind a + documented lint exemption; borrowing `--ok` for a number would make green mean "fine" in one + place and "number" in another. They belong in the theme's syntax palette — a later pass. + +Log badges moved from solid fills to tinted chips. The fills hand-picked a pair per mode with no +contrast guarantee on any theme; the chip pairing is the one the test asserts. It also reads better +in a dense log list, where a column of saturated blocks makes a real error harder to find. + +A `no-restricted-syntax` rule now rejects raw Tailwind color literals in `apps/inspector/src`, so +this cannot drift back. Full suite 7/7. + +**2026-09-09 — C1 done.** Panel state has a home: `store/panel-state.ts`, a persisted store behind +a `usePanelState` hook with `useState` ergonomics, so migrating a panel is one line per field. + +The decision the document said to make once: **global, not per session.** What someone is looking +for outlives the process they are looking at — you filter for `collision` because that is the bug, +and it is still the bug after a restart and in the next session you open to compare against. + +*(Corrected 2026-09-09: this originally argued that a restart mints a new session id. It does not +reliably — the runtime derives the session id from a device id persisted to disk, so a restart +usually reconnects as the same session. The decision holds; the reason first given for it was +wrong. Found while tracing §6.3.)* + +13 filters now persist across Logs, Assets, Performance, Compare, Observability, Debugger and +Console. Two deliberate exclusions, both because persisting would make the tool lie: Performance's +`paused`, since returning to a silently frozen chart is the stale-data trap, and Console's draft +`input`, since restoring a half-typed command into a console you can execute from is a worse +surprise than retyping it. + +Correction to §5: the finding listed Compare, Time Travel and Session Replay together as holding +ephemeral state that should persist. Compare does. Time Travel and Session Replay do not — reading +them, everything they hold is a scrub position, a loading flag or a record id, all of which are +genuinely ephemeral. Nothing to fix there. + +Persisting filters created a trap that had to be closed in the same change: a filter that outlives +the visit needs a way out as quick as the way in, or coming back looks like missing logs rather +than an active search. Log search gained a clear control, and Settings → Reset to defaults now +clears panel state too. + +**2026-09-09 — C2 done.** Panels can now say whether what they are showing is current. + +The judgement deliberately is not age. A value from 40 seconds ago is fine when the game has been +up an hour and wrong when it restarted five seconds ago, so freshness is measured against the +attached session's `connectedAt`, not the clock. Data that predates the session is from a previous +run, however recent it looks. `isFromPreviousRun` is a pure function so that judgement is testable +without a React tree, and `scripts/tests/freshness.test.ts` pins it — including that age alone never +marks data stale, because a warning that fires on a quiet panel trains people to ignore it. + +`` is quiet when the answer is boring — muted, small, no icon, because most of the time +it says "12s ago" and should be skippable — and loud when the data is from a previous run, which is +the case that sends someone chasing a bug in state that predates their fix. The refresh action is +offered only in that case: a refresh button you never needed is the noise L6 warns about. + +Wired into Assets and Observability first. Observability matters most — observed values are exactly +the data you cannot afford to read stale, since watching them change is the entire point. + +**2026-09-09 — C3 and C4.** + +C3 on the Session panel: all nine warnings now end with what to do, and the disconnected one carries +the run command built from the user's own configured project directory, the way the empty state +already did. The audit also found a warning that should not have existed: *Debugger attention* fired +on `pausedState || problems`, so sitting at a breakpoint — a thing you did deliberately — raised a +warning and degraded session health, in the middle of the work where noise costs most. Pausing is +state; only genuine sync and condition failures are warnings now. Other panels' empty states are +still to audit, so this is done for Session rather than done. + +C4 turned out to be two findings wearing one coat, and the fix was to name them. A bare +`.catch(() => {})` cannot say whether the silence was considered or merely convenient, so there are +now two senders. `sendUserCommand` surfaces failure and covers 11 sites the user reaches by pressing +a button — stop the recording, clear the logs, import a replay — where silence left them believing +something happened that did not. `sendBackgroundCommand` swallows *by design*, with the reasoning in +one place, and covers the `req:*` refreshes that are retried anyway and whose real signal is C2's +staleness indicator rather than a toast per hiccup. + +44 down to 27. The remainder are clipboard writes, `invoke` calls and plugin actions that still need +reading one at a time; the two named helpers mean any new bare swallow now stands out as untriaged. + +**2026-09-09 — Crash in the log search, reported from real use.** +`TypeError: undefined is not an object (evaluating 'log.str.toLowerCase')` on the first keystroke. + +The cause was a type that was never true. `schema` declared `str: z.string()`, but nothing anywhere +validated against it — `parseLogLine` did `JSON.parse` and cast the result, and the WebSocket path +casts too. So a runtime that omitted `str` produced a `Log` whose `str` was `undefined`, typed as a +string, and the filter dereferenced it. + +Fixed at both levels. The schema now defaults `str` and `trace` rather than requiring them, and +`parseLogLine` actually parses through it — a log line missing its message is still evidence +something happened, and dropping it would hide exactly the malformed output someone is hunting. The +matching logic moved to `pages/log/search.ts` with no React in it and is defensive regardless, +because the WebSocket path still casts and searching is the last place that should be strict about +shape: it is what people reach for when the output already looks wrong. + +Two things worth remembering from this one. First, C1 did not cause the crash but made it far more +reachable — a persisted filter is non-empty across much more of the app's life than one that reset +on every navigation. Persisting state raises the cost of every latent bug in the code that state +feeds. Second, **the first regression test I wrote was a false pass.** It seeded +`feather-log-history` with a `buckets` array; the real shape is `logsBySession` keyed by id, so no +malformed log ever reached the filter and the test went green against the unfixed code. Reintroducing +the bug to watch the test fail is what caught it. A test that has never failed has not been tested. + +**2026-09-09 — 6.1 Logs.** + +The panel's three open questions, answered. + +**Scale (L5) is fine, and that is the finding.** Measured over a realistic typing burst: 4.3 ms per +keystroke across 40,000 logs, 1.1 ms at 10,000. No debouncing or deferred value was added, because +adding either would have been complexity bought against a cost that is not there. Worth recording so +the next person does not optimise it on instinct. + +**Follow-tail was fighting the user.** Virtuoso's `followOutput` already behaves correctly — its own +documentation says it "scrolls down only if the list is already at the bottom" — but an effect +alongside it called `scrollToIndex` unconditionally on every arrival, overriding that. Scroll up to +read something during a chatty session and the next log line dragged you back to the tail. Reading +older output while a game was running was close to impossible, and the fix was deleting code rather +than adding it. The jump-to-tail on a *filter* change is kept and now keyed only on the filter, +because that is a deliberate act where landing at the tail is what you want. + +**Getting data out is partly solved.** `CopyButton` covers the message, the stack and the full JSON — +but only from the side panel, after selecting a log. There is no copy from the list itself and no +export of a filtered view, which is what the acceptance line asks for. Left open. + +One honest gap. I could not build a browser test for the arrival case: the e2e harness's +`emitMessage` did not deliver a `type: 'log'` message to the list in the time I gave it, and the +first draft of the test would have passed vacuously for exactly that reason. The precondition +assertion I added caught it — asserting the injected log actually arrives before checking scroll — +so the test failed loudly instead of lying. Rather than ship it, the check in `verify:enhanced` is a +static one, labelled as static: it asserts the effect is no longer keyed on arrivals and that +`followOutput` is still passed. The behaviour itself rests on the library's documented contract and +on reading the code. That is weaker than a browser test and is recorded as such. + +**2026-09-09 — 6.4 Debugger, first pass.** + +The panel asked "what does a breakpoint in a file the runtime never loads look like?" and the answer +was: a badge reading **"3 rejected"**, with no route to which three, in which files, or why. The +condition-error badge was marginally better — the runtime's message was in a `title` tooltip, but +only for the most recent one, and only on hover. A count you cannot open is a dead end: it tells you +something is wrong and then refuses to say what. + +Both are now openable and list each issue with its file, line and the runtime's own reason, and +selecting one jumps to that file and line — reusing the same navigation the stack-frame list already +uses, so it is not a new interaction to learn. + +Rejections moved from `destructive` to `warn` while in here. A breakpoint the runtime declined will +not fire, which is worth knowing, but nothing has crashed. Reserving danger for actual failures is +what keeps danger meaning something. + +The e2e test was verified the way the last one should have been: reverted to the old dead-end badge +first, confirmed it failed, then restored the fix. Still open on this surface: what source resolution +failure looks like, and whether it explains the root-path problem and offers the fix. + +**2026-09-09 — 6.4 Debugger finished.** The two questions I had left open, answered rather than +deferred. + +**Source resolution failure.** The read was `readTextFile(absPath).catch(() => { setFileContent(null) })` +— one of C4's remaining swallows, discarding both the path it tried and the reason it failed. The +panel then said "File content is unavailable. Open the project folder or select a readable .lua +file." for two different situations: nothing selected yet, which is normal, and a file it genuinely +could not read, which is not. + +Those are now separate. Nothing selected says so plainly. A failed read shows the **resolved +absolute path** it tried, the runtime's own reason, and a button that opens the folder picker — +plus the sentence that actually helps: the project root is inferred from the running game's config, +so it is usually what is wrong. Showing the path is the whole trick; one glance tells you whether +the root is wrong or the filename is, which no amount of generic wording does. + +**The keyboard acceptance line.** I had called this untestable and needing a real session. That was +wrong — Playwright drives a keyboard. All four flow controls are bound (F8 continue, F10 over, F11 +into, Shift+F11 out) and now pinned by a test that presses each one while paused and asserts the +matching `cmd:debugger:*` reaches the game. A second test covers the guard that matters more: a +stepping key must not fire while you are typing in a field. Both were verified by breaking them — +removing `isEditableTarget` from the guard makes the second fail with "a stepping key fired while +typing in a text field". + +What genuinely still needs a person: whether setting a *conditional* breakpoint and inspecting a +frame are reachable without the mouse. They are context-menu and click driven today. That is a +gap worth closing, but it is an addition to the keyboard surface rather than a refinement of it, so +it goes on the list rather than into this pass. + +**2026-09-09 — 6.4 keyboard completion, and an amendment to the rule.** + +The owner widened §1: a small addition that completes something already half-present counts as +refinement. The distinction that makes it safe is whether the feature already makes a promise it +does not keep. A debugger with F8/F10/F11 bound and no keyboard route to a breakpoint is not missing +a feature — it is half-delivering the one it has, and a user who reaches for F9 out of habit finds +nothing there. + +**F9 toggles a breakpoint, Shift+F9 edits its condition**, both acting on the line you are paused +at. Those are the bindings every editor uses, so most people will try them before reading anything. +Acting on the paused line is what makes it work without inventing a cursor concept the source view +does not have — and the paused line is the line you care about when paused. + +Why the gutter needed this at all: it is one button per line. The frames list is already reachable +by Tab and Enter because the frames are real buttons and a call stack is short, so that half of the +acceptance line was already met. The gutter is not — reaching line 1,847 of a real `main.lua` means +1,847 tab stops, which is reachable in principle and unusable in practice. That is an L4 failure, +not an accessibility footnote. + +The shortcut is advertised in the gutter tooltip **only on the paused line**, because that is the +only line it can act on. Promising a key that does nothing would be worse than saying nothing. + +Two smaller things fell out. The condition dialog had no accessible description — Radix warns about +it, and the warning is right: announcing only "Breakpoint condition — line 42" leaves a screen +reader user to guess what the field wants. And the first version of the test chained toggle-on, +dialog, toggle-off, which failed because focus after the dialog closed blocked the last key; splitting +them tests each behaviour rather than their interaction. Verified by disabling the F9 branch and +watching it fail. + +**2026-09-09 — 6.3 Performance / Profiler, first pass.** + +The sharp question was whether captures are comparable across runs. They were not, and the reason +was a one-line omission rather than a design gap. + +When a session id changes for the same device, `use-ws-connection` migrates the cached data across: +logs, performance metrics, observers, assets. Someone added assets to that list at some point. Nobody +added the profiler. So the cleanup that follows dropped every snapshot — and comparing before against +after is the entire point of a profiler, while making the change being measured means restarting the +game. The one comparison the feature exists for was the one it could not make, and it failed silently, +with less critical data carefully preserved either side of it. + +Only the snapshots travel. Live capture state stays behind, and that distinction is the part worth +having tested: carrying `recording: true` into a fresh run shows a capture in progress that nothing +is filling, and carrying the elapsed time attributes the old run's duration to the new one. Both are +the tool lying about what it is measuring, which is worse than losing the baseline. The judgement +lives in `profilerStateForNewSession` as a pure function, pinned by +`scripts/tests/profilerCarryOver.test.ts`, and verified by reverting it to the old behaviour and +watching two of the four assertions fail. + +**A correction this pass forced.** C1's rationale was wrong. I wrote — in the store's own comment +and in this log — that filters must be global because "a restart mints a new session id". Tracing +the migration showed the runtime derives its session id from a device id it persists to disk, so a +restart usually reconnects as the *same* session. The decision is still right for a better reason: +what someone is looking for outlives the process they are looking at. Both places now say that +instead. Worth noting how it surfaced — not by re-reading C1, but by working on something adjacent +that happened to touch the same mechanism. + +Still open on this surface: whether a capture's cost is obvious before starting it, and whether the +panel distinguishes "cheap to leave open" from "actively measuring". + +**2026-09-09 — C5, found by asking why the same bug happened twice.** + +The log-search crash and the plugin-manifest crash share a root: a value typed as one thing and +never checked. `use-ws-connection` holds 18 casts of inbound payloads, none validated, so the +question was how many are live hazards. + +Fewer than feared, and the reason is the finding. Feather's own runtime is disciplined about what it +sends — the Lua debugger falls back to `"?"` rather than emitting a frame without a `file`, and +filters C frames out entirely; Compare already coerces with `String(item.key)`; the plugin content +renderer guards with `Array.isArray` and `.every`. Three candidate crash sites, two already +defended. V4.md **D14** declined blanket payload schemas, and this audit says that call is holding. + +The undefended one was the only one whose value is authored **outside this repository**. A plugin's +`tabName` comes from its own Lua manifest, and the truthiness gate admitted any truthy value, so +`tabName = 1234` produced a nav item whose name was a number — then `.localeCompare` sorted it and +`.toLowerCase()` searched it. That list is the sidebar: one third-party plugin with an odd manifest +took navigation down for the entire app. + +So the rule is narrower than "validate everything" and sharper than "trust the runtime": be strict +**where somebody else's code decides what we render**. `pluginNavItems` is now a pure module doing +exactly that, and it keeps the odd plugin visible rather than hiding it — showing "1234" is a better +answer than silently dropping a plugin that exists. + +I could not get an e2e green for this one; the sidebar did not pick up an injected config through +two different seed helpers, and rather than keep guessing I extracted the logic and tested it +directly. That has now been the resolution three times in this work — extract the judgement, test the +judgement — and it is faster than fighting the harness every time. + +**2026-09-10 — 6.6 Assets, plus two surfaces reviewed and left alone.** + +Assets offers a **Missing** filter: which of this project's assets are not on disk. The check behind +it ran over `paths.slice(0, 250)` and presented the result as complete. On any project with more +assets than that, a file absent from disk at position 400 was never checked, never flagged, and +never appeared under the filter — the panel answered "is anything missing?" with a confident, +incomplete "no". That is the L1 failure the lens calls non-negotiable, and it is worse than not +offering the answer at all, because the confident answer stops you looking. + +The cap existed for a real reason, which is why the fix is not simply deleting it: one `stat` per +asset fired at once on a large project exhausts file handles. `findMissingPaths` checks everything +in bounded batches of 64, and re-checks cancellation *between* batches — on a big project this work +outlives the panel that started it, and someone who navigated away should not keep the filesystem +busy. All three properties are pinned by tests, including that batching did not quietly collapse to +serial. + +Two surfaces reviewed and deliberately unchanged, which is worth recording so nobody re-opens them: + +- **Observability.** The question was what happens when a value changes faster than the panel + updates. It does not poll — the runtime pushes while the panel declares interest through + `cmd:runtime:interest`, so it is live while open, and C2's indicator now covers the dormant case. + Complete as it stands. +- **Session Replay.** Flagged earlier for carrying the most toasts of any panel (6) against L6. They + are three success/failure pairs for export and import: user-initiated file operations whose + outcome is invisible otherwise. That is exactly what a toast is for. Not noise. + +**2026-09-10 — 6.11 Compare, 6.5 Console, and a gap C1 created.** + +Compare's empty state said "Compare needs at least two connected sessions / Connect another session +to compare runtime data." That names the requirement, not the act — a second session means a second +game, and the app has known the command for that since before this work began. It now shows +`feather run `, the same shape the no-session empty state uses. + +**A gap this work introduced, found by auditing for C3 rather than by anyone hitting it.** Two panels +report "No rows match the current filters." That was self-explanatory when a filter was always +something you had just typed. C1 made filters outlive the visit, so you can now arrive at an empty +panel because of a filter set days ago, on a different game, and the message tells you the cause +while offering no way out. Both now carry a **Clear filters** button, and `TriageEmptyState` gained +an `action` slot so the next one does too. + +Worth noting the shape of that mistake: C1 was a good change that quietly moved a cost somewhere +else. Persisting state does not only preserve what the user wanted — it preserves what they forgot, +and every state that reads persisted state inherits that. + +Console reviewed and unchanged. The open question was whether long results flood the pane; they do +not — output is capped at a length limit, marked as truncated, and expandable. It remains the +reference implementation the other panels were measured against. + +**2026-09-10 — 6.9 Time Travel, and a regression C0 caused.** + +Time Travel answers its own questions better than expected. Before you start it says what it records +and why; while recording it shows `frames / bufferSize (pct%)` against a bounded ring buffer, which +is the reassurance that matters — the cost is visible *and* capped. Nothing to change there. + +What I found instead was **my own C0 pass degrading the signal it was supposed to protect**. The +mechanical migration mapped `bg-red-500` to `bg-danger-surface` everywhere, but a surface is a tint +designed to sit *behind* text. On a 2px dot it is close to invisible. So the recording indicator, the +progress bar fill, and eleven other status dots across Compare, Observability, the profiler and the +debugger were all quietly washed out. + +The distinction the migration missed: **row and region tints want the surface; dots and bars want the +foreground.** Both are correct uses of the same ramp, and only the second was broken. 13 indicators +restored, row tints left alone, and `verify:enhanced` now fails on any `bg-*-surface` that sits on a +small round element or a bar fill. + +Worth being blunt about how this happened. C0's own log entry said the mechanical part was mostly +safe and listed five places where color was *claiming something untrue*. It did not consider that a +correct token could be applied to the wrong role — the token was right, the surface/foreground choice +was not, and nothing in the type system or the lint rule could see it. That class of error only +shows up by looking at what a screen renders. + +**2026-09-10 — 6.3 finished: the two questions I left open.** + +*Is a capture's cost obvious before starting it?* Partly, and it did not need new work. The panel +reports Elapsed, Samples and Total while a capture runs, and Performance carries a Feather overhead +panel showing ms/frame and budget misses. The runtime measures its own cost per feature — gc, +logger, observers, performance, profiler, transport — so the honest answer is that cost is visible +*during*, not *before*. Predicting it beforehand would mean new instrumentation, which is a feature +rather than a refinement. + +*Does the panel distinguish "cheap to leave open" from "actively measuring"?* Yes, explicitly: +"Recording capture" against "Capture stopped", with a dot beside it. + +What the question surfaced was a **consistency bug between two panels**. The profiler marked +recording with green; Time Travel marked it with red. The same concept, in opposite colours, in one +product. Neither reading was right: a capture in progress is not "fine" — it is costing frame time +and waiting to be stopped — and it is not a failure either. Both now use **warn**, which is the +state that means *this is on and wants your attention*, and keeping danger for real failures is what +keeps danger meaning something. That is the same call made for rejected breakpoints in 6.4, which is +the point: with a real semantic channel these questions have consistent answers instead of +per-panel taste. + +**2026-09-10 — 6.12 Settings and 6.13 About.** + +Settings' security question turned out to be answered already: the MCP toggle, the one with the +widest consequence, says at the point of decision that it "allows local MCP clients to inspect and +control live Feather sessions through a token-protected localhost bridge". It names the capability +granted and the protection around it. Nothing to add. + +Findability was the real gap, and the palette was the right place to fix it rather than Settings. +The command palette advertised "pages, plugins, snippets, sessions, docs" and could not find a +single setting — 1,789 lines of them across four sections, reachable only by opening the dialog and +hunting. Under §1's amendment that is a promise half kept, not a missing feature. + +Entries are derived from `settingsTabs` rather than copied from it, so a new section appears in the +palette without anyone remembering. Section granularity is deliberate: enumerating individual +toggles would drift the first time one is renamed and lands you in the same place anyway. + +**The interesting part was what the existing tests caught.** Making the dialog open at a chosen +section meant making its tabs controlled, which had two consequences I did not intend. A persisted +store written before the field existed rehydrated without it, and a controlled `Tabs` given +`undefined` renders *no section at all* — adding a field to a persisted store is a migration whether +or not anyone calls it one. And persisting the field meant Settings reopened wherever you last +finished, which `persists settings changes across reloads` failed on immediately. + +The second was the more interesting question, and the answer came from C1's own rule: state belongs +in storage if the user chose it and would be annoyed to choose it again. Which settings section you +were last on is not that — you open Settings *for* a reason each time. So it is navigation for the +life of the app run, which is enough for the palette to point at a section, and it resets after +that. The test that caught it had encoded the right expectation all along. + +About reviewed and unchanged: all four claims still hold after the split. "Inspect creative systems" +reads oddly at first now, but it describes inspecting the *game's* shader and particle systems +through the Lua plugins, which is a different thing from authoring them in Studio — and still true. + +**2026-09-10 — 6.2 Session finished.** + +The remaining question was whether warnings are ordered by what to do first or by the order the +checks happen to run. It was the latter, and the order was actively unhelpful: "Session disconnected" +is constructed first, so it sat above "Review security" every time. The page led with the thing you +already knew — you can see the game is not running — and buried the one you did not. + +Now sorted danger before warning, stable within a tone so related checks stay grouped. Combined with +C3 giving all nine an action, the panel now answers "what do I do?" in reading order. + +That closes every surface on the board. Inspector's thirteen panels are each either changed, +or reviewed and deliberately left alone with the reason recorded. + +**2026-09-10 — Studio designed (§9). It is not a product yet, and the reason is instructive.** + +Four verified facts, and the last one is the interesting one. + +`StudioApp.tsx` is still the Phase 05b placeholder, telling the reader the tools "move here in Phase +05c" while 25,500 lines of them sit in `apps/studio/src/tools/` that nothing in the shell imports. +The tools reference shadcn tokens 394 times and Studio's stylesheet defines none of them, so even a +wired shell would render every surface and border unresolved. And the tools branch on session state +24 times through a concept — "creative session" — that Studio never creates, so standalone Studio +reads as *live mode with no game* rather than *local authoring*, and reaches for a controller that +needs a game. + +The instructive part: **Phase 05c was verified and passed.** The 25,502 LOC landed, the boundary +held, the lint guards stood, every suite went green. What nobody checked was whether the application +could be opened and used, because the phase was defined as a move and the move was real. A migration +that is complete by its own definition can still leave a product that does not exist. + +The design is one sentence: **Studio is a local authoring tool that can optionally reach a running +game** — the inverse of what the code assumes. Today "no game" is a degraded state the tools +tolerate; in Studio it is the normal, fully capable one. Three consequences (local is the default, +a connection is an addition whose absence is not an error, and authoring work is durable because +losing a shader graph is not losing a filter), and five steps in dependency order. + +Worth keeping from the placeholder: *"Optional. Studio works as a local editor without it."* That +sentence is the correct framing of the whole product and it was already written, by someone who +understood it, in the file that otherwise says the tools have not arrived. + +**2026-09-09 — §4 design direction added, and C0 found while writing it.** The question was which +visual style suits Inspector; the answer turned out to matter less than what the audit for it +turned up. Bento and neo-brutalism were both considered and rejected for recorded reasons. The +direction is D1–D4: color as a reserved channel, typography for hierarchy, rules over cards, motion +only for causality — an instrument rather than an interface. + +C0 is the finding: four meanings expressed by 54 different Tailwind literals, none of them +reachable by the 28 shipped themes, several failing WCAG AA on the very backgrounds those themes +set — including `red-600` on Inspector's own default light ground. Proposed replacement tokens were +contrast-checked before being written down rather than after. Still no application code changed. diff --git a/V4-PARTICLES.md b/V4-PARTICLES.md new file mode 100644 index 00000000..a566cc2d --- /dev/null +++ b/V4-PARTICLES.md @@ -0,0 +1,688 @@ +# V4 — Particles: architecture + +*Written 2026-09-10, after the owner's report: "particles are supposed to be efficient… ideally most +of the work should be done by the gpu, and the cpu only work as orchestration… also, the exported +code should behave exactly as the preview."* + +--- + +## 0. How to use this document + +§1–§4 are findings — every number in them was measured in this repository, and the commands are +given so you can re-measure rather than trust. §5 is the architecture. §6 is the work, in order. +Do not start §6 before reading §2: it corrects the premise the report was written on, and an +architecture built on the uncorrected version would optimise the wrong thing. + +--- + +## 1. The situation + +Particles is the largest tool in Studio and the only one whose output is *code someone ships*. It is +also the one with **four** separate implementations of the same behaviour — three in Lua, plus the +TypeScript curve editor (`easing.ts`, 129 lines) that draws the curve you author: + +| # | Implementation | Lines | What it drives | +|---|---|---|---| +| 1 | `plugins/particle-system-playground/init.lua` | 4609 | The live preview **in an attached game** | +| 2 | `example/showcase_preview/main.lua` | 1237 | The love.js preview **Studio shows you** | +| 3 | `_generateCode()`, inside #1 as string literals | ~950 | The **exported** Lua someone ships | + +Only a fragment is shared: `timeline_runtime.lua` (303 lines) is required by #1, and embedded into #3 +*conditionally*. #2 shares nothing — it defines its own `easeTimelineValue`, its own +`evaluateKeyframes`, its own `normalizeTimeline`, its own emission gating. + +The tool next door already solved this. `showcase_preview/main.lua:47` reads +`local PreviewRuntime = require("shader-graph.preview_runtime")` — Shader Graph has one preview +runtime and the preview requires it. Particles has three copies and requires none of them. + +**This is why the export does not match the preview.** It is not a bug to be found; it is the +arrangement. + +--- + +## 2. Correcting the premise + +> "particles system are suposed to be efficient, they work mostly on the gpu" + +**Not in LÖVE 11.7.** `love.graphics.ParticleSystem` simulates on the **CPU, in C++**: position, +velocity, acceleration, damping, size/colour interpolation, spin and lifetime are all stepped in +native code inside `ps:update(dt)`. The GPU's role is the *draw* — the system's particles are +uploaded as one batched vertex buffer and issued as a single draw call, and any per-pixel work is +whatever shader is bound. LÖVE 11.7 has no compute shaders; GPU-resident particle simulation is not +available to us. + +So the goal is not "move simulation to the GPU". It is three separate things, and keeping them +separate is what makes the work tractable: + +**(a) No per-particle work in Lua.** Every particle attribute LÖVE can interpolate natively is one +it steps in C++ across the whole buffer. Re-implementing any of it in Lua replaces a tight native +loop with an interpreted one. *Already true today* — §4.1. + +**(b) No per-frame work in Lua that the value does not need.** This is where the CPU is actually +being spent, and it is entirely avoidable. §4.2. + +**(c) The GPU does the varying.** The one genuine GPU lever is the bound shader: per-particle +variation that would otherwise need N systems or Lua bookkeeping can often be a function of the +particle's own attributes in the vertex/pixel stage. §4.4. + +The owner's instinct — *"the cpu only work as orchestration whenever is possible, making sure things +are warmed, object pooling"* — is exactly right. It just lands on (b) and (c), not on moving +simulation anywhere. + +--- + +## 3. The principle + +**P1 — One program, three hosts.** The preview, the in-game plugin and the exported file must run +*the same code* deciding *the same things*. Where they differ, they differ in what they are attached +to (a canvas, a running game, a shipped project), never in what a keyframe means. This is the whole +of the owner's second requirement, and no amount of careful re-implementation substitutes for it. + +**P2 — The runtime simulates; Lua orchestrates.** Lua's job per frame is to decide *what changed* +and tell the particle system. If nothing changed, Lua's job that frame is nothing. + +**P3 — Exported code is the product.** It is the only artefact that outlives the session. It should +be code a competent LÖVE developer would be content to have written: no dead branches, no +`pcall` around calls that cannot fail, no configuration the project does not use. + +--- + +## 4. The findings + +### 4.1 Per-particle work is already native — that part is fine + +The plugin uses the built-in properties rather than re-implementing them. Measured: + +```sh +grep -ohE 'set[A-Z][A-Za-z]+' packages/runtime-lua/plugins/particle-system-playground/init.lua \ + | sort | uniq -c | sort -rn | head -20 +``` + +`setColors` (19), `setSizes` (24), `setLinearAcceleration` (16), `setLinearDamping` (15), +`setSizeVariation` (14), `setEmissionArea` (15), `setSpin` (6), `setQuads` (8), +`setRadialAcceleration` (5), `setTangentialAcceleration` (5), `setRelativeRotation` (4). Colour +gradients and size curves go through `setColors`/`setSizes`, which is the native path. + +**No change needed here.** Say so plainly rather than rewriting it. + +### 4.2 Per-frame work is unconditional — wasteful, but smaller than it looks + +`Runtime.applyTimelineToEmitter` (`timeline_runtime.lua:227`) runs **once per emitter per frame** +and, regardless of whether anything changed: + +- evaluates **8 lanes** through `evaluateKeyframes` → `easeTimelineValue`, a 25-branch if-chain +- calls **6+ setters**: `setEmissionRate`, `setSpeed`, `setSizes`, `setDirection`, `setSpread`, + `setOffset` +- allocates a fresh `sizes` table and `unpack`s it for `setSizes`, every frame +- routes every one of those through `callParticle`, which is `pcall` + +For a composite with a constant emission rate and no keyframes at all, that is the full cost every +frame for a result that never changes. A 6-emitter composite at 60fps is ~2,880 evaluations and +~2,160 pcall'd setter calls per second to produce identical values. + +**`pcall` density**, measured: + +```sh +grep -c pcall packages/runtime-lua/plugins/particle-system-playground/init.lua # 73 +grep -c pcall packages/runtime-lua/example/showcase_preview/main.lua # 54 +``` + +Under LuaJIT `pcall` allocates and blocks inlining across the call. On the hot path +(`updateParticleSystems` does `pcall(ps.update, ps, dt)` per system per frame) it is paying an +allocation to guard a call that cannot fail — `ps` is a ParticleSystem the plugin constructed. + +It also swallows every error silently, which is V4-ENHANCED **C4** in a hot loop. + +**Measured, rather than assumed.** Benchmarked in LÖVE's own LuaJIT, running the real +`applyTimelineToEmitter` against a mock ParticleSystem, 600 frames, 8 keyframed lanes per emitter: + +| Scene | Now | Baked LUT + change detection | +|---|---|---| +| 6 emitters, curved | 0.045 ms/f · **1,660 KB** · 3600 calls | 0.006 ms/f · 1 KB · 3600 calls | +| 6 emitters, **constant lanes** | 0.017 ms/f · **1,660 KB** · 3600 calls | 0.005 ms/f · 1 KB · **6 calls** | +| 20 emitters, curved | 0.141 ms/f · **5,532 KB** · 3600 calls | 0.019 ms/f · 5 KB · 3600 calls | +| 50 emitters, curved | 0.351 ms/f · **13,830 KB** · 3600 calls | 0.051 ms/f · 12 KB · 3600 calls | + +*(KB is total allocation over the 600 frames, measured with the collector stopped. An earlier +version of this table measured it with GC running and reported figures that swung 14x between runs +— that number was meaningless and is corrected here.)* + +**An earlier draft of this section called this "the real cost". The measurement says otherwise, and +the correction matters for what P2 is worth doing *for*.** At 60fps the budget is 16.667 ms/frame: +six emitters cost **0.28%** of it, fifty cost **2.1%**. Curve evaluation is not what makes frames +late. + +What the numbers indict is **allocation**, by three orders of magnitude. Six emitters churn +**1.66 MB per 10 seconds** (166 KB/s); fifty churn **13.8 MB** (1.38 MB/s). A fresh `sizes` table +and its `unpack` every frame, per emitter, plus the result table `applyTimelineToEmitter` returns +and nobody keeps. + +The damning row is the second one: **a composite whose lanes are all constant allocates exactly as +much as one that is fully keyframed** — 1,660 KB either way. The garbage is unconditional. It has +nothing to do with whether the animation is doing anything. + +GC pressure is what produces the hitches people feel; steady-state microseconds are not. Baking +drops the allocation to ~1 KB — near enough to zero — and change detection drops 3,600 setter calls +to **6**. + +**One caveat on the platform.** `jit.status()` returns **false** on arm64 macOS — LuaJIT cannot +JIT-compile there (W^X), so LÖVE runs the interpreter. These figures are therefore the *pessimistic* +case, and they are the case the owner's own machine is in. On x86_64 players the same Lua is +compiled and cheaper still, which reinforces the conclusion: this is a cleanliness and allocation +fix, not a frame-rate rescue. + +```sh +# reproduce: scripts/bench-particle-timeline.lua, run under LÖVE +love scripts/bench-particle-timeline +``` + +### 4.3 The export does not match the preview — verified, not suspected + +`timeline_runtime.lua` clamps four values. The preview Studio shows clamps **none**: + +```sh +grep -nE "math.max\(0, Runtime.evaluateKeyframes|clamp\(Runtime.evaluateKeyframes" \ + packages/runtime-lua/plugins/particle-system-playground/timeline_runtime.lua # 4 hits +grep -nE "math.max\(0, evaluateKeyframes|clamp\(evaluateKeyframes" \ + packages/runtime-lua/example/showcase_preview/main.lua # 0 hits +``` + +| Value | plugin + export | preview | +|---|---|---| +| `emissionRate` | `math.max(0, …)` | unclamped | +| `speedScale` | `math.max(0, …)` | unclamped | +| `sizeScale` | `math.max(0, …)` | unclamped | +| `opacity` | `clamp(…, 0, 1)` | unclamped | + +This is reachable with ordinary authoring, because the overshoot easings are *supposed* to leave +[0,1]. For a lane keyframed 0 → 1: + +| Easing | min | max | +|---|---|---| +| `inBack` | **-0.1000** | 1.0 | +| `outBack` | 0.0 | **1.1000** | +| `inElastic` | **-0.3731** | 1.0 | +| `outElastic` | 0.0 | **1.3731** | + +So a `sizeScale` lane with `inElastic` drives sizes to **-37%** in the preview and **0** in the +exported code. The user sees one thing and ships another, with no warning, from a stock easing. + +**A second divergence, in the opposite direction:** the exported code pools instances +(`pooledInstances`, `MAX_POOLED_INSTANCES` — `init.lua:3639,4382`). Neither the live plugin nor the +preview pools at all. So the export has allocation behaviour the preview cannot show you. + +### 4.4 Batching is given up in one specific place + +Atlas `playback = "variants"` (`init.lua:587`) constructs **up to 16 additional ParticleSystems**, +one per atlas frame, and the draw path loops `for _, ps in ipairs(atlasSystems(system))` issuing a +separate `love.graphics.draw` for each (`init.lua:2482`). One emitter becomes 16 draw calls and 16 +`ps:update(dt)` calls. + +This is the one place where the GPU lever in §2(c) applies directly: picking a random atlas frame +per particle is a texture-coordinate offset, which is a shader's job. `setQuads` already handles the +*animated* case natively in one system; only `variants` fans out. + +### 4.5 Warming is authored, exported, and honoured by nobody + +*(An earlier draft of this section said "nothing is warmed", which was wrong — the mechanism is +already there. What is missing is worse: it is wired up everywhere except where it counts.)* + +`kickStartSteps` and `kickStartDt` are real authored fields — they are in the Studio type, in the +project file, and in the export. The names come from Hot Particles, whose model this tool's data +was derived from. Where they are actually honoured: + +| Path | References | Applied? | +|---|---|---| +| Studio TS | 7 | authored, sent | +| plugin `init.lua` | 9 | **only** on an explicit `"kick-start"` button action (`init.lua:3075`) | +| exported code | 4 | written into the systems table and **never read** | +| `showcase_preview/main.lua` | **0** | not implemented at all | + +So: you set a kick-start, the preview ignores it, the export records it as dead data, and the +attached game applies it only if you press a button. A looping ambient effect (snow, rain, embers) +therefore starts empty in every path that matters and fills over its particle lifetime, so the +first seconds never look like the steady state you tuned against. + +LÖVE has no native prewarm; it is `ps:update(dt)` in fixed steps before the first draw — which is +exactly what `init.lua:3079` already does. It just needs to happen on start, in all three hosts, +instead of on a button in one. + +--- + +## 5. The architecture + +One module owns particle semantics. Everything else is a host that feeds it. + +``` + packages/runtime-lua/particles/ <- new, the only place semantics live + core.lua evaluate lanes, clamp, decide what changed + apply.lua turn a decision into native ParticleSystem calls + lifecycle.lua build / pool / warm / release + | + +-------------+-------------+----------------------+ + | | | | + plugin showcase_preview exported file unit tests + (attached) (love.js preview) (what ships) (no LÖVE needed) +``` + +**`core.lua` must not touch LÖVE.** It takes a timeline, a time and a base table, and returns a +plain table of resolved values. That is what makes it testable without a graphics context, and it is +the same move that worked in Inspector — extract the judgement, test that (V4-STUDIO §8). + +**`apply.lua` is the only code that calls a setter.** It takes `core`'s output plus the previous +output and issues *only the setters whose value changed*. This is P2 and §4.2 in one function. + +**The exported file embeds these modules verbatim**, not a re-implementation of them. It is +generated by concatenating the same source the preview loads, plus a data table for this composite. +`timeline_runtime.lua` already demonstrates the pattern (`M.source` as a string, `load`ed at require +time); it just needs to be the rule instead of the exception, and unconditional instead of +`if hasGenericLane`. + +### 5.1 Custom curves stay exactly as they are — they get baked, not simplified + +The question this architecture has to answer is whether arbitrary authored curves survive. They do, +and the reason is a property of the data: **a lane is a pure function of time.** It reads no runtime +state — not particle count, not the emitter's transform, nothing. So it can be sampled once and +looked up thereafter. + +`core.lua` bakes each lane into a flat array at load (and, in the editor, on edit). Per frame a lane +costs an array index and a lerp instead of a keyframe search plus a 25-branch easing chain. + +**Resolution is the whole correctness argument.** Sample at least once per displayed frame and the +result is exact as far as anyone can observe, because the samples land where the frames do. +Measured against the exact curve at real 60fps frame times over a 3-second timeline: + +| Curve | 64 samples (46.9 ms step) | 180 samples (16.7 ms step) | 360 samples (8.3 ms step) | +|---|---|---|---| +| `hold` step | **64.444%** error | **0.000%** | **0.000%** | +| elastic in+out | 3.031% | 0.031% | 0.031% | +| `inOutCubic` | 0.130% | 0.000% | 0.000% | + +Undersampling is visibly wrong — a `hold` step baked at 64 samples is 64% off, because a hard edge +smeared across 47 ms is a different animation. At one sample per frame it is exact. **The rule: +`resolution = ceil(duration * 120)`** — two samples per frame at 60fps, which leaves headroom for +frame-time jitter and high-refresh displays. + +**Cost of that choice:** 2.8 KB per baked lane at 360 samples. Bake only what needs it — a constant +lane is one number, not an array, and `timelineLanePlan` (`init.lua:3237`) already classifies lanes +as constant / linear / generic, so the classification exists and is being thrown away. In practice +most lanes are constant, so the realistic footprint is far below the 451 KB an all-generic +8-lane × 20-emitter worst case would take. + +**The editor is the one place that must not bake eagerly.** Baking 6 emitters × 8 lanes costs about +8 ms, which is fine on an edit and far too expensive while a keyframe is being dragged. While a drag +is in progress, evaluate directly — the exact path still exists and is only 0.046 ms/frame — and +bake on release. The preview stays live and the playback path stays fast. + +**What this does *not* change:** which easings exist, what a keyframe means, or the shape of a +project file. Baking is an implementation of `core.lua`, invisible above it. Authored curves are +untouched on disk, and `core.lua` keeps an exact evaluator for the editor and for tests to compare +the baked path against. + +### 5.2 Hot Particles' performance, and how to have it *and* the timeline + +Hot Particles (ReFreezed) is the reference the owner named, so it is worth being precise about +where its speed comes from. Read its export template — `exportTemplates/defaultLuaModule.lua`, 142 +lines — and the answer is immediate: + +```lua +local ps = LG.newParticleSystem(image1, 256) +ps:setColors(...) ps:setDirection(...) ps:setEmissionArea(...) +ps:setEmissionRate(...) ps:setEmitterLifetime(...) +ps:setLinearAcceleration(...) ps:setLinearDamping(...) +ps:setParticleLifetime(...) ps:setRadialAcceleration(...) +ps:setSizes(...) ps:setSpeed(...) ps:setSpin(...) +...20 setters in total, then: +table.insert(particles, {system=ps, kickStartSteps=…, kickStartDt=…, emitAtStart=…, …}) +return particles +``` + +**There is no update function.** The exported module is configuration: twenty setters run once, and +the consuming game calls `ps:update(dt)` and `love.graphics.draw(ps)`. Every animation Hot Particles +can express is a *native per-particle* interpolation — eight colour stops, eight sizes, spin, +accelerations, damping — stepped in C++ across the whole buffer. Per-frame Lua cost: **zero**. + +It is fast because its authoring model *is* LÖVE's ParticleSystem, one to one. That is a real +design achievement and it is also the whole explanation. **Hot Particles has no timeline** — no +emission-rate ramp over an effect's life, no bursts at authored times, no multi-emitter +choreography. Our timeline is strictly more than it offers, and the honest reading of "the same +performance" is therefore not "be as fast as Hot Particles at what Hot Particles does" — it is: + +> **An effect pays only for the tier it actually uses.** + +**Tier 0 — native.** Everything the effect does is expressible as native properties. Compiles to +what Hot Particles emits: setters at load, zero per-frame Lua, and the exported file has no runtime +attached to it at all. This is the default, and most effects live here. + +**Tier 1 — timeline.** The effect keyframes something over its own life. Costs one baked-LUT sample +and a changed-value check per lane per frame — **0.006 ms/frame for six emitters** (§4.2), against +0.045 unbaked. Only lanes that are actually keyframed exist; a constant lane is a number and +collapses the emitter back toward tier 0. + +The tiering must be **derived, not declared**. Nobody picks a tier in the UI; the compiler looks at +the effect and emits the cheapest form that expresses it. `timelineLanePlan` (`init.lua:3237`) +already classifies lanes as constant / linear / generic — the classification exists and is thrown +away. Keep it, and let it decide what gets emitted. + +The test that this is real: **an effect with no keyframes must export a file with no update loop in +it**, byte-comparable in shape to Hot Particles' template. If it exports a timeline runtime that +does nothing, the tiering is decorative. + +### 5.3 The authoring model + +*(The owner: "i'm okay with redesigning how particles are created, like the whole feature, as long +as we achieve the same things with a more intuitive design.")* + +Hot Particles' UI is a flat panel of sliders in fixed groups, with min/max pairs and eight numbered +colour slots — it mirrors the LÖVE API exactly, which makes it predictable and totally unmysterious, +and also makes the user do the translation from "I want embers drifting up" to twenty parameters. +Feather's editor went the other way: gizmos, curve editors, a timeline. Neither is wrong; they solve +different halves. + +The redesign keeps both halves and orders them by how people actually work: + +**1. Start from an effect, not a parameter.** Templates already exist (`presets.ts`) and are the +most-used entry point. Make them the opening screen rather than a dropdown — fire, smoke, sparks, +rain, snow, explosion, trail — each a working effect the moment it is picked. + +**2. Shape it with direct manipulation.** The gizmos are the tool's real advantage over a slider +panel and must survive: `DirectionSpreadGizmo`, `LinearAccelPlane`, `CircularForceGizmo`, +`RotationSpinGizmo`, `TextureOffsetGizmo`, `SizeCurveEditor`, `ColorGradientEditor`, +`DampingRangeEditor`. Drag on the preview, not in a form. + +**3. Expose the native model plainly underneath.** Every gizmo writes a named LÖVE property, and the +panel should say which. Someone who knows `setLinearAcceleration` should be able to find it, and +someone who does not should never have to. This is what makes the export legible to the person who +receives it — it is the same vocabulary. + +**4. The timeline is a layer you add, not a surface that is always there.** Today every composite +carries a timeline whether or not it uses one, which is why a constant-lane effect allocates as much +as a keyframed one (§4.2). Make adding a lane an explicit act, show which lanes exist, and let an +effect with none be visibly, structurally simpler — including in what it exports (§5.2). + +**What must survive the redesign**, because these are the capabilities Hot Particles does not have +and they are the reason this tool exists: multi-emitter composites, the timeline with bursts and +per-lane easing, live push into a running game, custom shaders per emitter, texture atlases with +animated and variant playback, and project import/export. + +**What the redesign should remove:** the assumption that every effect needs every surface. + +**What the redesign owes the export:** kick-start applied on start rather than on a button (§4.5), +and the tier-0 guarantee in §5.2 — the file someone ships should be the file they would have +written. + +**What the generator still decides:** what to *omit*. A composite with no shaders should not carry +shader plumbing. That is a data question — which fields are present — not a semantics question, so +it is safe for the generator to answer and it keeps P3 honest. + +--- + +## 6. The work + +Ordered so each phase is independently shippable and verifiable. + +### P1 — One semantic core — **DONE 2026-09-10** + +`packages/runtime-lua/plugins/particle-system-playground/core.lua` (327 lines) is now the only +definition of what a keyframe means. All three hosts require it; none defines easing or keyframe +evaluation locally. + +**Located in the plugin directory, not `packages/runtime-lua/particles/`** as this document +originally said. `scripts/generate-manifest.sh` only walks `feather/` and `plugins/`, so a new +top-level directory would never have been installed into a game. The plugin directory is also the +precedent the tool next door set with `shader-graph/preview_runtime.lua`. + +What landed: + +- **`core.lua`** — pure Lua, no `love` reference, no `require`. `Core.resolve` returns every lane's + value for a frame *already clamped*, so no host can forget. `Core.resolveSizes` fills a + caller-owned table, which is the allocation fix P2 needs. +- **`timeline_runtime.lua`** — 303 lines to 125, now only the setter half plus `coreSource()`. Its + semantics used to live inside a `[=[ ]=]` string, which is why **luacheck never saw them**: the + Lua lint went from 146 files to 147 by moving them into a real file. +- **`showcase_preview/main.lua`** — 1237 lines to 1080. 151 lines of duplicated semantics deleted, + and `applyTimelineAt` now goes through `Core.resolve`, which is what actually fixes the reported + bug. It previously called the evaluator directly and clamped nothing. +- **The duration clamp** — the plugin clamped to [0.25, 60] and the preview only to a 0.25 floor, so + a 90-second timeline looped at a different point in each. One rule now. +- **A fourth implementation was found:** `apps/studio/src/tools/particle-system-playground/easing.ts` + (129 lines) draws the curve editor. It is not Lua, so it cannot require `core.lua` — it is pinned + instead by a generated contract (below). Checked numerically: all 26 easings already agreed, so + this divergence was latent, not shipped. + +*Verified by:* + +| Test | Asserts | +|---|---| +| Lua e2e, 13 new assertions | the clamps hold across 180 frames of an `inElastic` lane; `resolveSizes` reuses and truncates its buffer | +| `particleEasingContract.test.ts` | `easing.ts` matches `core.lua` for all 26 easings × 21 samples | +| `particleCoreIsSingular.test.ts` | no host re-defines the semantics; `core.lua` stays LÖVE-free; the preview bundles and resolves through it | +| plugin e2e, export equivalence | the exported file's embedded core is loaded and compared against the live one — **all 26 easings**, 21 samples each | + +Each was verified by breaking what it tests. Two findings from doing so: + +1. Removing the clamps from `Core.resolve` fails the Lua e2e as intended. +2. **The export-equivalence test was vacuous when first written.** It sampled six hand-picked + easings, and a deliberately drifted `inQuad` passed straight through it. It now iterates + `Core.EASINGS` and asserts the count is 26, so a subset cannot silently be compared again. + +### P2 — Change detection — **DONE 2026-09-10** + +`apply.lua` is now the only code that calls a setter, and it sends only what moved. Lanes are +compiled once — constant lanes to a number, varying lanes to a baked lookup table — and cached +against the track's own identity, so the expensive part happens on edit rather than per frame. + +Measured with `love scripts/bench-particle-timeline`, 600 frames, 8 lanes per emitter: + +| Scene | Before | After | +|---|---|---| +| 6 emitters, curved | 0.038 ms/f · 368 KB · 3600 calls | 0.014 ms/f · **3.6 KB** · 3600 calls | +| 6 emitters, **constant** | 0.015 ms/f · 368 KB · 3600 calls | 0.008 ms/f · 3.6 KB · **6 calls** | +| 20 emitters, curved | 0.143 ms/f · 1,226 KB · 3600 calls | 0.046 ms/f · **12.0 KB** · 3600 calls | +| 50 emitters, curved | 0.330 ms/f · 3,066 KB · 3600 calls | 0.111 ms/f · **30.1 KB** · 3600 calls | + +Allocation drops ~100x and time ~3x. The row that matters is the second: a composite with no +keyframed lanes now issues **6 setter calls total** instead of 3,600, because after the first frame +there is nothing to say. + +**`pcall` is gone from the per-frame path.** `apply.lua` calls methods on the object it is handed +and never touches the `love` global, which is also what lets a counting stub stand in for a +ParticleSystem in the tests. It is guarded: `particleCoreIsSingular.test.ts` fails if `pcall` +reappears there. + +**Two allocations were found by measuring rather than reasoning.** The first was the benchmark's own +options table, rebuilt per frame — the test allocating, not the code, which would have masked the +thing being measured. The second was real: `resolveSizes` called `parseNumberList`, which builds a +table, on every emitter every frame — 369 KB per 600 frames on the path whose whole purpose is to +allocate nothing. It now reads a numeric list in place. + +**A behaviour fix fell out of dropping `pcall`.** LÖVE's `setSizes` accepts at most eight values, +and a longer list used to raise inside the `pcall`, so **no sizes were applied at all**. `core.lua` +now caps at eight, which applies the first eight instead of silently none. + +**Invalidation is the sharp edge.** A change detector is wrong the moment something else writes the +system. `Runtime.invalidateEmitter` is called wherever the properties are re-snapshotted +(`captureTimelineBase`) or the system is reset, and `Apply.invalidate` makes the next tick rewrite +everything. + +*Cost to be aware of:* compiling 50 emitters × 8 varying lanes takes ~94 ms (6 emitters: ~11 ms). +That is per edit, not per frame, and only varying lanes are baked — but it is why §5.1 says the +editor must not bake while a keyframe is being dragged. + +### P3 — Lifecycle: pool and warm — **DONE 2026-09-10** + +`lifecycle.lua` owns warming and pooling. Neither touches `love` — warming takes an updater +function, pooling takes a factory — so both unit-test without a graphics context. + +**Warming now happens on start, in all three hosts**, instead of behind a button in one: + +- the plugin warms in `resetTimelineSystems` +- the preview warms in `resetParticleSystems`, and now *carries* `kickStartSteps`/`kickStartDt` + through the payload at all — Studio had been sending them and this preview dropped them +- the exported file gains a `warmEmitter` and calls it where an emitter starts + +**The rule lives in one place and the export carries the answer.** Rather than restate the warm rule +in generated Lua, `_generateCode` asks `Lifecycle.warmStepsFor` at export time and emits the +resolved `warmSteps`/`warmDt` as literals. The generated file runs a loop; it does not decide +anything. + +The default: an authored `kickStartSteps` always wins **including an authored zero**, because +turning warmup off is a decision rather than an absence. Otherwise `ambient` warms one particle +lifetime and `loop`/`one-shot` do not, since their beginning is meant to be seen. + +*Verified on a real LÖVE particle system, not asserted:* an unwarmed ambient system holds **0** +particles at frame 1; warmed it holds **208**; left to settle for 600 frames the same system holds +**213**. Frame 1 is within 2.3% of the steady state. + +Two guards against the failure modes of a warmup loop: a runaway `kickStartSteps` is capped at +`MAX_STEPS` (a hang is not a warmup), and a large `kickStartDt` is clamped to 1/10 — a huge step +teleports particles past their own lifetime and leaves the system *emptier* than not warming. + +Pooling is bounded and says so: `Lifecycle.release` returns false when the pool is full, so the +caller knows it still owns what it holds. An unbounded pool is a leak with a friendly name. + +### P4 — Atlas variants — **DONE 2026-09-10, but not as specified** + +**The specced fix is not possible on LÖVE 11.** This document said "picking a random atlas frame per +particle is a texture-coordinate offset, which is a shader's job". A shader cannot do it, because it +has nothing to key the choice on: LÖVE 11's `ParticleSystem` exposes **68 methods and not one of +them provides per-particle data to a shader** — no `attachAttribute`, no custom vertex attributes, +no seed. Verified by enumerating the metatable. Per-particle variation needs the mesh-and-instancing +route, which is a different backend (see `V4-PARTICLES-GPU.md`), not a change to this one. + +So the fan-out stays. What changed is its cost, and **the draw calls turned out not to be the +expensive part.** + +Measured first, on a real LÖVE build, same particle count and emission rate in every row: + +| Systems | Particles | ms/frame | vs 1 | +|---|---|---|---| +| 1 | 3,509 | 0.119 | 1.0x | +| 4 | 3,515 | 0.138 | 1.2x | +| 16 | 3,507 | 0.301 | **2.5x** | + +2.5x for the draw path — real, not a crisis. Then the actual finding: keeping sixteen variants in +step called `copyParticleProperties`, which ran ~20 property copies **per variant per frame**, each +allocating a closure *and* a result table. That is 320 closures and 320 tables per emitter per +frame, and it quietly undid all of P2. + +| 16 variants, 600 frames | Time | Garbage | +|---|---|---| +| before | 0.037 ms/f | **45,750 KB** | +| after | 0.022 ms/f | 5,100 KB | + +1.7x faster and 9x less garbage — **and change detection now gates it entirely**, so an emitter +whose values did not move syncs nothing at all. `Runtime.applyTimelineToEmitter` returns the setter +count for exactly this, and `applyTimelineToSystem` skips the sync when it is zero. + +The copy moved to `lifecycle.lua` as `Lifecycle.copyProperties`, which made it testable — and the +first test written against it **found a bug immediately**: `getLinearAcceleration` returns four +values (xmin, ymin, xmax, ymax) and the rewrite copied two, silently collapsing the max bounds onto +the min. A spread of accelerations became a single acceleration, on variants only, invisible unless +you compared a variant against its source. Twenty-four assertions now check every copied property. + +*Still open, deliberately:* one emitter is still N draw calls when an atlas uses `variants` +playback. Fixing that needs per-particle attributes, and that is the GPU backend. + +### P5 — Generated code is the same program — **DONE 2026-09-10** + +The exported file now embeds `core.lua` and `apply.lua` verbatim and calls them. It defines none of +the semantics itself. + +Removed from the generator: **173 emitted lines** of mode normalization, value clamping, a keyframe +evaluator, clip gating and a bespoke `setTimelineValue` change detector — replaced by 40 that wire +up the shared modules. A further **121 lines** of `buildTimelinePlan` / `luaTimelinePlanTable` / +`timelineLanePlan` went with them: the exported file now carries the *authored* timeline and calls +`ParticleCore.compileTrack` at load, which is the same compile the editor runs. + +**The conditional embed is gone, and that was a real defect.** `core.lua` used to be embedded only +when a lane needed the "full" evaluator; otherwise the file got a cheaper inlined sampler. So simple +effects shipped a **different evaluator from the one the editor showed them** — two implementations, +one of them chosen automatically, exactly the arrangement §1 describes. Every export embeds the one +evaluator now. + +*Done-when, checked mechanically* — `_generateCode` contains no: + +| | | +|---|---| +| easing maths | gone | +| keyframe evaluation | gone | +| clip gating | gone | +| value clamping | gone | +| change detection | gone | +| lane names | gone | + +Guarded by `the generator emits particle behaviour, never defines it` in +`particleCoreIsSingular.test.ts`, which fails if any of them return. + +**Tier 0 is real, not decorative** (§5.2). When no lane varies, the export omits the compiled +tracks, the per-frame apply *and* the embedded `apply.lua` — there is no machinery to run and none +to read. Asserted both ways: a static effect must not contain them, a varying one must. + +*Verified frame for frame:* the test loads the exported file's own timeline table, compiles it with +the exported file's own embedded core, and compares every resolved lane against the live core across +241 frames — **2,169 comparisons, zero mismatches**. Confirmed non-vacuous by perturbing the +embedded core's opacity clamp, which produced 241 mismatches. + +## 7. What is out of scope + +- Rewriting the editor UI. This is the runtime and the export. +- GPU-resident simulation. Not available in LÖVE 11.7 (§2). +- Matching Hot Particles feature for feature. It is the performance reference (§5.2), not the + functional one — the timeline, multi-emitter composites and live push into a running game are + deliberately beyond it. +- The 4609-line `init.lua` as a whole. P1–P5 remove its duplicated semantics; the remaining + plugin/session plumbing is a separate concern. + +--- + +## 8. Traps + +**Do not "fix" the preview by copying the clamps into it.** That is the fourth implementation. The +clamps must move to a module all three require, or this document was pointless. + +**The clamp change is a behaviour change for existing projects.** Someone may have tuned an effect +against the unclamped preview. Landing P1 makes their preview match their export — which is the +point — but it will *look different* to them. Say so in the release note. + +**Verify a test by breaking the thing it tests.** Three tests in the Studio work passed against +unfixed code. Make the fix, watch the test pass, revert the fix, watch it fail, restore. + +**`pcall` removal is not blanket.** §4.2 is about the per-frame path on objects this code +constructed. Input authored outside the repo still gets validated. + +**Watch the buffer size.** Pooling and prewarm both interact with `setBufferSize`: a prewarmed +ambient system that hits its buffer cap silently stops emitting, which reads as "the effect broke" +rather than "the buffer is full". + +--- + +## 9. Definition of done — **all met, 2026-09-10** + +1. ✅ One module defines what a keyframe means; the plugin, the preview and the exported file all use + it, and none defines easing or keyframe evaluation locally. *(P1, guarded structurally.)* +2. ✅ An `inElastic` size lane renders identically in all three — the clamps live in `Core.resolve`. +3. ✅ A composite with no keyframed lanes issues no per-frame setter calls: **6 total**, not 3,600. +4. ✅ Ambient composites are warm on the first frame, in all three. Measured on a real system: 208 + particles at frame 1 against 213 settled. +5. ⚠️ **`variants` atlases still draw in N calls.** Not achievable on LÖVE 11: `ParticleSystem` + exposes no per-particle data to a shader (68 methods, verified). What P4 delivered instead was + the cost — 9x less garbage keeping the variants in step, and skipped entirely when nothing moved. + The draw-call fix needs the mesh backend (`V4-PARTICLES-GPU.md`). +6. ✅ A test runs the exported file's core over the same timeline and asserts they agree — 2,169 + comparisons, zero mismatches. +7. ✅ An effect with no keyframed lanes exports a file with no update loop, no compiled tracks and no + embedded applier. +8. ✅ A kick-start set in the editor is visible in the preview, not only in an attached game. + +**What the five phases cost and returned**, measured with `love scripts/bench-particle-timeline`: + +| | Before | After | +|---|---|---| +| 6 emitters, curved | 0.038 ms/f · 368 KB | 0.014 ms/f · 3.6 KB | +| 6 emitters, constant | 0.015 ms/f · 368 KB · 3600 calls | 0.008 ms/f · 3.6 KB · **6 calls** | +| 50 emitters, curved | 0.330 ms/f · 3,066 KB | 0.111 ms/f · 30.1 KB | +| 16 atlas variants | 0.037 ms/f · 45,750 KB | 0.022 ms/f · 5,100 KB, and skipped when idle | + +Implementations of the semantics: **four → one**. Lua files linted: 146 → 149, because the semantics +used to live inside a string where luacheck could not see them. diff --git a/V4-PENDING.md b/V4-PENDING.md new file mode 100644 index 00000000..2f1ee0d0 --- /dev/null +++ b/V4-PENDING.md @@ -0,0 +1,150 @@ +# V4 — what is left + +*State as of 2026-09-10, at the end of the particles and Studio-colour work. Every claim here was +checked against the repository rather than recalled; where something is "done", the evidence is +named so you can disagree with it.* + +--- + +## 0. How to read this + +Four documents drive V4 and each owns its own detail: + +| Document | Scope | State | +|---|---|---| +| `V4.md` | The re-architecture and release trains | Code landed; **5 release steps need push access** | +| `V4-ENHANCED.md` | Inspector, feature by feature | **Closed** | +| `V4-STUDIO.md` | Making Studio a usable product | S1–S3, S5 (C0/C1/C3) done; **S3b, S4, C2, C5 open** | +| `V4-PARTICLES.md` | Particle runtime and export | P1–P5 done; **§5.3 authoring redesign open** | + +This file is the index of what is *not* done. When an item lands, record it in its own document and +delete the row here. + +--- + +## 1. Needs you, not code + +**Release dry-runs — 5 open checkboxes in `V4.md`.** All blocked on release/push access, none on +code: + +- `cli-v4.0.1` publishes the CLI and nothing else +- a throwaway `cli-v0.0.0-rc.1` tag proves only the npm job runs +- a `studio-v*` release builds Studio and triggers no Inspector, CLI, runtime or extension release +- the same, proven by dry-run or pre-release +- packaged-artifact inventories for both desktop apps + +**`V4-PARTICLES-GPU.md` is in this branch's history.** It was committed by accident in `bb8de80` — +a `git add -A` swept up a file that was meant to stay out of the tree — and untracked again in +`db7bedb`. The file is on disk and `.gitignore` now prevents a repeat, but the content is in the +commits. Rewriting history to remove it is cheap before the branch is pushed and expensive after. + +--- + +## 2. Studio — the biggest remaining gap + +### S3b — the workbench shape for Texture Lab and Particles + +**Open.** Verified: neither `TextureLabPanel.tsx` nor the particle playground's `index.tsx` uses +`ResizablePanelGroup`, so neither has the three-region layout Shader Graph has. This is the one a +user feels immediately — two of the three tools do not look like the third. + +Largest remaining UX change in Studio, and the one worth doing with someone watching. + +### S4 — durable workspaces + +**Open, and smaller than it looks.** All three tools already have the machinery and none has an +owner: + +| Tool | What exists | +|---|---| +| Texture Lab | `textureLabWorkspaceId`, `textureLabWorkspaces`, `activateTextureLabWorkspace` | +| Particles | `writeLocalParticleWorkspace` / `loadLocalParticleWorkspace` / `deleteLocalParticleWorkspace`, keyed by an id that is currently `undefined` | +| Shader Graph | `activateWorkspace` in `store/shader-graph.ts` | + +The missing piece is **one application-level notion of "the thing I am working on"** — named, +listed, switchable, saved on change, supplying the id each tool's persistence already wants. + +Deleting a workspace should be explicit and confirmed. It is the only place in Feather that +irreversibly destroys the user's own work, and the only confirmation this product should have. + +*Acceptance:* build something in each of the three tools, `kill -9` Studio, reopen, and find all +three exactly as you left them. + +### S5 — C2 and C5 remain + +- **C2 — freshness.** Only applies in `attached` mode, and matters there: a preview reflecting a + game that has since restarted is the same lie Inspector had. `use-freshness.ts` judges against the + session's `connectedAt`, not the clock. +- **C5 — third-party data.** The shader graph loads user files and every tool accepts imported + workspaces. Be strict wherever a value authored outside this repository reaches rendering. + +--- + +## 3. Particles — the redesign, not the runtime + +**P1–P5 are done and the runtime work is finished.** Seven of eight definition-of-done items are +met. What remains is the half the five phases deliberately did not touch. + +### §5.3 — the authoring model + +**Open.** The plan is written and agreed; none of it is built: + +1. templates as the opening screen rather than a dropdown +2. the gizmos kept — they are the real advantage over a slider panel +3. the native LÖVE property named beside each control, so the export reads in the same vocabulary +4. the timeline as a layer you *add*, not a surface every composite carries + +### One definition-of-done item that cannot be met here + +**`variants` atlases still draw in N calls.** Not achievable on LÖVE 11: `ParticleSystem` exposes 68 +methods and none provides per-particle data to a shader — no `attachAttribute`, no seed, verified by +enumerating the metatable. P4 attacked the cost instead (9x less garbage, skipped entirely when +nothing moved). The draw-call fix needs per-particle attributes, which means the mesh backend +described in `V4-PARTICLES-GPU.md`. + +--- + +## 4. Known duplication, measured + +The theme registry and the particle semantics were both "one thing maintained twice", and both have +now been consolidated. So the obvious question is what else is. Measured rather than assumed: + +| Location | Result | +|---|---| +| `src/utils/arrays.ts` | **byte-identical** (28 lines) | +| `src/utils/cache.ts` | **byte-identical** (80 lines) | +| `src/utils/timers.ts` | **byte-identical** (64 lines) | +| `src/utils/assets.ts` | differ — Studio's dead `createGif` was removed | +| `src/utils/file.ts` | differ by 6 lines | +| `src/lib`, `src/hooks`, `src/store` | no identical files | +| `src/components` | no overlap at all — Inspector 17 files, Studio 1 | + +So: **three small files, 172 lines**, are genuinely duplicated. Candidates for `packages/ui` or a +small shared utils package, and cheap to do — but nothing like the 4,602 lines the theme registry +was, and none of them has drifted. + +*(An earlier draft of this section asserted that the two `components/` trees were probably copies. +They are not; the claim was written before it was checked, and checking took one command.)* + +## 5. Practices worth keeping + +Written down because each was learned by getting it wrong in this cycle. + +**Verify a test by breaking the thing it tests.** Three tests in the Studio work passed against +unfixed code. Worse, the particles export-equivalence test was *vacuous* when first written — it +sampled six hand-picked easings and a deliberately drifted `inQuad` passed straight through. It now +iterates all 26 and asserts the count. + +**Measure, do not reason, about allocation.** Two allocations were found by benchmarking that +reasoning had missed — and one of them was the benchmark's own options table, which would have +masked the thing being measured. + +**A surface token is not a mark.** The C0 regression — painting a small dot with a region tint, +making it invisible — has now happened twice. `verify:enhanced` checks for that exact shape. + +**`git add -A` hides things.** It swept a file into history that was meant to stay out, and it +committed a 162 MB corrupted document three times without anyone seeing a diff. Stage deliberately, +and check file sizes after any scripted edit. + +**Slicing backwards yields the empty string, and `str.replace('', x)` inserts between every +character.** That is how `V4-STUDIO.md` became 2,522,189 lines. Splice on indices. diff --git a/V4-STUDIO.md b/V4-STUDIO.md new file mode 100644 index 00000000..8b9efbb5 --- /dev/null +++ b/V4-STUDIO.md @@ -0,0 +1,748 @@ +# V4 Studio — turning Feather Studio into a usable tool + +**Status:** S1–S3 done, S3b/S4/S5 open · **Written:** 2026-09-10 · **Last worked:** 2026-09-10 +**Companions:** [`V4.md`](./V4.md) (why Studio is a separate product) · [`V4-ENHANCED.md`](./V4-ENHANCED.md) — its §9 summarizes this + +--- + +## 0. How to use this document + +Read §1 and §2 first — they are short, and everything after them only makes sense once you know +that **Studio currently cannot be opened and used at all**, and why. + +§5 is the visual direction; §6 is the work. Read §5 before S2 — the shell is where it lands. Each step is self-contained: it names the files, the exact change, how to +check it, and what "done" means. Steps depend on the ones before them. + +Every factual claim here was verified against the repository on 2026-09-10 and carries a file +reference. If something does not match what you find, **trust the code and correct this document** — +a stale spec is worse than none, and V4-ENHANCED's log has two entries about exactly that. + +--- + +## 1. The situation + +Feather Studio ships **35,325 lines** of source, including **25,500 lines** of working creative +tools, and renders none of them. + +`apps/studio/src/StudioApp.tsx` is 60 lines and is still the Phase 05b placeholder. It draws a +header, an environment panel, and this: + +> "The shader graph, texture lab and particle playground move here in Phase 05c. They are still +> served by Feather Inspector until then." + +They moved in Phase 05c. The shell was never updated. Nothing in Studio's own UI imports anything +from `apps/studio/src/tools/`. + +**Why nothing caught it.** Phase 05c was defined as a move, and the move was real and verified: the +LOC landed, the import boundary held, the lint guards stood, every suite went green. Nobody asked +whether the application could be opened, because that was not what the phase claimed to do. A +migration can be complete by its own definition and still leave a product that does not exist. + +--- + +## 2. The design principle + +> **Studio is a local authoring tool that can optionally reach a running game.** + +This is the inverse of what the code currently assumes, and it decides nearly every open question +below. + +Today "no game attached" is treated as a degraded state that the tools tolerate, because they were +written inside Inspector, where a gameless workspace was an unusual thing you deliberately created. +In Studio it is the **normal, fully capable** state. Someone opens Studio to build a shader; whether +a game happens to be running is beside the point until they want to see their work inside one. + +Three consequences, which resolve most smaller decisions: + +**P1 — Local is the default, not the fallback.** +Studio always has a workspace. It never asks for a session before letting you work, never shows a +session-required empty state, and never disables a control because no game is attached. A game gates +only what genuinely needs one: pushing into a live preview, and reading back from a running system. + +**P2 — A connection is an addition; its absence is not an error.** +"Not connected" is a neutral fact. Style it as chrome, never as `danger`. The placeholder already +says this correctly — *"Optional. Studio works as a local editor without it"* — and that sentence +should survive into the real shell. + +**P3 — Authoring work is durable.** +Inspector losing a filter costs a retype. Studio losing a shader graph costs an afternoon. Work +survives restarts and crashes, per workspace, by default. + +--- + +## 3. What already exists + +More than §9 of V4-ENHANCED first credited. Do not rebuild any of this. + +| Thing | Where | State | +|---|---|---| +| Three creative tools | `apps/studio/src/tools/{shader-graph,particle-system-playground,texture-lab}/` | Working, 25,500 LOC | +| Tool entry components | each tool's `index.tsx` | All three export a default component | +| Theme registry | `apps/studio/src/assets/theme/registry/` | Full copy: GitHub, Noctis, Rainglow, Tokyo Night, VS C++ | +| Theme provider | `apps/studio/src/theme.tsx` | `ThemeProvider`, `useResolvedTheme`, `useSystemThemeMode` | +| Design tokens | written by `ThemeProvider` onto the root element | Complete — *when the provider is mounted* | +| Inspector bridge | `apps/studio/src/useInspectorBridge.ts` | Working, optional, null-bridge by default | +| Session mirror | `apps/studio/src/session/index.ts` | Present, but models Inspector's world (S3) | +| Stores | `apps/studio/src/store/{shader-graph,studio-preferences}.ts` | Working, persisted | +| Creative MCP | `apps/studio/src/mcp/`, `desktop/mcp-transport.ts`, `src-tauri/src/creative_mcp.rs` | Working, port 4007 | +| Tauri shell | `apps/studio/src-tauri/` | Working, `com.kyonru.love.feather.studio` | +| A working reference | `apps/studio/src/showcase/` | **Read this first** — see §4 | + +### 3.1 The showcase is the working reference + +`apps/studio/src/showcase/` is Feather Studio running in a browser. It mounts the same tools from +the same directories and it works. When something in §6 is unclear, look there first. + +Two caveats: + +- It reaches the tools through **workarounds** for the session model being wrong (S3), notably + `playgroundOverride` and a hand-passed `standalone` prop. Those workarounds are not the target + shape; they are evidence of the defect. +- It is a public marketing surface with a gallery and its own layout. Studio is an application. Copy + its **provider stack**, not its chrome. + +--- + +## 4. Why nothing renders: the provider gap + +This is the single highest-value fact in the document. + +`apps/studio/src/main.tsx` mounts exactly one provider: + +```tsx + + + +``` + +`apps/studio/src/showcase/providers.tsx` — which successfully renders the same tools — mounts four: + +```tsx + + + + {children} + + + +``` + +So Studio is missing **`QueryClientProvider`**, **`UiProvider`** and **`ThemeProvider`**. + +What each absence would cost, if you mounted a tool today: + +- **No `ThemeProvider`** — no design tokens on the root element. The tools reference shadcn tokens + (`bg-card`, `text-muted-foreground`, `border-border`) **394 times**, and every one resolves to + nothing. `theme.tsx`'s own comment records this happening to the showcase already: *"Without it + `var(--background)` and friends resolve to nothing and the layout collapses."* +- **No `QueryClientProvider`** — the tools and their hooks use React Query. They throw. +- **No `UiProvider`** — `@feather/ui` components lose `useThemeMode`, `useSyntaxTheme` and + `copyToClipboard`. + +**A second half of this gap, found while implementing S1:** `@theme inline` — the block that maps the +tokens onto Tailwind's color utilities, and therefore what makes `bg-card` *exist* as a class at +all — lived only in `showcase-app.css`. Without it Tailwind never generates those utilities, so the +classes are inert no matter what the token values are. It now lives in +`apps/studio/src/theme-tokens.css`, imported by both Studio and the showcase so they cannot drift on +what a card looks like. + +--- + +## 5. The look: a workbench, not an instrument + +Inspector's visual direction is settled in V4-ENHANCED §4: *an instrument, not an interface* — color +reserved as a signal channel, typography carrying hierarchy, rules instead of cards, motion only for +causality. It is right for Inspector and **it is the wrong answer here**, which is worth stating +plainly because the two applications share a component library and the temptation to share a look is +strong. + +They are different species of tool: + +| | Inspector | Studio | +|---|---|---| +| What you do | **read** it | **work on** it | +| The product | the data it shows | the artifact you are making | +| Success | you understand something | you made something you like | +| The window's job | present state faithfully | stay out of the way of the work | +| Color belongs to | the tool, as signal | **the artifact** | + +The last row is the one that changes everything. Inspector can spend color on state because nothing +else in the window has any. Studio cannot: a texture, a gradient, a shader preview and a particle +system are *made of* color, and every colored pixel of interface competes with them for the judgment +the user is trying to make. + +### The metaphor + +**A workbench.** A large neutral surface, the work in the middle, tools within reach but out of the +way, and good light. + +It is a useful metaphor because it decides things. A workbench is not decorated. Its surface is +chosen so it does not lie to you about what you are making. Tools sit at the edges where your hand +falls, not in the middle where the work goes. And nothing on a good workbench is more interesting to +look at than the thing being built on it. + +### W1 — The artifact is the only thing allowed to be colorful + +The preview, the graph, the simulation get the space and the saturation. Everything else — panels, +labels, controls, borders — is achromatic, or as close as it can get while staying legible. + +This is stricter than Inspector's D1, which reserves color for the four semantic states. Studio keeps +that channel (a failed shader compile is still `danger`, and V4-ENHANCED **C0** still applies) but +adds a rule above it: **state color is allowed; decorative color is not, and neither is accent color +near a preview.** If a control needs emphasis next to the artifact, it gets it from weight, size or +contrast, not from hue. + +*Concretely:* no colored panel backgrounds, no accent-tinted headers, no colored tool icons. The +`--primary` rose is for focus and selection only, and it does not appear inside a preview region. + +### W2 — Dark by default, neutral by necessity + +Studio should open dark, regardless of the system preference, and it should let the user switch. +This is a real departure from Inspector — `apps/studio/src/theme.tsx` currently follows the system — +and the reason is not fashion. + +**You cannot judge a color against a surround that has its own.** Simultaneous contrast is a +measurable perceptual effect: the same swatch reads lighter against dark, darker against light, and +shifts hue against a tinted ground. A bright interface beside a texture you are authoring makes that +texture look darker and more saturated than it is, and you will compensate — in the file, wrongly. +Every tool where people judge color for a living defaults dark for this reason. + +Two rules follow: + +**The chrome is dark and desaturated.** Around `#242424` for the workbench surround — dark enough not +to compete, light enough that a black artifact still reads against it. Pure black is worse than +mid-dark: it maximizes contrast against everything, making previews look brighter and more saturated +than they are. + +**The immediate surround of a preview is neutral and switchable.** Not the panel color — a dedicated +ground, with the user able to choose it, because there is no single correct answer: + +| Ground | Value | For | +|---|---|---| +| Dark | `#141414` | judging bright artifacts, glows, additive particles | +| **Neutral** | `#767676` | **the default** — true 18% grey, the least-biasing ground | +| Light | `#d4d4d4` | judging dark artifacts and edges | +| Checker | 8px `#5a5a5a`/`#6f6f6f` | judging alpha | + +`#767676` is not a taste: it is the sRGB value whose relative luminance is 0.18, the neutral +photographers and colorists standardize on precisely because it biases perception least. The checker +values are deliberately close together and mid-valued — a high-contrast black/white checker is itself +a strong simultaneous-contrast stimulus and makes semi-transparent edges hard to read. + +The chosen ground is per tool and remembered (**C1**). + +### W2b — Regions are separated by value, not by lines + +*(Added 2026-09-10 on the owner's direction, after the first shell read as too heavy.)* + +Take the VS Code approach: regions differ by a half-step of **background value**, and drawn borders +are reserved for things that need an affordance — inputs, buttons, anything you can click into. + +A border is a line you have to look at. A value step does the same job of saying *these are +different regions* and then stops registering once you no longer need it, which is exactly the +behaviour a workbench's chrome should have. Drawn edges everywhere also read as decoration, and W1 +has already spent the decoration budget at zero. + +*Concretely:* `border-r` / `border-b` between layout regions becomes `bg-sidebar` (or another step) +on one side. A card sitting inside a tool that already owns the window loses its outline entirely — +that outline was Inspector's idiom, where cards separated peers on a scrolling page; here the +section is already inside its own region and the line has nothing to do. Grouping comes from a value +step and a quiet heading. + +*What keeps its border:* form controls, and any element whose edge tells you where to click. + +**Applied 2026-09-10.** The complaint that started this — *"the shader graph have strong border on +every container"* — turned out to have a cause underneath the taste question, and it is worth +recording because the taste fix alone would have hidden it: + +Tailwind v4 changed the default `border` colour from a light grey to **`currentColor`**. shadcn +covers this with a base rule (`* { border-color: var(--border) }`); the showcase entry had that rule, +but `apps/studio/src/studio.css` never did. Every `border` utility in Studio was therefore drawing in +the *text* colour — **15.8:1** against the page, where the intended token is **1.25:1**. Shader Graph +alone rendered **172** near-black outlines. That is not a heavy design, it is a missing line of CSS. + +Three things followed: + +1. **The base layer landed in `studio.css`**, alongside the removal of ~9 lines of dead + `.studio-panel` / `.studio-button` placeholder CSS that the real shell had already replaced — + which was independently hardcoding `#d7dbe1` borders and a `#f4f5f7` body, overriding the theme. + The body now takes `bg-background` from the token, so themes actually reach it. +2. **An earlier softening was reverted.** `--color-border` had been mixed down to 55% while the real + cause was still unknown; with borders resolving correctly that produced 1.13:1 — too faint to + read as a division. The raw token is 1.25:1, which is the VS Code range *and* what Inspector + already uses. Sharing one value serves the "single product" complaint better than a Studio-only + variant. +3. **The idiom got named.** `.surface` / `.surface-inset` / `.section-label` in `theme-tokens.css` + replaced 59 ad-hoc boxed containers written 8 different ways and 42 labels written 7 different + ways. Controls kept their borders, and so did anything carrying a state colour — those were + classified out by the presence of `h-\d` / `cursor-` / `focus` / `hover:` / `disabled:` or a + palette colour, not by hand. + +*Guarded by* `the three tools share one visual language` in `apps/studio/e2e/studio.spec.ts`, which +walks all three tools and fails on any sRGB border darker than rgb(90,90,90). Verified by reverting +the base rule and watching it report 172. + +**A defect this surfaced.** Tightening the spacing moved a diagnostic's fix button under the floating +LÖVE preview — `fixed bottom-4 right-4`, so it sits over whatever occupies that corner, which in +Shader Graph is the diagnostics list. The button was 59px inside the preview's box and could not be +clicked at all. The preview now publishes its measured footprint as `--floating-preview-gutter` (via +`ResizeObserver`, because it is resizable and a hardcoded value goes stale on the first drag) and +`ShaderRightPanel` reserves it in standalone. *Guarded by* `the floating preview never covers the +diagnostics it explains`. + +### W2c — Navigation overlays; it does not occupy + +*(Added 2026-09-10 on the owner's direction.)* + +The tool switcher is a menu behind a hamburger, opening as an overlay, not a permanent rail. + +Three tools do not justify taking width from a node graph or a texture preview for the whole +session — W1 says the artifact gets the space, and a rail contradicts it every minute it is on +screen. What remains permanently is a thin strip: which tool you are in, and whether a game is +attached. Everything else appears over the work when asked for and leaves when you have chosen. + +The cost is one extra click to switch tools, which is why the menu **closes on selection** — the +interaction must not also charge a dismissal. + +### W3 — Parameters are handles, not forms + +Asset work is continuous adjustment: *a bit more, a bit less, no — back.* An interface built from +text fields and Apply buttons makes that loop cost four actions instead of one. + +Every numeric parameter is **draggable to scrub**, click-to-type for precision, and arrow-key +nudgeable (shift for coarse, alt for fine). Every color parameter opens its picker in place. Ranges +show their bounds. Nothing needs confirming. + +*Concretely:* if a control has an Apply button beside it, the control is wrong. The exception is +anything genuinely expensive or destructive — generating a large texture, deleting a workspace — +where a deliberate act is the point. + +### W4 — Feedback follows the hand + +The preview updates **during** the drag, not on release. That loop — adjust, see, adjust — is the +entire product, and its latency is the quality metric users will feel before they can name it. + +Where something cannot be instant (a heavy generation, a compile, a round trip to a live game), it +says so with a progress affordance in place, and the last good result stays on screen until the new +one arrives. A preview that blanks while recomputing is worse than one that is briefly stale, because +blanking destroys the comparison you were in the middle of making. + +This is where Studio's motion budget goes. V4-ENHANCED **D4** says motion only for causality; here, +causality *is* the interaction, so the propagation of a change through the graph and into the preview +is worth animating. Nothing else is. + +### W5 — One workspace, not three pages + +Inspector is tabbed because you look at one thing at a time. Studio is not: you need the graph, the +preview and the parameters **simultaneously**, because the whole job is relating them. + +Shader Graph already has this shape — palette | canvas | inspector, resizable, in the showcase's +composition. The other two do not: Texture Lab is a page with a header +(`tools/texture-lab/index.tsx:62`), and Particle Playground is built from Inspector's cards +(`rounded-md border bg-card`). Both should become three-region workbenches: + +| Tool | Left | Center (the work) | Right | +|---|---|---|---| +| Shader Graph | node palette | node canvas + preview | node parameters | +| Particle Playground | system / emitter list | **simulation preview** | emitter parameters + timeline | +| Texture Lab | recipe / generator list | **texture preview** | generator parameters | + +Regions are resizable and their sizes persist per tool (**C1**). The center region is never smaller +than the sum of the sides. + +### W6 — The work has a name + +An asset is a thing someone made, not a scratch buffer. Workspaces are named, listed, switchable, +and saved continuously (**P3**, and S4 builds it). The name is visible while you work, because it is +the answer to "which one is this?" — and it is the only place in Studio where a delete needs +confirming. + +### What this means for the shell + +Applying W1–W6 to S2, the shell should be: + +- **A frame, not a page.** No large application header. A thin strip names where you are; tool + switching lives in an overlay menu behind it (**W2c**). +- **Dark, achromatic, quiet.** The shell contributes no color at all. +- **The tool fills the rest.** The tool owns the entire remaining window and manages its own regions. +- **The Inspector connection lives at the edge.** A small status affordance, styled as chrome (**P2** + — its absence is not an error). It belongs near the tool switcher, not in a banner. +- **The workspace name is visible**, in the same strip. +--- + +## 6. The work + +### S1 — Mount the providers — **DONE** (c95b272) + +**Goal:** the tools can render at all. + +**Files:** `apps/studio/src/main.tsx` + +Build a provider stack matching `showcase/providers.tsx`, with these differences: + +- The host stays resolved as it is today — `resolveHost()` already picks Tauri or web correctly and + lazily imports `@tauri-apps/*`. **Do not change it**; a web build must not pull Tauri in. +- Keep the existing `setCreativeMcpTransport` wiring for the desktop case. +- `UiProvider` needs the same three dependencies the showcase supplies. +- **Leave the theme behaviour alone for now.** §5 **W2** argues Studio should open dark regardless + of the system preference. The owner has deferred that: themes are wanted and the default is a + later conversation. `theme.tsx` keeps following the system, and W2's *preview ground* rule (the + neutral, switchable surround) lands in S3b regardless — it is the part that actually affects + color judgement, and it does not depend on which theme is active. + +**Acceptance:** a tool mounted anywhere in Studio renders with real card backgrounds, borders and +muted text, in both light and dark, following the system preference. + +**Check:** `pnpm run studio:dev`, then temporarily render `` inside `StudioApp`. It +should look like a tool, not unstyled markup. Revert the temporary render before moving on. + +--- + +### S2 — Build the shell — **DONE** (c95b272, refined in a44e7ce) + +**Goal:** Studio becomes usable. **This is the step that makes Studio exist.** + +**Files:** `apps/studio/src/StudioApp.tsx` (replace the placeholder), plus whatever navigation +components you add. + +**Read §5 first.** It is the visual direction, and the shell is where most of it lands — a frame +rather than a page, achromatic, with the tool owning everything below a compact strip. + +Requirements: + +1. **Navigation across the three tools.** Shader Graph, Particle Playground, Texture Lab. A compact + left rail or top strip, not an application header — §5 explains why the work gets the space. +2. **Mount the entry components.** All three export defaults: + - `apps/studio/src/tools/shader-graph/index.tsx` → `ShaderGraph` + - `apps/studio/src/tools/particle-system-playground/index.tsx` → `ParticleSystemPlaygroundPage` + - `apps/studio/src/tools/texture-lab/index.tsx` → `TextureLab` +3. **Do not pass `standalone`.** It is a workaround; S3 removes it. Passing it here would hide the + defect S3 fixes and make S3 look unnecessary. +4. **Keep the Inspector connection affordance**, and keep its framing (P2). The existing copy — + *"Optional. Studio works as a local editor without it; connecting lets you push work straight + into a running game."* — is correct. Move it somewhere it does not dominate the window; it is + secondary to the work. +5. **Remember which tool was open** (V4-ENHANCED **C1**, and use `usePanelState`'s reasoning: state + the user chose and would be annoyed to re-choose). Studio has no equivalent store yet; a small + persisted store is fine, and it should be global rather than per session for the same reason + Inspector's is. + +Routing is a judgement call. Studio has no router today and does not obviously need one — three +tools and local state is enough. If you add one, note that the tools were written as Inspector +*pages* and may assume a router context; check before assuming either way. + +**Acceptance:** open Studio with no Inspector running and no game, reach all three tools, and use +each one. + +**Check:** `pnpm run studio:tauri`. Also `pnpm run test:studio:e2e` — and see §7 about the four +existing tests, which assert the placeholder. + +--- + +### S3 — Invert the session model — **DONE** (c95b272) + +**Goal:** the tools stop treating "no game" as degraded. + +This is where P1 becomes code. **Read §7.1 before starting** — the semantics are subtle and the +existing predicates are easy to misread. + +**The defect, exactly.** Both Texture Lab and Particle Playground contain: + +```ts +const creativeSession = isCreativeSession(activeSession); +const playground = creativeSession ? localPlayground : livePlayground; +``` + +With no Inspector attached, `activeSession` is `null`, so `isCreativeSession(null)` is `false`, so +they select **`livePlayground`** — the controller that needs a running game. The ternary is backwards +for Studio's default, and Studio *never creates a creative session*: +`createCreativeSessionId` exists in `session/index.ts` and is called from nowhere. + +**Shader Graph already does it correctly** — `tools/shader-graph/index.tsx:75`: + +```ts +const localMode = !sessionSupportsRuntime(activeSession); +``` + +That is the shape to converge on. + +**Sites** (all verified 2026-09-10): + +| File | Line | What it does | +|---|---|---| +| `tools/particle-system-playground/index.tsx` | 54, 56, 59 | `creativeSession`, controller choice, `localMode = standalone \|\| creativeSession` | +| `tools/particle-system-playground/components/ParticlePreviewMonitor.tsx` | 56 | runtime session gate | +| `tools/texture-lab/index.tsx` | 16, 17, 19 | `creativeSession`, workspace id, controller choice | +| `tools/shader-graph/index.tsx` | 75 | already correct — the reference | +| `tools/shader-graph/LoveNodePreview.tsx` | 79 | runtime session gate | +| `hooks/use-shader-graph.ts` | 35 | runtime session gate | +| `session/plugin-control.ts` | 24 | runtime session gate | + +**The change:** + +1. Replace `isCreativeSession` branching with one explicit mode derived from the bridge — `local` + (always available) or `attached` (a game is reachable). Express it once, in `session/`, and have + the tools read it. +2. Delete `CREATIVE_SESSION_PREFIX`, `createCreativeSessionId`, `isCreativeSession` and the + `'creative'` `SessionKind` from `apps/studio/src/session/index.ts`. They describe Inspector's + exception. In Studio the same words name the default, which is precisely why the code reads + backwards. (Inspector removed its own copy for the same reason — see V4.md **D43**.) +3. Remove the `standalone` prop from `ParticleSystemPlaygroundPage` and the `playgroundOverride` + injection the showcase uses. Both are workarounds for this defect; when the default is right, they + have nothing to do. +4. Leave the **runtime session gates** alone in substance. `sessionSupportsRuntime` answers a real + question — *may I push to a live game?* — and its answer is legitimately `false` in local mode. + Rename if it clarifies; do not remove. + +**Acceptance:** no tool asks whether a session is "creative". Standalone Studio uses the local +controller because that is the default path, not because a prop said so. The showcase renders +without `playgroundOverride`. + +**Check:** with no Inspector, all three tools are fully usable and none shows a session-required +state. `pnpm run test:showcase:e2e` still passes. + +--- + +### S3b — Give the other two tools the workbench shape + +**Goal:** §5 **W5**. All three tools are workspaces, not pages. + +Shader Graph is already right: palette | canvas | inspector, resizable, composed in +`showcase/ShowcaseShaderGraph.tsx`. The other two are not, and both use Inspector idioms that no +longer fit: + +- `tools/texture-lab/index.tsx:62` — a `
` with a `
`, i.e. a page. +- `tools/particle-system-playground/index.tsx:32` — `rounded-md border bg-card`, Inspector's card. + +Rebuild both as three regions using `@feather/ui/resizable`, per the table in §5 **W5**, with the +preview in the center. Add the neutral, switchable preview ground from **W2** — no preview should +sit on the panel color. + +This is a layout change, not a rewrite: the panels inside each tool keep working. Do it after S3 so +you are not moving components and changing their session behaviour at once. + +**Acceptance:** each tool shows its work, its parameters and its list at the same time, the regions +resize, and the preview sits on a neutral ground the user can change. + +### S4 — Durable workspaces + +**Goal:** P3. Work survives restarts, crashes and tool switches. + +**Current state:** + +- Texture Lab has workspaces — `store/studio-preferences.ts` holds `textureLabWorkspaceId` and + `textureLabWorkspaces`, with `activateTextureLabWorkspace`. +- Particle Playground has per-workspace persistence keyed by an id — + `tools/particle-system-playground/use-local-particle-playground.ts` (`writeLocalParticleWorkspace`, + `loadLocalParticleWorkspace`, `deleteLocalParticleWorkspace`) — but with no session the id is + `undefined` and it falls back to `defaultLocalParticleData()`. +- Shader Graph has `activateWorkspace` in `store/shader-graph.ts`. + +So all three have the machinery and none of them has an owner. **The missing piece is a workspace +concept at the application level**, not in each tool. + +Requirements: one shared notion of "the thing I am working on" — named, listed, switchable, saved on +change, and supplying the id each tool's existing persistence already wants. Deleting a workspace +should be explicit and confirmed; this is the one place in Feather where a confirmation is warranted, +because it is the only irreversible destruction of the user's own work. + +**Acceptance:** build something in each tool, force-quit Studio, reopen it, and find all three +exactly as you left them. + +**Check:** do it by hand — `pnpm run studio:tauri`, work, `kill -9` the process, reopen. An +automated test is welcome but the manual check is the one that matters, because it is the one a user +performs. + +--- + +### S5 — Apply what Inspector already learned — **C0, C1, C3 DONE 2026-09-10** + +**Goal:** Studio starts where Inspector finished rather than rediscovering it. + +**C0, C1 and C3 landed. C2 and C5 remain.** + +#### C0 — semantic colour + +The derivation is **shared, not ported**. `packages/ui/src/theme/semantic.ts` owns the +contrast-solving — hand-copying 211 lines of it would have produced the divergence this repository +keeps finding, and the two apps cannot import each other, so the package was the only honest home. + +**82 of 86 raw literals became tokens.** The other four are identity, not state, and now say so with +an inline disable: Texture Lab's move and resize handles (told apart by shape *and* colour over +arbitrary texture content) and Shader Graph's violet "Probe" badge, which marks *what a node is*, +not that something is wrong with it. + +**The C0 regression happened again, and was caught.** A mechanical pass turned the two drag handles +into `bg-info-surface` / `bg-ok-surface` — a pale region tint on a 12px solid mark over a texture, +which is invisible. `verify:enhanced` now checks for exactly that shape. + +#### The whole registry is shared now, not just the derivation + +*(2026-09-10, on the owner's call.)* Ten of the eleven theme files were **byte-identical** between +the two apps — one registry maintained twice, 4,602 duplicated lines — and the eleventh differed +only because C0 had landed in one of them. Same shape as the particle semantics: the copy that has +not drifted yet is the one that is about to. + +`packages/ui/src/theme/` holds all of it: `registry/` (79 themes across five families), `dark.ts`, +`light.ts`, `semantic.ts`, and the `NOTICE.md` attribution that belongs with them. Both apps import +`@feather/ui/theme/registry`; neither has an `assets/theme` directory any more. **4,824 lines +deleted, 45 added**, across six import sites. With `types.ts` now a sibling of `semantic.ts`, the +generic signature the split had forced went away too. + +#### C1 — panel state: already satisfied + +Checked rather than assumed. Studio persists the active tool (`studio-ui.ts`), preferences including +collapsed palette categories (`studio-preferences.ts`), and the shader graph itself. A survey found +exactly **one** un-persisted filter: the node palette's search box. It stays that way deliberately — +you type it to find a node and immediately drag that node out, so persisting it would reopen the +palette pre-filtered, which is the "reopening into a state nobody left it in" trap C1 warns about. + +#### C3 — next actions: mostly satisfied, two gaps closed + +Of 15 error and empty states, most already name the action, and "No composites yet" has a **New +Composite** button directly beneath it. Two did not: the node picker's "No nodes found" now says the +search is filtering and that clearing it shows everything, and three connected-game preview failures +now name the likely cause and what to check. + +#### Still open from this phase + +- **C2 — freshness.** Only applies in `attached` mode, and matters there: a preview reflecting a game + that has since restarted is the same lie. `apps/inspector/src/hooks/use-freshness.ts` judges + against the session's `connectedAt`, not the clock. +- **C5 — third-party data.** The shader graph loads user files and the tools accept imported + workspaces. Be strict wherever a value authored outside this repository reaches rendering. + +**Acceptance — both met.** `pnpm run verify:enhanced` has an S5 section with five checks, and the +`no-restricted-syntax` rule covers `apps/studio/src` alongside `apps/inspector/src`. The rule was +confirmed to bite by reintroducing a literal. `themeSemanticColors.test.ts` asserts every shipped +theme clears WCAG AA for all four states against page, card **and its own surface**, and that +neither app has grown its own registry back. + +--- + +## 7. Reference + +### 7.1 Session semantics — read before S3 + +`apps/studio/src/session/index.ts` mirrors Inspector's session shape so 25,500 lines of tools could +move without being rewritten. The mirror is fed **only** by `syncFromBridge`, from Inspector, over +the bridge. + +Consequences, all true today: + +- With no Inspector: `sessionId` is `null`, `sessions` is `{}`, `activeSession` is `null`. +- `isCreativeSession(null)` → `false`. It does **not** mean "local"; it means "not one of Inspector's + creative sessions", and Studio never makes one. +- `sessionSupportsRuntime(null)` → `false`. This one is honest: there is no game to push to. +- So `!sessionSupportsRuntime(activeSession)` is the correct test for "local mode" today, which is + why Shader Graph works standalone and the other two do not. + +### 7.2 The Inspector bridge + +`apps/studio/src/useInspectorBridge.ts` returns: + +```ts +type InspectorConnection = { + bridge: SessionBridge; + connected: boolean; + session: SessionDescription; + error: string | null; + connect: () => Promise; +}; +``` + +It starts as a **null bridge** and only connects on an explicit user action — Studio never probes +loopback by itself. Keep that: it is a deliberate security property, not an oversight. + +The contract lives in `packages/session-bridge/`, with 16 tests in +`packages/session-bridge/test/bridge.test.mts`, most of them negative (forged capability, revoked, +expired, incompatible peer, no session). Read them before changing anything about the connection. + +### 7.3 Ports + +`4004` game WebSocket · `4005` Inspector MCP bridge · `4006` CLI MCP HTTP transport · +`4007` Studio creative MCP · `4008` Studio↔Inspector bridge · `1420` Inspector dev · `1430` Studio dev. + +### 7.4 Commands + +```sh +pnpm run studio:dev # Studio web dev server, port 1430 +pnpm run studio:tauri # Studio desktop app +pnpm run studio:build # production build +pnpm run typecheck:studio +pnpm run test:studio:e2e # Playwright, apps/studio/e2e/ +pnpm run test:showcase:e2e # the showcase renders the same tools — run it after S3 +pnpm run verify # typecheck, lint, drift checks (~5s warm) +pnpm run test # every lane, serially, ~3 min +pnpm run verify:enhanced # the V4-ENHANCED gate; extend it in S5 +``` + +--- + +## 8. Traps + +Each of these has already cost time in this repository. They are listed so they cost it once. + +**The existing Studio e2e tests assert the placeholder.** `apps/studio/e2e/studio.spec.ts` has four +tests checking for the subtitle "Shader graph · Texture lab · Particle playground", the connect +button and the note. S2 will break them, and that is correct — they encode the state you are +replacing. Rewrite them to assert the property you want, not the markup you have. This is V4.md +**D43**: a test that asserts current behaviour locks in the thing you are trying to change. + +**An app's config is part of the migration.** When previews moved out of Inspector, the love.js Vite +plugin was extracted so *"only Studio and the showcase consume this"* — its own words. The showcase +got wired up; **Studio never did**. `apps/studio/vite.config.ts` had no plugin and no +`publicDir`, so every preview iframe requested `/showcase-lovejs/...`, hit the SPA fallback, and +rendered **Studio inside its own iframe**. Reported as *"the preview node... is currently displaying +the page itself"*. Studio's `build` was missing the two asset steps too, so the packaged app shipped +the same hole. + +This is the Phase 05c pattern again (§1): the code moved, the tests passed, and the product it +moved *to* did not work. When a shared module names its consumers, check that each one actually +imports it — and check the build output, not just the dev server. *Guarded by* `the love.js preview +target is served, not the app itself`, which also asserts `showcase.love` and the player are there, +since an entry page that resolves while its assets 404 is the same defect one level down. + +**Verify a test by breaking the thing it tests.** Three tests in this work passed against unfixed +code before anyone noticed. The routine that catches it: make the fix, watch the test pass, revert +the fix, watch it fail, restore. If it does not fail, it is not testing what you think. + +**Extract the judgement and test that.** Studio's e2e harness is thinner than Inspector's. Where a +browser test is hard to get right, pull the decision into a pure module and unit test it — that +resolved this problem three times in Inspector (`searchableText`, `isFromPreviousRun`, +`profilerStateForNewSession`, `pluginNavItems`). It is faster than fighting a harness and leaves +better-shaped code. + +**Adding a field to a persisted store is a migration.** State written before your field rehydrates +without it. A controlled component handed `undefined` can render nothing at all. Give every new +persisted field a fallback at the point of use. + +**Inspector and Studio must not import each other.** Enforced by `no-restricted-imports` in +`eslint.config.js` for `apps/inspector/src/**` and `apps/studio/src/**`. Shared code goes in +`packages/`. If S5 shares the semantic ramps, that is where they belong. + +**The runtime is disciplined; third parties are not.** Feather's own Lua runtime is careful with the +payloads it sends. The values worth being strict about are the ones authored outside this repository +— plugin manifests, imported workspaces, user files. + +--- + +## 9. Definition of done + +Studio is finished as a tool when all of these are true: + +1. A developer installs Studio, opens it with **no Inspector and no game**, and builds a shader, a + particle system and a texture without ever seeing a session-required state. +2. Their work is still there after a force-quit and reopen. +3. Connecting to Inspector **adds** live preview, and disconnecting removes it without losing work + or showing an error. +4. Every state color follows the user's theme and clears WCAG AA on it. +5. Studio contributes no color of its own, and every preview sits on a neutral ground the user can + change — so what someone sees while authoring is the artifact, not the interface's opinion of it + (§5). *(Dark-by-default is deferred: see S1.)* +6. `pnpm run verify:enhanced` covers Studio and passes. +7. `pnpm run test` passes 7/7. + +Then Studio gets its own feature-by-feature section in V4-ENHANCED, and the same lens (L1–L6) is +applied to each of its three tools — which is where the real refinement of the creative work begins. +This document only gets it to the point where that is worth doing. diff --git a/V4.md b/V4.md new file mode 100644 index 00000000..252da989 --- /dev/null +++ b/V4.md @@ -0,0 +1,1547 @@ +# Feather v4 — Architecture, Guidelines, and Migration Plan + +> **Companion document.** V4.md is about *structure* — separating products so they ship +> independently. [`V4-ENHANCED.md`](./V4-ENHANCED.md) is about *quality* — refining the features +> that already exist, without adding new ones. Structural work belongs here; feature-level +> refinement belongs there. + +> **Status:** Phases 01-04 and 06 implemented · Phase 05 is the only structural phase outstanding · +> cross-phase follow-ups remain in §8.1 · **Last updated:** 2026-09-08 · **Baseline:** +> `main` @ `41689d1` +> +> This document is the single source of truth for the v4 re-architecture. It is written so that +> a person or an agent with no prior context can open it, find the next unstarted task, and do it +> correctly. If you change the plan, change this file in the same commit. + +--- + +## 0. How to use this document + +### If you are picking up work + +1. Read **§1 Why v4 exists** and **§3 The organizing principle**. They are short and they are what + keep the migration from drifting into a generic monorepo refactor. +2. Read **§4 Invariants**. These hold for every commit, not just at the end. +3. Open **§8 Status board**, find the first phase whose implementation is not marked `DONE`, and go + to that phase in **§7**. Also check **§8.1 Cross-phase follow-ups** for release proofs and small + compatibility work that does not belong to the structural phase sequence. +4. Do not start a phase whose **Preconditions** are unmet. The dependency edges are load-bearing; + the numeric order is the preferred execution order, not a claim that independent work must wait. + Phase 02 in particular must land before Phase 01's version skew is exposed to users. Phase 06 is + independent of Phase 05, as recorded in **D24**. +5. When you finish a task, tick its checkbox in §7 and update §8 in the same commit. + +### If you are an agent + +- Read `AGENTS.md` first for repo-wide rules (docs rules, changelog rules, generated files, e2e + rules). **This document does not replace it.** V4.md governs *what to build and in what order*; + AGENTS.md governs *how to work in this repo*. +- Read the subsystem skill named in each phase's **Skills** line before editing that subsystem. +- Every phase lists a **Verify** block. Run it. Report actual output, including failures. +- If a task turns out to be wrong or blocked, do not silently skip it. Add a note under the task, + update §8/§8.1, record any owner decision in §9, and stop for a human. + +### Recording progress + +Progress lives in four places and they must agree: + +| Where | What it records | +| --- | --- | +| §7 task checkboxes | Fine-grained: individual tasks within a phase | +| §8 Status board | Coarse: implementation and acceptance state by phase | +| §8.1 follow-ups | External release proofs and small cross-phase compatibility checks | +| `CHANGELOG.md` | User-visible outcomes only (per AGENTS.md changelog rules) | + +Internal migration steps that change nothing user-visible do **not** go in `CHANGELOG.md`. + +--- + +## 1. Why v4 exists + +### The problem + +**Feather cannot ship one thing.** A one-character fix in the CLI currently costs a notarized +desktop build on four platforms, a new LuaRocks rock, and a republished VSIX, because all of them +are keyed to the same git tag. + +Two workflows trigger on `tags: - "*"`: + +- `.github/workflows/release.yml` — runs `npm-cli-release`, `luarocks-release`, and `tauri-release` + (a 4-platform matrix with Apple signing and notarization). +- `.github/workflows/vscode-extension.yml` — builds the Bun binary matrix and publishes the VSIX. + +Each job hard-fails unless its package version equals the tag. Duplicate publishes are blocked on +purpose (`fail_on_duplicate: true` for LuaRocks, an explicit `npm view` check for npm), so a tag +cannot be re-cut — every release must be a fresh version *everywhere*. + +The version is kept in lock-step by `scripts/set-version.sh`, which `sed`s one number into seven +files. Critically, **`.husky/pre-commit` runs that script on every commit and fails if the files +drift**, so lock-step is enforced continuously, not just at release time. + +> **Resolved by Phase 01 (2026-09-08).** The section above describes the state at the `41689d1` +> baseline and is kept as the motivating diagnosis. Tags are now per-train, `set-version.sh` is +> train-scoped, and the pre-commit hook only enforces the desktop trio. See §7 Phase 01. + +### What v4 is not + +v4 is a **release-boundary** re-architecture. It is not: + +- a rewrite of any tool's behavior, +- a redesign of the user-facing `init → run → debug → build → release` workflow, +- a plugin SDK overhaul, +- an excuse to reorganize code that ships together and always has. + +### Evidence the seams are real + +The repo is already working around the missing boundaries in four visible ways: + +1. **The desktop app is two products in one shell.** Of 36,980 lines in `src/pages`, three + session-free creative tools account for 24,671 — **67%**. `src/router.tsx` mounts `/shader-graph` + and `/texture-lab` with *no session guard*, while every inspector route is wrapped in + `RequireRuntimeSession`. + +2. **Desktop pages import the web-only build.** `src/pages/particle-system-playground/index.tsx:14`, + `src/pages/texture-lab/index.tsx:5`, and `src/components/session-tabs.tsx:60` all import + `@/showcase/use-local-particle-playground`. The desktop app needed browser-shaped persistence, + found it in the showcase, and imported across the boundary instead of declaring a capability. + +3. **The showcase is a fork-by-config.** `vite.config.ts` and `vite.showcase.config.ts` + independently define the same `loveJsPreviewHeaders` CSP block, the same `/showcase-lovejs` + middleware, and the same three-entry `luaWatchFiles` list. + +4. **The CLI copies its sibling's source into its own publish directory.** `cli/package.json`'s + `bundle:lua` runs `bash ../scripts/bundle-lua.sh`, which does + `rm -rf cli/lua && cp -r src-lua/{feather,plugins}`. The Lua runtime — 27 built-in plugins plus + the core library, published separately to LuaRocks — has no package identity. + +--- + +## 2. Goals and non-goals + +### Goals + +| # | Goal | How we know it's met | +| --- | --- | --- | +| G1 | Ship any one artifact without shipping the others | A CLI patch publishes to npm and triggers no runtime, Inspector, Studio, or VSIX job | +| G2 | Make mixed versions safe | A runtime/Inspector protocol mismatch produces a clear, actionable error, not undefined behavior | +| G3 | One source of truth for the wire message registry | Every message name and the shared envelope are declared once; TS and generated Lua consumers cannot drift | +| G4 | Studio is its own application | Studio produces its own desktop binary and web build; Inspector ships none of the creative-tool routes or code | +| G5 | The build graph and persisted cache decide CI work | Build/test workflows invoke Turbo rather than maintaining duplicated `paths:` lists | +| G6 | Each desktop app owns its automation surface | One `feather mcp` server routes session/debug work to Inspector's backend and creative work to Studio's; each app releases its own half independently (D32) | +| G7 | Feather still feels like one product | Shared UI package, shared protocol, one docs site, one command vocabulary | + +### Non-goals + +These are explicitly **out of scope for v4**. Do not expand into them. + +- The user-facing workflow (`init`, `run`, `watch`, `build`, `upload`, `release`). See `ROADMAP.md`. +- Plugin SDK versioning and the plugin UI/action protocol. `packages/protocol` is the natural home + for it *later*; pulling it in now widens v2 past the release problem. +- Generated-file strategy. `registry.json` and `plugin-catalog.ts` stay committed with their + `check:*` guards. +- Redesigning desktop code signing and notarization. Inspector keeps its current secrets and steps; + Studio gets its own release path using the same proven mechanics and a distinct artifact/channel. +- Splitting the 13 inspector surfaces. They share a session, a sidebar, and a socket, and have never + needed to ship apart. + +### Success criteria + +v4 is done when all of the following are true: + +- [ ] `git tag cli-v4.0.1 && git push --tags` publishes the CLI and nothing else. +- [x] A protocol version mismatch between runtime and Inspector shows a named error in the UI. +- [x] `packages/protocol` is the only place a wire message name or common envelope is declared. +- [x] `apps/studio` builds and releases as its own signed desktop application + (`com.kyonru.love.feather.studio`, `studio-v*`). Release run itself is pending push access. +- [x] `apps/showcase` contains no page code — one entry file importing Studio's public web mount. +- [x] `vite.showcase.config.ts` no longer exists. +- [x] No Studio route, page code, or built asset ships in Inspector — enforced by + `check:artifact-boundary`, which scans the built bundles, not just the source. +- [x] No file under `apps/inspector` imports Studio — 0 matches, and a lint guard rejects it. +- [ ] A `studio-v*` release builds Studio and triggers no Inspector, CLI, runtime, or extension release. +- [x] **Reframed by D32.** There is one MCP server. Inspector's backend serves session, runtime and + debugging; with Studio absent, creative calls report "Feather Studio is not running" by name. +- [x] **Reframed by D32.** Studio's endpoint serves only its own authoring state and runs + independently of Inspector. +- [x] **Superseded by D32.** One server, routed to both backends; `--studio-url` and + `--studio-token` point at Studio's side. +- [x] All pre-migration `pnpm run test:*` equivalents pass on `main`. +- [x] All suites pass: Inspector 44, Studio 4, Showcase 21, bridge 13, unit 93, CLI 409, + Lua 734 assertions, Rust 28 + 3. + +--- + +## 3. The organizing principle + +> **A directory earns package status only when it is a release boundary or a shared contract.** + +Everything in the target layout is one or the other. Nothing is a package because it "feels like a +module." + +### Corollaries + +- **Do not split by aesthetics.** If two things always ship together and always will, they stay + together. This is why the inspector's 13 surfaces remain one app. +- **Do not create a package to hold shared code unless the sharing is a contract.** `packages/ui` + qualifies because it is a stable API consumed by two independently-released apps. A `packages/utils` + grab-bag would not. +- **A new package must declare its train.** See §6. If you cannot say which train it rides, it is + not a package yet. + +### When in doubt + +Ask: *"If I tag a release right now, does this directory need to be in it?"* +If the answer is "sometimes yes, sometimes no," it is a release boundary. If the answer is "always," +it belongs inside something that already ships. + +--- + +## 4. Invariants + +These hold for **every commit** during the migration, not just at phase boundaries. + +1. **`main` is always shippable.** Every phase leaves every currently implemented release path + working. Phase 05 adds Studio as a fifth tagged release path; it must be shippable before its + trigger is enabled. If a phase cannot be landed in a shippable state, split it. +2. **No phase may leave a train un-releasable.** If you change a release workflow, prove it with a + dry-run or a pre-release tag before merging. +3. **Protocol changes keep every real consumer in sync.** After Phase 02, a message-registry change + must update the generated Lua table and pass the TypeScript/dispatch-table checks. Do not + generate Rust types for messages the transparent relay does not deserialize (**D15**). +4. **Cross-package imports go through package entry points.** No reaching into another package's + `src/` by relative path. pnpm will enforce most of this after Phase 03; until then it is a review + rule. +5. **Generated files stay in sync.** `pnpm run check:registry` and `pnpm run check:plugin-catalog` + must pass. The pre-commit hook already enforces this — do not weaken it. +6. **Docs move with the code.** Per AGENTS.md, docs live beside the subsystem and are exposed + through `docs/` by symlink. When a subsystem moves, its symlink target moves in the same commit. +7. **Commit scopes must exist before you use them.** `.husky/commit-msg` whitelists scope prefixes. + New packages need their scope added to that list *first*, or every commit is rejected. See Phase + 05b. +8. **Do not weaken `.husky/pre-push` without replacing it.** It currently runs four e2e suites. It + may become Turbo-cached (Phase 06), but it may not simply be deleted. Phase 05 adds separate + Inspector, Studio, and bridge coverage before retiring the combined app suite. + +--- + +## 5. Target architecture + +### Layout + +```text +feather/ +├── apps/ Everything that ships to a user +│ ├── inspector/ [platform] Tauri desktop. Session-bound debugging surfaces. +│ ├── studio/ [satellite] Shader graph · texture lab · particle lab. +│ │ Its own binary, identifier, version and release feed. +│ ├── showcase/ [satellite] Thin shell: Studio for the web. No page code. +│ ├── cli/ [platform] @kyonru/feather. +│ ├── vscode-extension/ [satellite] Vendors a pinned platform snapshot. +│ └── docs/ [satellite] Zensical site. +├── packages/ Consumed by the applications, shipped by none +│ ├── protocol/ ◆ Wire message registry and envelope. +│ ├── host/ ◆ Environment capabilities. tauri/ and web/ impls. +│ ├── session-bridge/ ◆ Versioned Inspector↔Studio contract. +│ ├── ui/ Shared interface primitives. +│ └── runtime-lua/ The Lua runtime and built-in plugins. To LuaRocks. +└── catalog/ + └── packages/ Love2D package registry. + +◆ = new. These three contracts are what make the split hold. +``` + +### The three internal contracts + +Separated builds fragment a product unless the seams are named and typed. These three contracts +hold the internal split together. Section 5.4 separately splits the external MCP surface into two +app-owned backends behind one server, so automation follows the same release boundaries without +making an agent configure two endpoints. + +#### 5.1 `packages/protocol` — the cohesion anchor + +At the baseline, wire message names existed only as string literals scattered across TypeScript and +Lua. The authoritative Phase 02 inventory found **78 messages**: 44 Inspector→game names (including +auth, command, and request messages) and 34 game→Inspector names. The first estimate counted 24 +because its search missed underscores; a later call-site scan estimated ~83 because it counted +duplicates. `src-tauri/src/ws_server.rs` does not parse the application messages — it relays raw +JSON text. Phase 02 resolves name/envelope drift without manufacturing payload types for that relay. + +The outbound (`cmd:`/`req:`) message set: + +```text +cmd:assets:preview cmd:debugger:enable cmd:plugin:params req:config +cmd:assets:toggle cmd:eval cmd:plugin:toggle req:console:globals +cmd:config cmd:log cmd:profiler req:console:pins +cmd:console:pin cmd:plugin:action cmd:runtime req:observers +cmd:console:unpin cmd:plugin:action:cancel cmd:runtime:interest req:performance +cmd:debugger:continue req:assets req:plugins +cmd:debugger:disable +``` + +**Implemented target:** a TypeScript registry is the source of truth for all message names and the +common envelope. TypeScript consumers use derived unions and guards; a generator emits the Lua +message table; `check:protocol` verifies both live dispatch tables and the generated output. Payload +data remains `unknown` until a handler has a load-bearing reason to own a schema (**D14**), and Rust +stays a transparent relay for messages it does not deserialize (**D15**). + +A standalone integer `protocolVersion` rides the existing `feather:hello` exchange, with a named, +actionable Inspector error on mismatch. The inter-application Studio bridge gets its own negotiated +compatibility version in Phase 05; it is not silently coupled to the game wire protocol. + +> **This is the load-bearing piece.** The moment the release trains are decoupled, "they always ship +> together" stops being true — a user can run CLI 4.2 against desktop 4.0. This is why Phase 02 +> follows Phase 01 immediately and before anything else. + +#### 5.2 `packages/host` — what the surrounding environment can do + +The studio's entire dependence on Tauri is roughly seven call sites across +`@tauri-apps/plugin-fs`, `@tauri-apps/plugin-dialog`, and `@tauri-apps/api/core` — open a file, save +a file, pick a path. That is a tiny interface, and it already has two implementations in the repo +(Tauri, and the browser code hiding in `src/showcase`). + +```ts +export interface FeatherHost { + openFile(accept: string[]): Promise; + saveFile(name: string, data: Uint8Array): Promise; + pickDirectory(): Promise; + workspace: WorkspaceStore; // fs-backed on desktop | IndexedDB-backed on web + theme: ThemeSource; + openExternal(url: string): void; +} + +// apps/studio → createTauriHost() +// apps/showcase → createWebHost() +``` + +Studio owns its workspace state in both builds. `use-local-particle-playground` therefore belongs to +Studio, not to either host implementation. The host only supplies environment capabilities, and the +inversion described in §1 disappears. + +#### 5.3 `packages/session-bridge` — a versioned process boundary + +The studio's coupling back to the inspector is narrower than it looks: **four modules across nine +import sites.** Every one of them means "push this to the running game," which is exactly the +capability the web build does not have. + +| Module the studio imports | Sites | Becomes | +| --- | --- | --- | +| `@/store/session` | 5 | `bridge.session` — `null` on web | +| `@/hooks/use-plugin-control` | 2 | `bridge.pluginControl` | +| `@/lib/send-command` | 1 | `bridge.send` | +| `@/utils/love-preview-upload-bridge` | 1 | `bridge.uploadPreview` | + +Phase 04's module-level `CommandSender` registry is a transitional in-process seam. It is not the +final boundary: once Studio is a separate binary, it cannot call Inspector's Tauri commands or read +Inspector's stores. + +The target is a small, versioned inter-process contract: + +- Inspector remains the only owner of the authenticated game session and WebSocket server. +- Studio runs fully standalone with `bridge: null`; the showcase always uses that mode. +- An explicit local pairing lets Studio send the contract's high-level commands to Inspector. +- The native transport is loopback-only, authenticated with a short-lived Inspector-issued + capability, and negotiates a bridge protocol range before accepting commands. Bind explicitly to + loopback; never log or persist the capability or place it in a URL. +- Disconnect, incompatible-version, revoked-capability, and Inspector-not-running states are + visible and recoverable. Studio must never silently fall back from a rejected bridge to an + unauthenticated channel. +- Neither application imports the other's source or loads the other's JavaScript. Both consume + `@feather/session-bridge` through its public entry points. + +Phase 05 must prove this transport in a narrow vertical slice before moving the three tools. The +bridge is a product integration between two applications, not a reason to put Studio back inside +Inspector. + +#### 5.4 One MCP server, two backends + +> **Reframed by D32.** This section originally specified two MCP servers with a `feather mcp +> --target ` selector. That was reversed before implementation: agents configure **one** +> server. The state-ownership split below survived the reversal and is what shipped; the +> two-endpoint topology did not. D42 keeps the original text. + +Inspector and Studio own different state, and that split is real. What it does *not* justify is +making an agent discover, authorize and configure two servers — MCP's consumer is an agent, and two +endpoints with two tokens is worse for exactly the audience the feature serves. So ownership is +expressed as **routing behind a single server**, not as two servers. + +MCP is also distinct from `session-bridge`: MCP serves external AI clients, while `session-bridge` +is the private, authenticated integration between the two Feather applications. + +**Inspector's backend owns live operational work:** + +- session discovery and sanitized config, logs, performance, assets, observers, and plugin state, +- runtime refresh/control and generic authenticated commands, +- debugger state, breakpoints, stepping, frame inspection, and source context, +- Console actions under the existing Console/API-key gates, +- time travel and Session Replay recording/playback/import workflows, +- built-in plugin catalog metadata and live plugin actions. + +**Studio's backend owns creative work:** + +- Shader Graph snapshot, compile, import/export, and preview workflows, +- Particle Playground authoring, parameters, previews, and project/code/ZIP exports, +- Texture Lab recipes, generators, texture/atlas generation, and saved creative workspace state, +- high-level shader, particle-system, and texture creation tools. + +Each app still owns and versions the handlers, schemas and creative/operational state behind its +half, and each mints its **own** loopback token per launch on its **own** port — Inspector 4005, +Studio 4007. Neither reads the other's credentials, and rotating or disabling one does not affect +the other. Both endpoints stay loopback-only, origin-restricted where applicable, and redacted; +Inspector's Console and command tools retain their additional runtime authorization gates. + +What is *not* separate is the server an agent configures. `feather mcp` is that server: it serves +the union and delegates `/creative/*` to Studio, everything else to Inspector. When the app behind a +route is not running, the error names it (`Feather Studio is not running. Start it and try again.`) +rather than failing as a generic transport error. + +```sh +feather mcp # one server; routes to whichever apps are running +feather mcp --studio-url http://127.0.0.1:4007 # default; override if Studio moved +feather mcp --studio-token # Studio mints its own per launch +feather mcp setup --client claude # writes one entry, not two +``` + +`FEATHER_STUDIO_MCP_URL` and `FEATHER_STUDIO_MCP_TOKEN` are the environment equivalents. There is no +`--target` flag; it presumed a choice that no longer exists. + +Studio works without Inspector for local creative operations; in-game preview tools report a clear +"Inspector not paired" state and use `session-bridge` only after explicit pairing. Inspector's +backend never serves creative actions, and Studio's never serves operational ones — the router, not +either app, is what joins them. + +### What deliberately stays together + +| Kept together | Why | +| --- | --- | +| The 13 inspector surfaces | Share a session, a sidebar, and a socket. Never shipped apart. | +| The 3 creative tools | They cross-import and therefore move together into the separate Studio application. None remain in Inspector. | +| The 27 built-in plugins | Ride the platform train with the runtime. Plugin SDK versioning is a non-goal. | +| `feather-server` with `inspector` | The Rust shell has no independent consumer. | + +--- + +## 6. Compatibility family, release trains, and versioning + +**Model: one compatibility family with independently releasable trains.** + +The *platform family* is the set of artifacts that participate in the game wire protocol. It is a +compatibility concept, not a single lock-step release train. CLI, runtime, and Inspector can each +ship alone; the umbrella `v*` tag remains a convenient coordinated release that gives all three the +same version. Studio is a separate application and release train with a separate bridge contract. + +| Train | Members | Tag | Publishes | +| --- | --- | --- | --- | +| Coordinated platform | CLI + runtime + Inspector/server | `v4.1.0` | All three platform artifacts at one version | +| CLI | `cli` | `cli-v*` | npm package + generic MCP transport/selection shim | +| Runtime | `runtime-lua` | `runtime-v*` | LuaRocks artifact only | +| Inspector | `inspector`, `feather-server`, the MCP session/debug backend (:4005) | `desktop-v*` | Signed/notarized Inspector binaries + session/debug MCP backend | +| Studio | `studio`, its Tauri shell, the MCP creative backend (:4007) | `studio-v*` | Signed/notarized Studio binaries + creative MCP backend | +| Extension | `vscode-extension` | `ext-v*` | Marketplace VSIX | +| Continuous web | `showcase`, `docs` | — | Deployments from `main`; showcase builds Studio's public web entry | +| Catalog | `catalog/packages` | — | Registry push (already independent) | + +### Worked examples + +- **CLI patch** → tag `cli-v4.0.1`. npm publishes. LuaRocks is skipped because the runtime content + hash is unchanged. Inspector, Studio, and VSIX are skipped. +- **Shader graph feature** → tag `studio-v1.3.0`. Studio binaries publish. Inspector is untouched; + the showcase deploys the same Studio source through its independent web pipeline. +- **Debugger MCP improvement** → tag `desktop-v4.0.2`. Inspector and its session/debug MCP surface + publish together; Studio and the npm CLI package are untouched. +- **Texture-generation MCP improvement** → tag `studio-v1.3.1`. Studio and its creative MCP surface + publish together; Inspector is untouched. +- **Game protocol change** → platform tag `v4.1.0`. Runtime, CLI, Inspector, and server move + together when the compatibility change requires it. Studio stays independent unless the + Inspector/Studio bridge contract also changes. + +### Studio is a separate application, not a downloaded bundle + +“Separate bundle” means a separate installed application and native binary. Inspector never embeds, +downloads, remotely loads, or executes Studio JavaScript. Studio has its own application identity, +version source, Tauri configuration, permissions, icons, platform artifacts, signing/notarization, +and release workflow. Reuse the existing desktop release mechanics, but do not share an artifact or +release feed with Inspector. If automatic updating is added later, each app must also have a +separate update identity and channel. + +This makes the trust boundary ordinary: installing or updating Studio cannot mutate an Inspector +installation, and an Inspector update cannot replace Studio. Inspector may offer an explicit +“Open in Studio” or installation link, but integration happens only through the authenticated local +bridge in §5.3. The web showcase is a separate deployment target compiled from Studio's public web +entry; it is not code loaded by Inspector. + +### The extension vendors a snapshot + +`vscode-extension/src/cli.ts:13-25` resolves the CLI from `bundled-bin/`, and +`vscode-extension/scripts/prepare.mjs` copies four Bun-compiled binaries, the Lua runtime, +`registry.json`, and `plugin-catalog.json` into it. **The extension does not call the user's CLI.** + +That makes it hermetic and therefore easy to decouple — but it takes on a different obligation: + +- The extension must record which platform version it vendored, as `featherPlatform` in its + `package.json`. +- `prepare.mjs` must verify the bundled binaries and runtime came from that platform version and fail + loudly if they did not. +- The extension must surface the vendored platform version in its status/about output, so a user + reporting a bug reports a coherent pair. + +### The honest trade + +Satellites are not free. Studio declares a supported bridge-protocol range; Inspector translates +that high-level bridge contract to its game session, so Studio does not couple itself directly to a +runtime version. The extension pins a platform snapshot. You are trading +"one number, always correct" for "a small number of declared ranges, verified in CI." + +**This only works if CI actually enforces them.** A declared-but-unchecked range is worse than +lock-step: it is a loose system that is quietly wrong. The core wire checks landed in Phase 02; +Studio's checks land with the application in Phase 05, and the extension snapshot check is tracked +explicitly in §8.1. + +--- + +## 7. Migration phases + +Each phase is independently landable and leaves `main` shippable. **Preconditions are load-bearing; +numeric order is preferred, not absolute.** + +--- + +### Phase 01 — Split the release trains + +**Goal:** Land the entire stated pain point with zero file moves. +**Why here:** It is the highest-value, lowest-risk change, and it is independently revertable. +**Preconditions:** None. +**Skills:** `skills/feather-cli-builds/SKILL.md` + +#### Tasks + +- [x] Replace the `tags: - "*"` trigger with per-train tag prefixes. Final vocabulary is finer than + first sketched — see **D7** in the decision log: `v*`, `cli-v*`, `runtime-v*`, `desktop-v*`, + `ext-v*`, plus the reserved `studio-v*` prefix that Phase 05 activates. +- [x] Split `release.yml` into per-train workflows. Implemented as three reusable workflows + (`_release-cli.yml`, `_release-runtime.yml`, `_release-desktop.yml`) plus thin triggers + (`release-cli.yml`, `release-runtime.yml`, `release-desktop.yml`), so `v*` still fires all + three while each prefix fires only its own. `release.yml` deleted. +- [x] Change `.github/workflows/vscode-extension.yml` to trigger on `ext-v*` only. +- [x] Add a guard so `luarocks-release` no-ops when `packages/runtime-lua/feather` is unchanged since the last + runtime release. Implemented in `_release-runtime.yml` as a `guard` job using git history + rather than a stored hash, with a `force` dispatch input. +- [x] Rewrite `scripts/set-version.sh` to take a train argument and write only that train's files. + Also made it portable — the previous version used macOS-only `sed -i ''` and would have failed + on CI's GNU sed. +- [x] **Update `.husky/pre-commit`** — now runs `set-version.sh desktop` and checks only the desktop + trio (`package.json`, `src-tauri/Cargo.toml`, `src-tauri/tauri.conf.json`). +- [x] Make `e2e/app.spec.ts`'s `seedHealthySessionConfig` stop tracking the app version. Resolved + better than planned: the fixture now pins a *deliberately different* runtime version (`3.9.0`), + which both removes it from version bumps and asserts that drift does not degrade session health. +- [x] Document the tag vocabulary in `CONTRIBUTING.md` (new **Release Trains** section). +- [x] **Discovered during implementation:** `src/pages/session/index.tsx` compared the Lua runtime + version against the desktop app version and raised a "Version or plugin mismatch" warning when + they differed. That only ever held because lock-step guaranteed equality — decoupling the + trains would have made every session show a false warning. Removed `versionMismatch` from the + two warning conditions and the summary chip tone; both versions are still *displayed*. + `config.API` remains the compatibility signal until Phase 02 replaces it. +- [x] Add `scripts/release-tag-version.sh` — single definition of tag parsing, shared by every + release workflow, rejecting tags outside the vocabulary. + +#### Verify + +```sh +pnpm run typecheck && pnpm run lint +pnpm run test:cli:e2e +pnpm run test:app:e2e +bash scripts/release-tag-version.sh cli-v4.0.1 # -> "cli 4.0.1" +bash scripts/set-version.sh cli 4.0.1 # -> touches ONLY cli/package.json +``` + +**Result (2026-09-08):** `typecheck:web`, `typecheck:lua` (145 files), and `lint` clean. +`test:app:e2e` 53 passed. `test:cli:e2e` 409 passed / 0 failed / 1 pre-existing skip. +Tag parser and train scoping verified by round-trip; the runtime guard was dry-run against real git +history (both the publish and the skip path). + +**Still outstanding — requires push access:** dry-run a CLI-only release on a throwaway tag to +confirm only the npm job runs. + +```sh +git tag cli-v0.0.0-rc.1 && git push origin cli-v0.0.0-rc.1 +``` + +#### Done when + +Pushing a `cli-v*` tag runs the npm publish job and nothing else, and a normal commit no longer +rewrites seven version files. + +#### Rollback + +Revert the workflow files and `.husky/pre-commit`. No source code has moved. + +--- + +### Phase 02a — Negotiate the protocol version + +**Goal:** Make the mixed versions that Phase 01 just enabled *safe*. +**Why here:** Phase 01 made version skew reachable by users. Doing 01 without this ships a bug. +**Preconditions:** Phase 01 `DONE`. +**Skills:** `skills/feather-lua-runtime/SKILL.md` **and** `skills/feather-desktop-app/SKILL.md`, +plus `skills/feather-desktop-app/references/tauri-and-protocol.md`. + +#### Tasks + +- [x] Add `FEATHER_PROTOCOL_VERSION` to `packages/runtime-lua/feather/init.lua`, kept distinct from the existing + `FEATHER_API` plugin version. They are different contracts: `API` gates which plugins load, + the protocol version gates whether runtime and desktop can talk at all. +- [x] Advertise it as `protocolVersion` in the `feather:hello` config payload. **Deviation from the + written task** — see **D9**: this rides the *existing* handshake rather than adding one to + `ws_server.rs`. Rust stays a transparent relay, as its skill requires. +- [x] Add `src/constants/feather-protocol.ts` with a supported *range* + (`FEATHER_PROTOCOL_MIN_SUPPORTED`..`FEATHER_PROTOCOL_VERSION`) and `evaluateProtocol()`. +- [x] Surface a named mismatch error with remediation: a persistent toast in + `use-ws-connection.ts` and a Session health warning, plus a Protocol chip and detail row that + show both sides of the negotiation. +- [x] Treat a runtime that reports no `protocolVersion` as `legacy`, **not** as a failure. Every + runtime shipped before this omits the field. See **D10**. +- [x] Add `pnpm run check:protocol` (`scripts/check-protocol.mjs`) verifying the Lua and TS + declarations agree and that the supported range is coherent. Wired into `.husky/pre-commit` + and `.github/workflows/lint.yml`. +- [x] Add Lua e2e coverage asserting `feather:hello` advertises the version and reports the plugin + API separately. +- [x] Add Playwright coverage for both an unsupported version and a legacy runtime. + +#### Verify + +```sh +pnpm run check:protocol +pnpm run typecheck && pnpm run lint +pnpm run test:lua:e2e +pnpm run test:app:e2e +pnpm run test:showcase:e2e +pnpm run test:cli:e2e +``` + +**Result (2026-09-08):** `check:protocol` OK (v1, desktop supports v1..v1) and verified to fail on +both drift and an incoherent range. `typecheck:web`, `typecheck:lua` (145 files), `lint` clean. +Lua e2e 731 assertions. App e2e 55 passed (2 new). Showcase e2e 17 passed. CLI e2e 409 passed / +0 failed / 1 pre-existing skip. + +#### Done when + +A deliberately mismatched `protocolVersion` produces a clear, actionable error in the UI, a legacy +runtime still reads as Healthy, and the two declarations cannot drift. **Met.** + +--- + +### Phase 02b — One source of truth for the message set + +**Goal:** Goal **G3** — the wire message names and common envelope are declared once instead of +drifting across dispatch tables and generated consumers. +**Why separate from 02a:** 02a discharges the safety risk Phase 01 created and is small. This is a +large mechanical change with a different risk profile, and `main` is safe without it. +**Preconditions:** Phase 02a `DONE`. +**Skills:** same as 02a. + +#### Tasks + +**Landed — the message set:** + +- [x] Inventory the full message set. Final count is **78 distinct messages**, derived from the two + authoritative dispatch tables rather than from scattered call sites: **44 desktop→game** + (3 `auth:`, 32 `cmd:`, 9 `req:`) from `Feather:__handleCommand`, and **34 game→desktop** from + the `switch` in `src/hooks/use-ws-connection.ts`. No overlap between directions. +- [x] Create `packages/protocol` (`@feather/protocol`) declaring `TO_GAME` and `TO_DESKTOP`, with + `isToGameMessage` / `isToDesktopMessage` guards and derived union types. Registered as an npm + workspace; `pnpm run typecheck:protocol` added and wired into `pnpm run typecheck`. +- [x] Resolve the `packages/` naming collision — see **D12**. The protocol package sits alongside + the catalog for now; `generate-registry.mjs` filters on `.endsWith('.json')` so it is ignored. + `AGENTS.md` documents the shared directory and the Phase 06 split. +- [x] Extend `check:protocol` to verify the registry against both live dispatch tables, in both + directions, with per-problem remediation. Verified to exit 1 on a declared-but-unhandled + message and on a handled-but-undeclared one. + +**Landed — the envelope and its consumers:** + +- [x] Add `packages/protocol/src/envelope.ts` with `parseInbound()`, replacing the unchecked + `JSON.parse(...) as WsMessage` cast in `src/hooks/use-ws-connection.ts`. An unrecognized type + is reported once per type as a *skew signal* rather than dropped silently. +- [x] Generate the desktop→game set as a Lua table (`pnpm run generate:protocol-lua` → + `packages/runtime-lua/feather/protocol_messages.lua`), consumed by `Feather:__handleCommand` to notice a + command from a newer desktop instead of falling off the end of the dispatch chain. +- [x] Extend `check:protocol` to verify the generated Lua table against the registry, with a + "file is stale" remediation. Verified failing. +- [x] Lua e2e for the unknown-command path (recorded, no reply, deduped by type). + +**Deliberately not done — per-message payload schemas.** See **D14**. Goal **G3** is met: the +message set is declared once and consumed by TS and Lua. Rust is excluded on evidence, not +omission — see **D15**. + +**Compatibility work outside this phase:** Studio's declarations cannot exist until the Studio +application and bridge exist, so they are explicit Phase 05d tasks. The extension snapshot check is +tracked in §8.1. Neither changes this phase's completed message-registry acceptance. + +#### Verify + +```sh +pnpm run check:protocol +pnpm run typecheck && pnpm run lint +pnpm run test:lua:e2e && pnpm run test:tauri:e2e +pnpm run test:app:e2e && pnpm run test:cli:e2e +``` + +**Result (2026-09-08):** `check:protocol` reports 44 desktop→game and 34 game→desktop messages +matching their dispatch tables and the generated Lua table, and fails on drift in any of the three. +`typecheck` (web, protocol, lua), `lint` clean. Lua e2e 734 assertions. App e2e 55. Showcase e2e 17. +CLI e2e 409 passed / 0 failed / 1 pre-existing skip. + +#### Done when + +No wire message name or common envelope is declared outside `packages/protocol`, and CI fails if a +dispatch table or generated Lua output drifts from the registry. **Met.** Payload shapes remain +implicit in each handler by decision (**D14**). + +#### Rollback + +The generated types are additive. Revert the consumers first, then the generators. + +--- + +### Phase 03 — pnpm + Turborepo at the current layout + +**Goal:** Get the task graph and strict linking in place *before* moving code. +**Why here:** Proving the graph reproduces today's CI on a known-good layout isolates tooling +failures from refactor failures. +**Preconditions:** Phase 02 `DONE`. + +#### Tasks + +- [x] Convert `package-lock.json` → `pnpm-lock.yaml`; add `pnpm-workspace.yaml`. Members listed + explicitly rather than by glob, because `packages/` also holds catalog `*.json` data. +- [x] Add `turbo.json`. Root-level scripts use turbo's `//#task` form; `cli` and `protocol` expose + package-level `build` / `typecheck` / `test`. Caching verified (`FULL TURBO`, 11ms on a warm run). +- [x] Leave every directory where it is. **No file moves in this phase.** Confirmed. +- [x] Resolve what strict linking surfaced — see **D17**. It was not undeclared dependencies: it was + a stale npm-created `node_modules/playwright` real directory shadowing pnpm's symlink, giving + two module instances of `@playwright/test` and zero discovered tests. +- [x] Update all nine workflows to pnpm (`pnpm/action-setup`, `--frozen-lockfile`, `cache: pnpm`, + and `pnpm-lock.yaml` in the `paths:` filters). +- [x] Update `.husky/pre-commit` to run lint and the three typechecks through turbo, and + `.husky/pre-push` to pnpm. +- [x] Convert contributor commands in `AGENTS.md` and `CONTRIBUTING.md`. The end-user + `npm install -g @kyonru/feather` line in `README.md` deliberately stays npm. + +#### Verify + +```sh +pnpm install --frozen-lockfile +pnpm exec turbo run //#lint //#typecheck:web //#typecheck:protocol //#typecheck:lua //#check:protocol +pnpm run test:lua:e2e && pnpm run test:app:e2e +pnpm run test:showcase:e2e && pnpm run test:cli:e2e +``` + +**Result (2026-09-08):** turbo 5/5 tasks green and caching. Lua e2e 734 assertions. App e2e 55. +Showcase e2e 17. CLI e2e 409 passed / 0 failed / 1 pre-existing skip. No directory moved. + +#### Done when + +Every check that passed on npm passes on pnpm + turbo, with no directory having moved. **Met.** + +#### Rollback + +Restore `package-lock.json` and the npm-based workflows. Nothing else changed. + +--- + +### Phase 04 — Extract `ui` and `host`, delete the inversion + +**Goal:** Name the capability boundary so the studio can build without Tauri. +**Preconditions:** Phase 03 `DONE`. +**Skills:** `skills/feather-desktop-app/SKILL.md` + +#### Tasks + +- [x] Move `src/components/ui/*` (33 primitives) to `packages/ui`, with `cn` and `useIsMobile`. + 71 import sites rewritten to `@feather/ui/*`. Needed dependency injection — see **D19**. +- [x] Create `packages/host` with the `FeatherHost` interface. Scoped to the five operations the + studio actually performs, derived from its Tauri call sites, not a speculative filesystem API. +- [x] Implement `createTauriHost()`. `@feather/host/tauri` is a separate entry point so a web build + never pulls `@tauri-apps/*` into its bundle. +- [x] Implement `createWebHost()`. This replaced three separate hand-rolled `isWeb()` browser + pickers that had been copied across the studio. +- [x] Delete the three cross-boundary imports. `use-local-particle-playground` moved to the studio + that owns it rather than into the host — see **D20**. `src/showcase/` is now a 6-file shell. +- [x] Create `packages/session-bridge`. It abstracts one thing: `sendCommand`. The session store, + plugin control and upload helper turned out to be portable already — see **D21**. +- [x] Add a lint rule forbidding `@tauri-apps/*` in the studio directories, with a message pointing + at `useHost`. Verified firing on a probe file. + +#### Verify + +```sh +pnpm run typecheck && pnpm run lint +pnpm run test:app:e2e && pnpm run test:showcase:e2e +pnpm run test:lua:e2e && pnpm run test:cli:e2e +``` + +**Result (2026-09-08):** typecheck (web, protocol, host, session-bridge, ui, lua) and lint clean. +App e2e 55. Showcase e2e 17. Lua e2e 734 assertions. CLI e2e 409 passed / 0 failed / 1 skip. + +**Known residual, and a Phase 05 precondition.** The studio no longer imports Tauri *directly*, but +five transitive paths still reach it, all through inspector hooks: + +```text +shader-graph/CodePreview <- use-shader-graph <- use-ws-connection <- @tauri-apps/* +shader-graph/NodePalette <- store/settings <- log-history <- use-logs <- @tauri-apps/plugin-fs +``` + +`sessionQueryKey` was extracted to `src/lib/session-query-keys.ts` to break one such chain, but +`use-ws-connection` itself is inspector-owned and cannot move until the apps split. This does not +block anything today — the showcase builds and passes — but it must be cut in Phase 05. + +#### Done when + +Still one desktop app — but it no longer imports its own web build, and the Tauri surface is +confined to one package. **Met for direct imports;** the transitive chain above is Phase 05 work. + +--- + +### Phase 05 — Make Inspector and Studio separate applications + +**Goal:** Move the 24,671 lines of creative tooling into an independently built and released +Feather Studio application. Inspector must contain only session-bound debugging surfaces. +**Why here:** It depends on `ui`, `host`, and the transitional `session-bridge` seam from Phase 04, +plus the workspace/build graph from Phase 03. +**Preconditions:** Phases 03 and 04 `DONE`. The Studio release trigger stays disabled until its +binary, signing, and isolation checks pass. +**Skills:** `skills/feather-desktop-app/SKILL.md` and `skills/feather-cli-builds/SKILL.md` + +> ⚠️ **This is the only genuinely large structural diff in the plan.** The three creative tools +> cross-import (`texture-lab` uses `shader-graph`'s store; both `shader-graph` and the particle lab +> import `TextureLabDialog`). **They move as one unit into Studio or not at all.** Do not extract +> them as three applications, and do not leave compatibility copies in Inspector. + +#### Non-negotiable application boundary + +- Feather Inspector and Feather Studio are separate installed applications with separate native + binaries, bundle identifiers, versions, build outputs, permissions, signing, releases, and + release feeds. +- Inspector does not embed Studio assets, expose Studio routes, download a Studio bundle, or load + Studio code from a local or remote URL. +- Studio does not import Inspector hooks, stores, pages, Tauri commands, or Rust internals. +- Inspector owns live game sessions. Optional live preview from Studio crosses the authenticated, + versioned process bridge in §5.3; standalone and web Studio remain fully useful with no bridge. +- Inspector owns the session/runtime/debugging MCP backend; Studio owns the creative MCP backend + and its state. Inspector must not keep importing Studio types/stores merely to proxy creative + actions, and Studio's backend must not serve Inspector's operational tools. The single `feather + mcp` server is what joins them, by routing (D32) — neither app reaches through the other. + +#### The persisted-state blocker, established 2026-09-08 + +**`src/store/settings.ts` must be split before tool code moves.** Two shortcuts were evaluated and +both are closed: + +1. *Leave settings where it is and let Studio import it.* Fails the application boundary. +2. *Move settings wholesale into a shared `@feather/app-state` package.* **Circular.** The store + imports texture-lab and shader-graph code, so shared app state would depend on Studio while + Studio depends on shared app state. + +The Studio-owned fields are: + +| Field | Owner | +| --- | --- | +| `textureLabRecipe`, `textureLabSavedRecipes`, `textureLabWorkspaceId`, `textureLabWorkspaces` | Studio | +| `collapsedShaderGraphNodeCategories` | Studio | +| `particleTimelineZoom`, `particleTimelineSnap` | Studio | +| `assetSourceDir` | Both apps — copy it during migration, then let each app own its value independently | + +The nine tool-specific actions and their normalizers move with those fields. Inspector keeps its +existing asset-source action; Studio gets its own asset-source setting rather than reaching back +into Inspector state. + +This is persisted Zustand state under `settings-storage`. Extracting a new store inside the current +application is necessary but not sufficient: a separately installed Studio has its own application +data directory and webview storage origin, so it cannot assume access to Inspector's local storage. +The migration must therefore have two explicit steps: + +1. While the tools still run in Inspector, extract a versioned `StudioPreferencesV1` payload and a + Studio-owned store, preserving the current `partialize`/`merge` workspace-snapshot behavior. +2. On explicit first pairing, Inspector offers the validated legacy payload over the authenticated + bridge. Studio imports it idempotently into its own storage and records the migration version. + Keep the Inspector copy for rollback; never delete it automatically. If Inspector is absent, + Studio starts clean and can import later. + +The final Inspector may retain a minimal, read-only legacy export adapter for this handoff, backed +by a migration schema in the shared bridge contract. That adapter is not a Studio feature: it must +not render a tool, mutate Studio state, or pull Studio implementation code back into Inspector. + +Tests must cover a legacy payload, malformed/partial data, repeated migration, an existing newer +Studio store, and preservation of recipes/workspaces. Land the extraction before moving files. + +#### Landable stages + +##### 05a — Extract Studio-owned state in place + +- [x] Add `StudioPreferencesV1`, normalizers, serialization, and a dedicated Studio preferences + store (`src/store/studio-preferences.ts`) while all routes still run in the current + application. `exportStudioPreferencesV1()` produces the payload 05b hands over the bridge; + `importPreferences()` is the idempotent receiver and refuses a payload from a newer Studio. +- [x] Read legacy `settings-storage` once, preserve workspace snapshots, and leave + `assetSourceDir` plus all Inspector settings in the Inspector store while seeding an + independent Studio copy. The legacy key is read, never written or cleared — rollback stays + possible. +- [x] **The blocker is gone.** `src/store/settings.ts` no longer imports `@/pages/texture-lab/generator`, + `@/types/texture-lab`, `@/types/shader-graph` or `@/constants/shader-graph`, so shared app + state is no longer circular with Studio. 374 → 197 lines. +- [x] Repoint the 6 consumers: 5 Studio files (which now import *no* Inspector settings at all — a + good sign of clean ownership) and `use-mcp-creative-bridge`, whose creative half moves out in + 05c. +- [x] Add e2e coverage for legacy adoption, a malformed payload, repeated migration across reloads, + and a clean install. See **D28** for a real durability gap these found. +- [x] Keep `main` shippable and all existing routes working. App e2e 59 (4 new), showcase e2e 17, + typecheck and lint clean. + +##### 05b — Scaffold the application and prove the process bridge + +- [x] `studio:`, `inspector:`, `protocol:`, `host:`, and `ui:` are already accepted by + `.husky/commit-msg`. +- [x] `.husky/commit-msg`'s help text is now generated from the whitelist itself, so the two cannot + drift apart again. +- [x] `apps/studio` exists as its own workspace package, Vite build and Tauri application: + product name `Feather Studio`, identifier `com.kyonru.love.feather.studio`, its own version + (`0.1.0`, independent of the platform), port 1430, and `dist/` output. Its crate omits + `axum`/`tokio` (no WebSocket server) and `tauri-plugin-shell` (no CLI sidecar) — see the + comment in its `Cargo.toml` explaining why adding either would breach the boundary. Its + capability set is narrower than Inspector's: no `opener:allow-open-path` over `/**`. + **Verified:** Studio's Vite config has no alias back into `src/`, and nothing under + `apps/studio/src` imports Inspector code. It builds to a 197 KB bundle with the Tauri host + split into a separate 5 KB lazy chunk, so the web build excludes Tauri entirely. +- [x] `@feather/session-bridge` now carries a versioned contract: request/response envelopes, + four methods, typed error codes, and `negotiate()` as a **range overlap rather than equality** + — equality would force the two applications back into lock-step. `createNullBridge`, + `createClientBridge` and `createBridgeServer` are the three adapters. 13 unit tests + (`pnpm run test:bridge`, run in CI) cover negotiation, forged/revoked/expired capabilities, + no-session, transport failure, and the null bridge as a *supported state*. +- [x] The Inspector half is `src-tauri/src/studio_bridge.rs`: an axum `POST /bridge` route bound to + **127.0.0.1** (not the wildcard the game socket uses, because this endpoint accepts commands + on behalf of another application), capabilities issued in-memory with a 30-minute TTL and + swept on issue, and version negotiation *before* any method is reachable. The capability + travels in the request body, never a URL. Studio's half is `useInspectorBridge`, and + connecting is an explicit user action so Studio never probes loopback on its own. + 4 Rust tests cover negotiation and capability issue/verify/revoke. + **Port 4008.** 4005 is the Inspector MCP bridge, 4006 is the CLI's MCP HTTP transport, and 4007 is Studio's creative MCP endpoint. +- [x] The transfer works end to end. Inspector's Rust relays `preferences.export` to its webview, + which answers from the legacy `settings-storage` slice through a **read-only** adapter that + renders nothing, mutates nothing and imports no Studio code — the field list comes from + `STUDIO_PREFERENCE_KEYS` in the shared contract, which is what keeps the coupling from + returning. Studio imports on first pairing when `migratedFrom === 0`. Three bridge tests cover + a full payload, nothing-to-hand-over, and the capability gate. +- [x] Studio mints its own MCP token per launch and serves its own loopback endpoint on 4007. + Inspector's existing token, endpoint and defaults are untouched, and neither reads the other's + credentials. What is *not* separate is the server an agent configures — see **D32**. +- [x] **Not built, by decision (D32).** `--target` presumes two servers to choose between. Instead + `feather mcp` gained `--studio-url` and `--studio-token` for pointing at Studio's backend; the + agent still configures one server. + +##### 05c — Move creative ownership out of Inspector + +- [x] Moved in one change: the three tool directories (**25,502 LOC**) plus their types, stores, + hooks, constants, preview components and the love.js preview. 73 files now under + `apps/studio/src`. The showcase moved with them — it *is* Studio in a browser, so keeping it + in Inspector would have re-created the coupling. +- [x] `src/` → `apps/inspector/src`, `src-tauri/` → `apps/inspector/src-tauri`, `index.html` with + them. The §5 target layout now exists: three apps, five packages, one catalog. Rust builds and + its 28 tests pass from the new location; Inspector's bundle identity, signing and release feed + are unchanged. +- [x] `use-mcp-creative-bridge.ts` moved into Studio and its three Tauri call sites now go through + `@studio/mcp/transport`, implemented in `apps/studio/src/desktop/` — Studio's one Tauri-only + area, the same role `packages/host/src/tauri.ts` plays. **The lint guard found this**, not a + reading of the code. +- [x] **Superseded — one MCP, routed.** See **D32**. The creative registrations stay in the single + `feather mcp` server; what changed is where their requests go. `/creative/*` now reaches + Studio's own endpoint, everything else reaches Inspector, and an application that is not + running is reported by name rather than as a refused connection. +- [x] **Superseded by D32.** They stay in the one server; Inspector remains their backend. +- [x] Desktop Studio resolves `createTauriHost()` and registers the desktop MCP transport, both + imported lazily. Browser Studio gets `createWebHost()` and the null bridge. The showcase has + its own provider tree rather than borrowing Inspector's. +- [x] Removed from Inspector: the three routes, the creative MCP bridge hook, the now-unused + `RequireWorkspaceSession` guard, and `session-tabs`' cross-application workspace cleanup — + Studio owns its workspaces and cleans them up itself. +- [x] **Cut.** Walking Studio's full import graph (91 modules, excluding its `desktop/` area) + reaches no `@tauri-apps` at all, and `use-ws-connection` is no longer reachable. The chain + recorded in Phase 04 is gone. +- [x] Bidirectional lint guards in `eslint.config.js`, verified firing on probe files in both + directions. `pnpm run lint` now covers `src`, `apps` and `packages`, not just `src`. + **Audit: 0 Inspector→Studio imports, 0 Studio→Inspector imports, 0 Tauri outside the two + designated areas.** + +##### 05d — Separate builds, releases, showcase, and acceptance + +- [x] Studio has `dev`, `build`, `typecheck`, `tauri:dev`, `tauri:build` and its own Playwright + task; turbo carries `//#test:studio:e2e`, `//#test:inspector:e2e`, `//#test:showcase:e2e`, + `//#test:unit` and `//#test:bridge`, each with its own inputs. +- [x] **Superseded by D32.** There is one MCP server, so there is no target selection to test. + What the routing needs is covered: the existing CLI MCP suite exercises both backends, Studio's + endpoint has Rust tests for snapshots, per-launch tokens and late resolution, and the + not-running path is asserted by the error text naming the application. +- [x] `scripts/set-version.sh studio ` writes Studio's manifest, Cargo package and Tauri + config and nothing else — verified by round-trip. `platform` deliberately does **not** touch + Studio: it is a satellite, not a platform member. +- [x] `set-version.sh desktop` and `_release-desktop.yml` repointed to `apps/inspector/src-tauri`; + `tauri-action` gains `projectPath: apps/inspector`. The trio is unchanged in composition, only + in location. +- [x] `release-studio.yml` fires on `studio-v*`, verifies Studio's version trio against the tag, + and builds the signed/notarized four-platform matrix with `projectPath: apps/studio`. + Releases land on the `studio-v*` tag — a distinct feed, so a Studio update never appears as + an Inspector one. It also **asserts its own isolation**: the job fails if any other release + workflow ever starts matching `studio-v*`. +- [x] `check:protocol` now verifies the bridge version and range agree between + `packages/session-bridge/src/contract.ts` and `apps/inspector/src-tauri/src/studio_bridge.rs`, + and **fails if Studio source references the runtime wire protocol version at all** — Studio + pairs with Inspector and never speaks to a game directly, so Inspector owns that translation. + Both guards verified firing. +- [x] `apps/showcase` is a thin shell: an `index.html` and a three-line entry that imports + Studio's public web mount. It carries no page code. Studio owns what it renders. +- [x] Both deleted. The love.js middleware, CSP block and Lua watch list now live once in + `scripts/vite-lovejs-plugin.mjs`. **The Inspector no longer needs it at all** — the previews + moved with the tools — so its Vite config went from 198 to 73 lines. This closes the + duplication recorded as evidence in §1. +- [x] Three configs, each beside its product: `apps/inspector/`, `apps/studio/`, `apps/showcase/`. + Studio gained a suite of its own (4 tests) asserting what is unique to it — that it boots + standalone, treats the bridge as optional, reports a failed connection without breaking, and + knows which host it is under. Scripts renamed `test:inspector:e2e` / `test:studio:e2e` / + `test:showcase:e2e`. +- [x] `scripts/check-artifact-boundary.mjs` scans the *built* bundles for markers of the other + application — test ids and command strings rather than package names, which minifiers strip. + This catches what the lint guards cannot: a transitive or dynamic import that pulls one app + into the other without any single file importing across. Wired into CI after both builds. +- [x] `apps/docs/studio.md` documents why the applications are separate, installing either without + the other, using Studio standalone, pairing, what pairing does to your settings, and the single + MCP server. All 31 symlink targets moved with the docs and resolve. `CHANGELOG.md` carries four + user-facing entries for the split. +- [ ] Dry-run or pre-release `studio-v*` and prove that no Inspector, CLI, runtime, or extension + publish job starts. + +#### Verify + +```sh +pnpm turbo run typecheck lint build +pnpm turbo run test:inspector:e2e test:studio:e2e test:showcase:e2e test:bridge:e2e +pnpm turbo run test:inspector:mcp test:studio:mcp test:mcp-shim +pnpm run test:tauri:e2e +bash scripts/release-tag-version.sh studio-v1.0.0 # -> "studio 1.0.0" +bash scripts/set-version.sh studio 1.0.0 # -> touches ONLY Studio's version trio +test ! -f vite.showcase.config.ts && echo "showcase config removed" +``` + +Also inspect both packaged artifacts: Inspector must contain no Studio routes/assets, and Studio +must contain no Inspector server, CLI sidecar, session stores, or debugger surfaces. Record the +artifact contents and the Studio-only release dry-run in §8.1. Connect an MCP client to each target +independently and record its advertised resources/tools; no creative tools may appear on Inspector +and no session/debugger tools may appear on Studio. + +#### Done when + +Feather Inspector and Feather Studio install, build, test, sign, distribute, and release independently; +Inspector contains none of the three creative tools; Studio works standalone and can pair safely +with Inspector for live preview; each app exposes its own independently versioned MCP server; and +Showcase is a web host for the same Studio source rather than a fork. + +--- + +### Phase 06 — `runtime-lua` as a package; retire the CI path filters + +**Goal:** Close the last two v1 workarounds. +**Preconditions:** none in practice — see **D24**. Ran ahead of Phase 05. +**Skills:** `skills/feather-lua-runtime/SKILL.md`, `skills/feather-cli-builds/SKILL.md` + +#### Tasks + +- [x] Move `src-lua/` to `packages/runtime-lua/` with a package manifest. 175 references across 50 + files plus 28 docs symlinks rewritten. `CHANGELOG.md` deliberately left alone — its entries + describe releases that shipped, and rewriting them would falsify the record. +- [x] Move the Love2D catalog to `catalog/packages/` (answering **Q2**), repointing + `generate-registry.mjs`, both backfill scripts, and `registry.yml`'s trigger. The publish + **target is still the `packages` branch**; only the source moved. Registry output verified + byte-identical (34 packages, 51 entries). +- [x] Give the runtime its own bundler at `packages/runtime-lua/scripts/bundle.sh `. `cli` + declares `@feather/runtime-lua` as a workspace dependency and calls it; root + `scripts/bundle-lua.sh` is deleted, so the reach-in is gone. +- [x] `vscode-extension/scripts/prepare.mjs` repointed; `extension:build` verified end to end. +- [x] Replace the duplicated `paths:` lists. The five build/test workflows now trigger + unconditionally and run the Turbo graph, backed by an `actions/cache` step for `.turbo`. + Turbo cache hits skip unchanged work; this is cache-based reuse, not affected-task filtering. + `pages.yml` and `registry.yml` keep their path triggers because those are deployment + selection, not build/test selection. +- [x] `.husky/pre-push` runs the four suites through turbo, so an unchanged suite is a cache hit + (verified `FULL TURBO`, 13ms) rather than a rerun. All four still gate the push. +- [x] Update the LuaRocks `copy_directories` path, and its zip step, which carried a latent depth + bug — see **D23**. + +#### Verify + +```sh +pnpm turbo run build test +pnpm turbo run test:lua:e2e test:cli:e2e +pnpm --filter @kyonru/feather pack --dry-run # confirm lua/ is present +``` + +**Result (2026-09-08):** typecheck (146 Lua files + 5 TS projects) and lint clean. Lua e2e 734 +assertions. CLI e2e 409 passed / 0 failed / 1 skip. App e2e 55. Showcase e2e 17. Extension build +green. Turbo caching verified. + +#### Done when + +Build/test CI runs the Turbo graph and reuses unchanged work from the persisted cache instead of a +human keeping duplicated YAML path lists in sync. **Met.** + +--- + +## 8. Status board + +Implementation state records whether the repository work for a phase has landed. Acceptance records +whether its real-world proof has also run; an external acceptance follow-up does not make later, +already-landed architecture disappear. + +| Phase | Name | Implementation | Acceptance | Notes | +| --- | --- | --- | --- | --- | +| 01 | Split the release trains | `DONE` | `PENDING EXTERNAL` | Code landed 2026-09-08; CLI-only tag dry-run is in §8.1. | +| 02a | Negotiate the protocol version | `DONE` | `PASSED` | Skew behavior and drift checks verified. | +| 02b | One registry for message names/envelope | `DONE` | `PASSED` | Payload schemas deliberately declined — see D14. | +| 03 | pnpm + Turborepo | `DONE` | `PASSED` | Workspace linking and cache behavior verified. | +| 04 | Extract `ui` + `host` | `DONE` | `PASSED` | Direct inversion removed; residual paths belong to 05. | +| 05 | Separate Inspector and Studio apps | `MOSTLY DONE` | `PENDING EXTERNAL` | 05a-05c landed; 05d landed bar the release dry-run. Remaining items in §8.1. | +| 06 | `runtime-lua` + CI graph | `DONE` | `PASSED` | Landed ahead of 05; cache-based CI reuse verified. See D24. | + +Implementation states: `NOT STARTED` → `IN PROGRESS` → `BLOCKED` → `DONE`. Acceptance states: +`PENDING` / `PENDING EXTERNAL` → `PASSED`. A `BLOCKED` phase must describe the blocker under its task +and list the required owner decision here or in §8.1. + +### 8.1 Cross-phase follow-ups + +- [x] `apps/cli` and `apps/vscode-extension` are linted, and `pnpm run lint` now covers `.cjs` too. + The carve-out is gone. Most of what surfaced was configuration rather than defects — CommonJS + files legitimately using `require`, Node test globals, and 28 `eslint-disable` directives that + had gone stale. Genuine fixes: four unused bindings, a redundant escape inside a character + class, a rethrow that dropped its `cause`, and a directive pointing at a rule that was never + registered. **One rule was wrong** — see **D34**. + +**Landed 2026-09-08 — platform coherence and release storage.** A platform tag now publishes a +manifest instead of duplicating artifacts, answering "which versions work together?" without a +compatibility matrix. See **D25** and **D26**. + +- [x] `scripts/build-platform-manifest.mjs` emits `feather-platform.json` plus a release body + listing every component version with its install command. `release-platform.yml` publishes it + on `v*` and verifies the CLI, runtime and desktop versions agree with the tag. +- [x] Move the four Bun-compiled CLI binaries (**342 MB**: 62 + 67 + 99 + 114) off the extension + release. **Corrected from "delete" to "relocate" — see D27.** They now attach to the CLI + train, which fires on both `cli-v*` and `v*`, so a CLI-only patch stays pinnable hermetically. + The extension release keeps only the VSIX, which already contains them via `prepare.mjs`. +- [x] Add `docs/ci.md`: install paths (hermetic binary vs npm with its Node >=22 engine), the LÖVE + + `xvfb` requirement, which commands belong in a pipeline and which do not, the + `doctor --production --json` release gate, and the verified non-interactive `--yes` set. +- [x] Fix the Turborepo cache key. It was `turbo-${{ runner.os }}-${{ github.sha }}`, which mints a + new entry against the 10 GB Actions cache quota on every push. +- [x] `feather doctor` gained a **Compatibility** group reporting the embedded runtime against the + CLI version, pointing at the platform releases when they differ. Offline, no network. + + +- [ ] With release/push access, create a throwaway `cli-v0.0.0-rc.1` tag and prove only the npm + release path starts. Record the workflow run and remove the throwaway tag/release afterward. +- [x] `featherPlatform` is in the extension manifest, `prepare.mjs` fails the package build when the + snapshot it would bundle disagrees with it (verified firing), the value is written to + `bundled-bin/platform.json` and reported by the `feather.doctor` command. `set-version.sh + platform` keeps the declaration in step; the extension's *own* version stays independent, + because it is a satellite. +- [ ] During Phase 05d, record packaged-artifact inventories for both desktop apps and a + `studio-v*` pre-release proving release isolation. + +--- + +## 9. Decision log + +Short records so decisions are not relitigated. Add to this list; do not edit past entries. + +**D1 — Organizing principle is release boundaries, not module boundaries.** +Release coupling was identified as the only day-to-day pain. Splitting by module aesthetics would +move files without changing what ships together. + +**D2 — Hybrid versioning: one platform train, four satellites.** +Full independence would require a compatibility matrix across runtime, CLI, server, and UI. Full +lock-step is the current problem. The platform set is exactly "things that must agree on the wire +protocol." + +**D3 — pnpm + Turborepo.** +Strict linking enforces the package boundaries this plan creates; npm hoisting would let them decay +silently. Turbo's graph replaces the nine hand-maintained CI path filters. + +**D4 — The inspector's 13 surfaces stay one app.** +They share a session, a sidebar, and a socket. No release-boundary justification exists. + +**D5 — Protocol typing precedes train decoupling in effect, not in order.** +Phase 01 ships first because it is revertable and delivers the stated goal, but Phase 02 must follow +immediately, because 01 is what makes version skew reachable by users. + +**D23 — Moving a directory one level deeper broke two path assumptions.** +`src-lua` sat one level below the root; `packages/runtime-lua` sits two. Two places had quietly +encoded that depth. `cli/test/commands/run.test.mjs` derived the repo root as `dirname(LOCAL_SRC)`, +which now points at `packages/` — caught by a failing CLI test. And the runtime release workflow did +`cd packages/runtime-lua && zip -r ../feather-*.zip`, which would have written the archive into +`packages/` while the GitHub Release step looked for it at the root. The second would only have +surfaced during an actual release. + +**D32 — One MCP server, routed to two backends.** +§7 planned an Inspector-owned MCP and a Studio-owned MCP with `--target` to choose between them. +Rejected on the owner's call, and it is the better answer. MCP's consumer is an agent: making it +configure two servers with two tokens is worse for exactly the audience the feature serves, and +`feather mcp setup --client claude` writes one entry today. The server is also not a release +boundary — its capability tracks Inspector and Studio, it has no cadence of its own, and it already +ships inside the CLI binary, which is the hermetic install path. Splitting it would be, in §2's +words, "an excuse to reorganize code that ships together and always has". + +What genuinely changed is that creative state moved to a second process, so the single server now +routes: `/creative/*` to Studio's own loopback endpoint, everything else to Inspector. A missing +application is named in the error rather than surfacing as a refused connection. + +This also uncovered a break introduced in 05c: Studio's frontend MCP transport was calling +`set_mcp_creative_snapshot` and `resolve_mcp_creative_request`, Tauri commands that live in +*Inspector's* shell. Studio had no `invoke_handler` at all, so its creative MCP was dead code that +would have thrown. `apps/studio/src-tauri/src/creative_mcp.rs` implements them. + +Studio therefore gains `axum` and `tokio`, which 05b's comment said would breach the boundary. That +comment was too blunt. The boundary is not "no HTTP server" but "no second path to a running game": +this endpoint serves Studio's own authoring state, and pushing into a game still goes through the +authenticated Inspector bridge. Studio still runs no WebSocket server and owns no sessions. + +**D34 — A lint rule was wrong, and following it would have been a bug.** +`no-useless-assignment` flagged `let sessionReplayIncluded = false` in `doctor/index.ts` as dead. +It is not: the only assignment sits inside `if (configSource)`, so a project with no +`feather.config.lua` never reaches it and that default is exactly what gets reported. Deleting the +initializer would have made the production session-replay check depend on uninitialized state. +Suppressed on the line with the reason, rather than obeyed. Two others were resolved by making the +code better instead of silencing: `supportedUploadDoctorTargets` now backs a real +`isSupportedUploadDoctorTarget` guard rather than being a type-only const with `'itch'` hardcoded +beside it, and the `config.ts` rethrow attaches `{ cause: err }` so the original parse error +survives. + +**D33 — The port map is now explicit, after a collision.** +Assigning Studio's bridge to 4006 in 05b collided with the CLI's own MCP HTTP transport, which +defaults to that port. Nothing caught it because neither runs during the test suites. The map: +4004 game WebSocket · 4005 Inspector MCP bridge · 4006 CLI MCP HTTP transport · 4007 Studio creative +MCP · 4008 Studio↔Inspector bridge. + +**D31 — `cli`, `vscode-extension` and `docs` belong in `apps/`, not `packages/`.** +§5 originally placed the CLI and the extension under `packages/`. That was wrong by the plan's own +rule: `packages/` is for things *consumed* by other things, and both of those are shipping +artifacts a user installs. `apps/` now holds everything that ships — inspector, studio, showcase, +cli, vscode-extension, docs — and `packages/` holds only what the applications consume. The layout +in §5 is corrected accordingly. + +**D30 — `scripts/tests/` was fourteen dead test files, and nobody knew.** +Found while surveying the Inspector move. Fourteen unit test files covering shader graph codegen, +particle timelines, texture generation and theme resolution — with **no runner**: absent from +`package.json`, `turbo.json`, the husky hooks and CI. They had been broken since before this work: +verified at baseline `41689d1`, where `src/assets/theme/registry/index.ts` already used extensionless +imports that Node's ESM loader cannot resolve. Running them under `tsx`, which honours the tsconfig +paths, revives all of them: **93 tests passing**. Now wired into `pnpm run test:unit`, turbo, +pre-push and CI, so they cannot rot again — and they happen to cover exactly the Studio logic +Phase 05c moved. + +**D29 — Tailwind's `@source` scope broke silently when the apps split.** +Moving the tools gave the showcase its own Vite root, and Tailwind v4 scans relative to that root. +It stopped generating classes for `packages/ui` — so Radix select and dialog *rendered* but had no +positioning classes, and Playwright reported "element is visible, enabled and stable… outside of the +viewport" on five tests. Nothing errored; the CSS bundle was simply 75 KB instead of 132 KB. Both +Studio stylesheets now name `packages/ui/src` explicitly, and the Inspector's does too even though +it currently works by accident of its Vite root being the repository root — that accident ends when +`src/` moves to `apps/inspector`. + +**D28 — The migration has to persist eagerly, or it is not a migration.** +The first cut adopted legacy settings inside zustand's `merge`, which only runs during hydration and +does not itself write. So an adopted payload sat in memory until the user happened to change a +Studio control — and would be redone on every launch until then. The e2e caught it as an empty +storage key. `onRehydrateStorage` now materializes the store on first run, which also records the +distinction the two-step migration needs: `migratedFrom: 0` means "this install started clean", +which is not the same as "this install has never run". + +Two test assertions also had to move, and the reason is worth keeping: `/texture-lab` activates a +per-session workspace on mount, so the *active* recipe and workspace id are overwritten immediately. +The migrated data is not lost — activation folds it into the workspace snapshot map. Asserting on +the active slot was testing app behavior; asserting on `textureLabWorkspaces['my-game']` tests that +nothing was dropped, which is the property that matters. + +**D27 — The standalone CLI binaries are relocated, not removed.** +The first pass dropped them from the extension release as "zero loss, they are already in the VSIX." +That reasoning was wrong: the VSIX serves extension users, while the standalone Bun binaries are the +*hermetic* install path — no Node toolchain, one download — which is what a lean CI runner wants. +Deleting them would have removed a real capability. + +They now attach to the **CLI train** rather than the platform release. That matters because +`release-cli.yml` fires on `cli-v*` as well as `v*`: publishing them only on platform tags would +have meant a CLI-only patch had no hermetic artifact, forcing pipeline users to wait for a full +platform release to pin a CLI fix — defeating the decoupling for the audience that most needs it. +Each train owns its own artifacts; the platform manifest links to them and copies nothing. + +**D25 — A platform tag publishes a manifest, not a rebuild.** +Independent trains create a question the lock-step model never had: which versions work together? +§6 first answered it with per-satellite compatibility ranges, which is worse — a declared range +nobody exercises is a loose system that is quietly wrong. Instead `v*` publishes +`feather-platform.json`: the set that was actually built and tested together, with each component +linked to its own registry. npm, LuaRocks and the Marketplace host their own bytes, so the manifest +duplicates nothing, and the release page is a single answer rather than a matrix to solve. + +**D26 — Release storage is about copies, not policy.** +The extension release attached the four CLI binaries — 342 MB — that `prepare.mjs` had already +placed inside the VSIX. That is the largest storage item in the project and it was pure duplication. +Separately, the Turbo cache key introduced in Phase 06 was keyed on `github.sha`, minting a fresh +entry against the hard 10 GB Actions cache quota on every push. Release assets on public repos are +generally not billed against repository storage, but the number of copies kept is controllable +regardless of that, so it was worth fixing on its own terms. + +**D24 — Phase 06 ran before Phase 05.** +§7 lists 06 after 05, but every 06 task is independent of the app split: the runtime move, the +catalog move, the bundler, and the CI graph touch no `src/` layout. Phase 05's blocker is +`src/store/settings.ts` — 373 lines of *persisted* zustand state mixing studio preferences (texture +recipes, timeline zoom, collapsed node categories) with app settings, so splitting it needs a +migration for existing users' stored state. Doing the independent, low-risk phase first leaves the +riskiest one to be done with full attention rather than in a hurry. + +**D19 — `@feather/ui` injects its two app dependencies rather than importing them.** +Four primitives needed app state: `sonner` and the Lua/GLSL editors want the resolved theme, and the +copy button wants the app's clipboard-with-toast helper. Both live behind the settings store, so +importing them would have dragged app state into a presentation package and made it unusable by a +second app. `UiProvider` supplies `useThemeMode`, `useSyntaxTheme` and `copyToClipboard`; the +defaults are a light theme and a bare `navigator.clipboard` write so a primitive still renders in +isolation. + +**D20 — `use-local-particle-playground` moved to the studio, not into the host.** +Phase 04 as written said to move it into the web host. It is not a host capability — it is particle +workspace state that happens to persist locally. It belongs to the tool that owns it, so it moved to +`src/pages/particle-system-playground/`. The inversion is gone either way. + +**D21 — The session bridge abstracts one function.** +The four modules §5.3 listed were expected to need injection. Inspecting them showed only +`send-command` imports Tauri (`invoke`); the session store is plain zustand, the upload helper has no +imports at all, and plugin-control is only tainted through send-command. So the bridge is a +registry for a single `CommandSender`, defaulting to a no-op. A module-level registry rather than +React context because the studio's preview controllers are module singletons created at import time. + +**D22 — The desktop bundle registers the transport unconditionally.** +First attempt gated `setCommandSender` on `!isWeb()`, which broke six Playwright tests: that suite +runs the desktop bundle in a browser with a mocked `invoke`, so `isWeb()` is true and every game +command silently no-oped. The host is still chosen by what is present (the file dialogs need a real +runtime), but the transport is always registered for this bundle. The showcase has its own entry and +registers nothing, which is what leaves the tools as local editors on the real web. + +**D18 — `protocolVersion` stays a standalone integer.** +Answering Q4: tying it to the platform semver would mean every platform release implies a protocol +change, and readers would have to work out which parts of a version are wire-relevant. A bare +integer that increments only on a breaking wire change is less to maintain and unambiguous at the +handshake. `check:protocol` keeps the two declarations in step. + +**D16 — pnpm 11 moved its settings out of `package.json`.** +`pnpm.onlyBuiltDependencies` in `package.json` is no longer read at all (pnpm says so explicitly). +The equivalent is `allowBuilds` in `pnpm-workspace.yaml`, as a map. Blocked build scripts are a +*fatal* install error, and pnpm re-runs install before every script, so this blocks everything until +fixed. `esbuild`, `keytar`, and `@vscode/vsce-sign` are approved — esbuild's matters because Vite +depends on it. + +**D17 — The Playwright breakage was stale npm state, not a boundary violation.** +Phase 03 predicted strict linking would surface packages reaching undeclared root dependencies. It +surfaced something else: `pnpm install` left npm's `node_modules/playwright` in place as a real +directory while `@playwright/test` resolved its own copy under `.pnpm`. Two realpaths meant two +module registries, so every spec threw "did not expect test() to be called here" and Playwright +reported 0 tests in 0 files. Deleting `node_modules` in the root and each workspace and reinstalling +fixed it. No source import needed changing — the boundaries were already clean. + +**D14 — Per-message payload schemas are declined, not deferred indefinitely.** +Modelling all 78 payloads was attempted and abandoned on evidence. The messages are not uniform: +`eval:response` carries `id`/`status`/`result`/`prints` at the top level while others nest under +`data`. A first cut of `parseInbound` rebuilt the envelope from four known keys and silently dropped +those top-level fields — caught by `golden.spec.ts`. That is exactly the failure mode a slightly +wrong schema produces, and it is worse than an honest `unknown`. The envelope is exact; `data` stays +`unknown`. Revisit per-message only where a handler's shape is actually load-bearing. + +**D15 — No Rust serde structs are generated.** +`src-tauri/src/ws_server.rs` deserializes exactly one message, `AuthResponseMsg`, for the appId +handshake; the other 77 are relayed as raw text and never parsed. Generating structs for them would +be dead code. + +**D12 — `packages/protocol` sits beside the Love2D catalog for now.** +`packages/` means "Love2D catalog entries" today, and the target layout in §5 wants it to mean npm +packages. Rather than pull the `catalog/packages/` move forward from Phase 06, the protocol package +lands alongside the catalog JSONs. `scripts/generate-registry.mjs` filters on `.endsWith('.json')`, +so the subdirectory is invisible to it; verified the generated registry is byte-identical after the +addition. `AGENTS.md` records the temporary sharing. + +**D13 — The registry declares message names before payload shapes.** +Names alone make drift mechanically detectable, which is the property that matters once trains ship +separately. Payload schemas for 78 messages are a long tail with a different risk profile: getting a +shape subtly wrong is worse than leaving it implicit. Split so the detectable-drift half could land +and be verified on its own. + +**D9 — `protocolVersion` rides `feather:hello`, not a new handshake in Rust.** +The written task said to add it to `src-tauri/src/ws_server.rs` and `packages/runtime-lua/feather/lib/ws.lua`. +But an auth handshake already exists (`auth:challenge` / `auth:response`) followed by a +`feather:hello` config payload that *already* carried an API-compat check, and +`skills/feather-desktop-app/references/tauri-and-protocol.md` is explicit that Rust should stay a +transparent relay. Adding the field to the existing hello payload is smaller, keeps Rust untouched, +and lets the desktop report a rich error instead of dropping the socket. + +**D10 — A runtime reporting no `protocolVersion` is `legacy`, not broken.** +Every runtime released before this negotiation existed omits the field. Since any warning flips the +Session health verdict to "Needs attention" (`src/pages/session/index.tsx`), warning on absence +would have shown a degraded session to every existing user the moment they updated the desktop app. +Absence is surfaced in the Protocol chip and detail row and does not degrade the verdict; only a +genuine incompatibility does. + +**D11 — The plugin API version and the wire protocol version stay separate.** +`FEATHER_API` (5) gates plugin loading and already has min/max range handling in +`packages/runtime-lua/feather/plugin_manager.lua`. The wire protocol is a different contract that can evolve at a +different rate, so it got its own integer rather than overloading `API`. + +**D7 — Five tag prefixes, not three.** +Section 6 first sketched `v*` / `cli-v*` / `ext-v*`. Implementation added `runtime-v*` and +`desktop-v*`, because the platform's three members each have a real reason to ship alone: a runtime +fix without a CLI release, or a desktop rebuild without burning a rock version. `v*` still fires all +three for a genuine platform release. + +**D8 — Version equality is not a compatibility signal.** +`src/pages/session/index.tsx` treated runtime ≠ desktop as a fault. That was only ever correct +because lock-step made them equal by construction. Decoupled trains are *expected* to drift, so the +check was removed rather than adjusted. `config.API` carries the signal until Phase 02 replaces it +with a negotiated `protocolVersion`. + +**D6 — The extension vendors a platform snapshot rather than declaring a CLI range.** +Discovered during planning: `vscode-extension/src/cli.ts` resolves from `bundled-bin/`, not from the +user's PATH. Hermetic bundling is simpler to verify than a compat range. + +> **Numbering note.** D35–D42 were originally written as D25–D32 during Phase 05 planning, before +> the implementation decisions now holding those numbers were recorded. They were renumbered on +> 2026-09-08 so every identifier in this log is unique. Nothing in the repository cited them by +> number, and every `D25`–`D32` reference elsewhere in this document points at the entries above. + +**D35 — “Separate bundle” means a separate Feather Studio application and native binary.** +Owner clarification on 2026-09-08 supersedes the ambiguous delivery wording in Q1, not the earlier +reason for separating Studio. Inspector must not statically link, dynamically import, download, or +remotely execute Studio code. Studio owns its Tauri shell, app identity, version, permissions, +artifacts, signing, releases, and release feed. Showcase compiles Studio's public web entry, but +Inspector contains none of it. + +**D36 — Platform is a compatibility family, not one release train.** +D2 described the initial hybrid model; the implemented `cli-v*`, `runtime-v*`, and `desktop-v*` +paths in D7 mean those artifacts no longer keep one version in ordinary releases. `v*` is an +umbrella coordinated release, while protocol negotiation carries compatibility. The terminology in +§6 now matches the implementation. + +**D37 — A separate application requires an explicit persisted-state handoff.** +The original Phase 05 migration assumed the new Studio store could adopt Inspector's browser +storage directly. Separate Tauri applications have separate data directories/origins, so that is +not a valid boundary. Inspector retains a versioned legacy export; after explicit authenticated +pairing, Studio validates and imports it idempotently into its own store. The legacy copy remains +for rollback. `assetSourceDir` is copied once because both products use the concept, then each app +owns its value independently; it does not justify shared mutable app state. + +**D38 — The Phase 04 command-sender registry is scoped, not removed.** +D21 and D22 accurately record why a module-level sender worked inside the combined app. It cannot +cross a process boundary. *Corrected after implementation:* this entry predicted Phase 05 would +"replace" the registry, and that is not what happened. `setCommandSender` still lives in +`packages/session-bridge/src/index.ts` and is still how Inspector's own bundle reaches a game — +`apps/inspector/src/main.tsx` is its only caller. What Phase 05 actually did was stop it at the +application boundary: Studio references it zero times and reaches games only through the versioned, +authenticated bridge, with the null adapter standing in when no Inspector is paired. The registry +was scoped to one app, not retired. + +**D39 — Studio owns the creative state behind MCP.** +`use-mcp-creative-bridge.ts` reads and mutates all three creative stores, so leaving it in Inspector +would preserve the very dependency Phase 05 removes. Its React executor and corresponding native +creative routes move to Studio. *Reframed by D32:* the ownership claim holds — creative state and +its handlers live in Studio, session and debugging state in Inspector — but this entry assumed that +ownership implied a second MCP *server*. It does not. There is one server, and ownership is +expressed as routing behind it. Shared Rust infrastructure still earns a crate only if both binaries +consume a stable public contract. + +**D40 — Turbo cache reuse is not affected-task filtering.** +Phase 06 removed duplicated build/test `paths:` lists by running the Turbo graph and restoring its +persisted cache. That skips unchanged work through cache hits; it does not use +`--filter=...[origin/main]` to select a smaller graph. The goal and acceptance language now state the +implemented behavior precisely. Deployment workflows may still use path triggers. + +**D41 — The protocol source of truth covers message names and the common envelope.** +The earlier target text overclaimed per-message payload schemas and generated Rust consumers. +D14 and D15 intentionally declined both. G3 is satisfied by one registry for the 78 names, exact +envelope parsing, generated Lua names, and drift checks against both dispatch tables; handler-owned +payload data remains `unknown` until evidence justifies a schema. + +**D43 — The tools moved; their trails did not, and nothing caught it.** +Phase 05 moved Shader Graph, Particle Playground and Texture Lab to Studio and deleted the pages, +but left Inspector advertising all three: sidebar group, command-palette entries, runtime interest, +and the "creative workspace" session that existed only to host them. Every one navigated to a route +with no match and no fallback, so they rendered an empty page. Inspector's Rust shell also kept a +whole creative MCP surface — two Tauri commands, two `/creative/*` routes on 4005, the snapshot and +waiter state behind them, and three tests — which contradicts §5.4's "Inspector's backend never +serves creative actions" outright. It was unreachable: Studio's webview invokes its own shell, and +the CLI routes creative traffic to 4007. + +Two lessons worth keeping. First, a move is not done when the new home works; it is done when the +old home stops claiming the feature. Second, the e2e suite *asserted the Creative sidebar group was +visible* — the test locked the trail in rather than catching it, which is why 44 passing tests said +nothing. Deleting code that a test asserts the presence of is the case to watch for. + +**D42 — Superseded: Inspector and Studio each own an MCP server.** +> **Superseded by D32 on 2026-09-08. Do not implement this.** Kept because it records a position +> the owner held and then reversed, and because the reasoning about *which app owns which state* +> survived the reversal even though the two-server topology did not. + +Original entry: Inspector MCP remains valuable for live sessions, logs, performance, assets, +observers, plugins, debugger control, Console, time travel, and Session Replay. Studio MCP owns +Shader Graph, Particle Playground, and Texture Lab resources/tools. Their schemas, handlers, +credentials, configuration, tests, and docs ship with their respective app trains. The CLI keeps +`feather mcp` only as a target-selecting transport shim, defaulting to Inspector for compatibility; +it must not remain the release owner of either app's tool definitions. + +What D32 changed: agents configure one server, not two. The state split above is still real, but it +is a routing detail behind `feather mcp` rather than two endpoints an agent must discover, and the +CLI is not a "shim" over two servers — it is the single server, delegating `/creative/*` to Studio. + +--- + +## 10. Resolved questions + +Answered or clarified by the project owner on 2026-09-08. Recorded here so they are not reopened. + +| # | Question | Answer | Consequence | +| --- | --- | --- | --- | +| Q1 | Does Studio ship inside Inspector, or as a separate bundle? | **A separate application and native binary.** | Studio has its own Tauri shell, build/sign/release/distribution path, and application data. Inspector ships no Studio routes or assets and never loads Studio code. The apps integrate only through the authenticated local bridge. See D25. | +| Q2 | Should `catalog/packages` move at all? | **Yes — move to `catalog/`,** but it must still publish to the package branch on release. | Phase 06 moves the directory *and* updates `registry.yml` so the publish target is unchanged. Moving the source without repointing the pipeline would silently stop registry publishes. | +| Q3 | What is the deprecation window for the old `v*`-fires-everything behaviour? | **Treat v2 as a new start.** Keep backwards compatibility where it is cheap; breaking v1 patterns is acceptable. | No deprecation shim is owed. `v*` keeps working as the full-platform tag because that is cheap and useful, not because it is owed to anyone. | +| Q4 | Does `protocolVersion` follow the platform semver or get its own integer? | **Whichever is easier to maintain.** | Keep the standalone integer already shipped in Phase 02a. It is easier: it increments only on a breaking wire change, so most platform releases do not touch it, and a reader never has to work out which parts of a semver are wire-relevant. Recorded as **D18**. | +| Q5 | Does Inspector still have its own MCP after Studio separates? | **It owns a distinct backend, not a distinct server.** *(Answer revised by D32; originally "both applications own distinct MCP servers".)* | Inspector keeps session/runtime/debugging automation, Studio owns creative automation, and each mints its own token on its own port. But an agent configures **one** server: `feather mcp` routes `/creative/*` to Studio and the rest to Inspector. `--target` was never built. See D32 and D42. | + +## 11. Glossary + +| Term | Meaning | +| --- | --- | +| **Train** | A set of artifacts that version and release together. | +| **Platform family** | Runtime, CLI, Inspector, and server: independently releasable artifacts connected by the game protocol and optionally coordinated by a `v*` tag. | +| **Satellite** | An artifact that releases on its own cadence and declares a compatibility range. | +| **Contract** | A typed, versioned seam between independently-released code: protocol, host, session-bridge. | +| **Host** | The environment a UI runs in (Tauri desktop or browser) and the capabilities it provides. | +| **Bridge** | The authenticated, optional local process connection from Studio to an Inspector-owned live game session. | +| **MCP session/debug backend** | Inspector-owned automation for live sessions, runtime state, plugins, debugger, Console, time travel, and replay. Served on :4005, reached through `feather mcp`. | +| **MCP creative backend** | Studio-owned automation for shader, particle, texture, and other creative workflows. Served on :4007, reached through `feather mcp` at `/creative/*`. | +| **Inspector** | The session-bound Feather desktop application: debugging, logs, performance, assets, plugins, and related surfaces. | +| **Studio** | The separate Feather desktop/web application containing shader graph, texture lab, and particle lab. | + +--- + +## 12. Conventions carried over from v1 + +These do not change in v4. See `AGENTS.md` for the authoritative versions. + +- **Commit scopes** are whitelisted in `.husky/commit-msg`. Add new scopes before using them. +- **Generated files** are never hand-edited. `registry.json` and `plugin-catalog.ts` have generators + and `check:*` guards. +- **Docs live beside the subsystem** and are exposed through `docs/` by symlink. Edit the source + file, not the symlink path. +- **`CHANGELOG.md`** is Keep a Changelog style and covers user-visible changes only. +- **E2E coverage** accompanies behavior changes; say so explicitly when it is not feasible. +- **Release builds stay Feather-free by default.** Do not embed debugger or runtime content in + release or upload paths unless explicitly requested. +- **Capabilities stay off by default.** Console, hot reload, filesystem, and network are opt-in. diff --git a/cli/.gitignore b/apps/cli/.gitignore similarity index 100% rename from cli/.gitignore rename to apps/cli/.gitignore diff --git a/cli/README.md b/apps/cli/README.md similarity index 98% rename from cli/README.md rename to apps/cli/README.md index 4eea19c9..4907b326 100644 --- a/cli/README.md +++ b/apps/cli/README.md @@ -29,17 +29,17 @@ feather run path/to/my-game --target ios npm install -g @kyonru/feather ``` -Requires **Node.js 18+** and **LÖVE** installed on your system. +Requires **Node.js 22+** and **LÖVE** installed on your system. For local development inside this repository: ```bash -npm install -npm run cli:build -npm run feather -- --help +pnpm install +pnpm run cli:build +pnpm run feather --help ``` -`npm install` links the workspace package into `node_modules/@kyonru/feather` and exposes `node_modules/.bin/feather` for local CLI testing. +`pnpm install` links the workspace package into `node_modules/@kyonru/feather` and exposes `node_modules/.bin/feather` for local CLI testing. ### Desktop CLI Backend @@ -133,7 +133,7 @@ feather init --plugins screenshots,console feather init --plugins hot-reload --hot-reload-allow game.player,game.systems.combat feather init --session-name "My Game" --app-id feather-app-... feather init --remote --branch v0.7.0 # use a specific runtime release -feather init --local-src ../feather/src-lua # use a local source tree +feather init --local-src ../feather/packages/runtime-lua # use a local source tree feather init --install-dir lib/feather # configure like FEATHER_DIR=lib/feather ``` @@ -154,8 +154,8 @@ CLI mode writes a development config with `debug = true`, `autoRegisterErrorHand Install source priority: -1. `--local-src ` copies from a local `src-lua` style tree. -2. Running the CLI from the Feather monorepo copies the repo's `src-lua`. +1. `--local-src ` copies from a local `packages/runtime-lua` style tree. +2. Running the CLI from the Feather monorepo copies the repo's `packages/runtime-lua`. 3. Published CLI installs copy the bundled `cli/lua` runtime. 4. `--remote` downloads from GitHub using `--branch`. @@ -252,7 +252,7 @@ return DEBUGGER | ---------------------------- | ---------------------------------------------------------------------------------------------------- | | `--remote` | Download from GitHub instead of copying the local/bundled Lua runtime. | | `--branch ` | GitHub branch or tag to download from when using `--remote` (default: `main`). | -| `--local-src ` | Copy from a local `src-lua` style directory. | +| `--local-src ` | Copy from a local `packages/runtime-lua` style directory. | | `--install-dir ` | Install directory for auto/manual modes (default: `feather`). | | `--no-plugins` | Skip plugin installation and omit the CLI-mode default include list. | | `--plugins ` | Comma-separated plugin IDs. In CLI mode this overrides the default creative plugins. | @@ -942,7 +942,7 @@ feather update # interactive source picker in a terminal feather update -y # update from the local/bundled CLI copy feather update path/to/my-game feather update --remote --branch v0.7.1 -feather update --local-src ../feather/src-lua +feather update --local-src ../feather/packages/runtime-lua ``` In an interactive terminal, `feather update` opens an Ink workflow to choose local/bundled files or a GitHub branch/tag. In scripts or with `-y`, it uses the local/bundled CLI copy unless `--remote` is provided. @@ -968,8 +968,8 @@ feather plugin --remote --branch main The workflow can list installed plugins, install one or more catalog plugins, remove installed plugins, or update selected plugins. Like `feather init`, plugin installs and updates are local-first by default: -1. `--local-src ` copies from a local `src-lua` style tree. -2. Running the CLI from the Feather monorepo copies the repo's `src-lua`. +1. `--local-src ` copies from a local `packages/runtime-lua` style tree. +2. Running the CLI from the Feather monorepo copies the repo's `packages/runtime-lua`. 3. Published CLI installs copy the bundled `cli/lua` runtime. 4. `--remote` downloads from GitHub using `--branch`. @@ -997,7 +997,7 @@ Install a plugin from the local/bundled runtime by default, or from GitHub with ```bash feather plugin install console feather plugin install time-travel --remote --branch main -feather plugin install console --local-src ../feather/src-lua +feather plugin install console --local-src ../feather/packages/runtime-lua feather plugin install console --install-dir lib/feather feather plugin install console input-replay # install multiple at once feather plugin install console --force # overwrite if already installed @@ -1014,7 +1014,7 @@ If a plugin is already installed, `feather plugin install` skips it and continue | `--json` | Emit machine-readable summaries for desktop/automation use. | | `--remote` | Download from GitHub instead of the local/bundled runtime. | | `--branch ` | GitHub branch or tag when using `--remote` (default: `main`). | -| `--local-src ` | Copy from a local `src-lua` style directory. | +| `--local-src ` | Copy from a local `packages/runtime-lua` style directory. | | `--install-dir ` | Install directory (default: `feather`). | | `--dir ` | Project directory (default: current directory). | diff --git a/cli/package.json b/apps/cli/package.json similarity index 89% rename from cli/package.json rename to apps/cli/package.json index 0883456b..f57d7321 100644 --- a/cli/package.json +++ b/apps/cli/package.json @@ -34,11 +34,11 @@ "build": "tsc && cp src/generated/registry.json dist/generated/registry.json", "build:binary": "bun build ./dist/index.js --compile --outfile bin/feather --target bun-darwin-arm64 && bun build ./dist/index.js --compile --outfile bin/feather-darwin-x64 --target bun-darwin-x64 && bun build ./dist/index.js --compile --outfile bin/feather-win-x64.exe --target bun-windows-x64 && bun build ./dist/index.js --compile --outfile bin/feather-linux-x64 --target bun-linux-x64 && rm -rf bin/skills && cp -R skills bin/skills", "dev": "cp src/generated/registry.json dist/generated/registry.json 2>/dev/null; tsc --watch", - "bundle:lua": "bash ../scripts/bundle-lua.sh", - "prepack": "npm run bundle:lua && npm run build", + "bundle:lua": "bash ../../packages/runtime-lua/scripts/bundle.sh ./lua", + "prepack": "pnpm run bundle:lua && pnpm run build", "typecheck": "tsc --noEmit", "test": "node --test test/commands/*.test.mjs", - "test:e2e": "npm run build && node --test test/commands/*.test.mjs", + "test:e2e": "pnpm run build && node --test test/commands/*.test.mjs", "package:add": "tsx --tsconfig scripts/tsconfig.json scripts/add-package.tsx", "package:add-url": "tsx --tsconfig scripts/tsconfig.json scripts/add-package-url.tsx", "package:update": "tsx --tsconfig scripts/tsconfig.json scripts/update-package.tsx", @@ -53,6 +53,7 @@ "ora": "8.0.1", "react": "19.2.6", "react-devtools-core": "file:stubs/react-devtools-core", + "@feather/runtime-lua": "workspace:*", "zod": "^4.4.3" }, "devDependencies": { diff --git a/cli/scripts/add-package-url.tsx b/apps/cli/scripts/add-package-url.tsx similarity index 99% rename from cli/scripts/add-package-url.tsx rename to apps/cli/scripts/add-package-url.tsx index 21c5d1c8..3dae9355 100644 --- a/cli/scripts/add-package-url.tsx +++ b/apps/cli/scripts/add-package-url.tsx @@ -21,10 +21,8 @@ import { SelectStep, AutoStep, SubpackagesStep, - YesNoStep, ReviewStep, Header, - Hint, Spinner, } from './wizard-shared.js'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/cli/scripts/add-package.tsx b/apps/cli/scripts/add-package.tsx similarity index 100% rename from cli/scripts/add-package.tsx rename to apps/cli/scripts/add-package.tsx diff --git a/cli/scripts/remove-package.tsx b/apps/cli/scripts/remove-package.tsx similarity index 100% rename from cli/scripts/remove-package.tsx rename to apps/cli/scripts/remove-package.tsx diff --git a/cli/scripts/tsconfig.json b/apps/cli/scripts/tsconfig.json similarity index 100% rename from cli/scripts/tsconfig.json rename to apps/cli/scripts/tsconfig.json diff --git a/cli/scripts/update-package.tsx b/apps/cli/scripts/update-package.tsx similarity index 100% rename from cli/scripts/update-package.tsx rename to apps/cli/scripts/update-package.tsx diff --git a/cli/scripts/wizard-shared.tsx b/apps/cli/scripts/wizard-shared.tsx similarity index 100% rename from cli/scripts/wizard-shared.tsx rename to apps/cli/scripts/wizard-shared.tsx diff --git a/cli/skills/catalog.json b/apps/cli/skills/catalog.json similarity index 100% rename from cli/skills/catalog.json rename to apps/cli/skills/catalog.json diff --git a/cli/skills/feather-debug-builds/SKILL.md b/apps/cli/skills/feather-debug-builds/SKILL.md similarity index 100% rename from cli/skills/feather-debug-builds/SKILL.md rename to apps/cli/skills/feather-debug-builds/SKILL.md diff --git a/cli/skills/feather-debug-builds/references/workflow.md b/apps/cli/skills/feather-debug-builds/references/workflow.md similarity index 100% rename from cli/skills/feather-debug-builds/references/workflow.md rename to apps/cli/skills/feather-debug-builds/references/workflow.md diff --git a/cli/skills/feather-logs-observability/SKILL.md b/apps/cli/skills/feather-logs-observability/SKILL.md similarity index 100% rename from cli/skills/feather-logs-observability/SKILL.md rename to apps/cli/skills/feather-logs-observability/SKILL.md diff --git a/cli/skills/feather-logs-observability/references/workflow.md b/apps/cli/skills/feather-logs-observability/references/workflow.md similarity index 100% rename from cli/skills/feather-logs-observability/references/workflow.md rename to apps/cli/skills/feather-logs-observability/references/workflow.md diff --git a/cli/skills/feather-mcp-live-sessions/SKILL.md b/apps/cli/skills/feather-mcp-live-sessions/SKILL.md similarity index 100% rename from cli/skills/feather-mcp-live-sessions/SKILL.md rename to apps/cli/skills/feather-mcp-live-sessions/SKILL.md diff --git a/cli/skills/feather-mcp-live-sessions/references/workflow.md b/apps/cli/skills/feather-mcp-live-sessions/references/workflow.md similarity index 100% rename from cli/skills/feather-mcp-live-sessions/references/workflow.md rename to apps/cli/skills/feather-mcp-live-sessions/references/workflow.md diff --git a/cli/skills/feather-particle-effects/SKILL.md b/apps/cli/skills/feather-particle-effects/SKILL.md similarity index 100% rename from cli/skills/feather-particle-effects/SKILL.md rename to apps/cli/skills/feather-particle-effects/SKILL.md diff --git a/cli/skills/feather-particle-effects/references/workflow.md b/apps/cli/skills/feather-particle-effects/references/workflow.md similarity index 100% rename from cli/skills/feather-particle-effects/references/workflow.md rename to apps/cli/skills/feather-particle-effects/references/workflow.md diff --git a/cli/skills/feather-performance-profiling/SKILL.md b/apps/cli/skills/feather-performance-profiling/SKILL.md similarity index 100% rename from cli/skills/feather-performance-profiling/SKILL.md rename to apps/cli/skills/feather-performance-profiling/SKILL.md diff --git a/cli/skills/feather-performance-profiling/references/workflow.md b/apps/cli/skills/feather-performance-profiling/references/workflow.md similarity index 100% rename from cli/skills/feather-performance-profiling/references/workflow.md rename to apps/cli/skills/feather-performance-profiling/references/workflow.md diff --git a/cli/skills/feather-plugin-iteration/SKILL.md b/apps/cli/skills/feather-plugin-iteration/SKILL.md similarity index 100% rename from cli/skills/feather-plugin-iteration/SKILL.md rename to apps/cli/skills/feather-plugin-iteration/SKILL.md diff --git a/cli/skills/feather-plugin-iteration/references/workflow.md b/apps/cli/skills/feather-plugin-iteration/references/workflow.md similarity index 100% rename from cli/skills/feather-plugin-iteration/references/workflow.md rename to apps/cli/skills/feather-plugin-iteration/references/workflow.md diff --git a/cli/skills/feather-project-context/SKILL.md b/apps/cli/skills/feather-project-context/SKILL.md similarity index 100% rename from cli/skills/feather-project-context/SKILL.md rename to apps/cli/skills/feather-project-context/SKILL.md diff --git a/cli/skills/feather-project-context/references/workflow.md b/apps/cli/skills/feather-project-context/references/workflow.md similarity index 100% rename from cli/skills/feather-project-context/references/workflow.md rename to apps/cli/skills/feather-project-context/references/workflow.md diff --git a/cli/skills/feather-qa-playtester/SKILL.md b/apps/cli/skills/feather-qa-playtester/SKILL.md similarity index 100% rename from cli/skills/feather-qa-playtester/SKILL.md rename to apps/cli/skills/feather-qa-playtester/SKILL.md diff --git a/cli/skills/feather-qa-playtester/references/workflow.md b/apps/cli/skills/feather-qa-playtester/references/workflow.md similarity index 100% rename from cli/skills/feather-qa-playtester/references/workflow.md rename to apps/cli/skills/feather-qa-playtester/references/workflow.md diff --git a/cli/skills/feather-release-builds/SKILL.md b/apps/cli/skills/feather-release-builds/SKILL.md similarity index 100% rename from cli/skills/feather-release-builds/SKILL.md rename to apps/cli/skills/feather-release-builds/SKILL.md diff --git a/cli/skills/feather-release-builds/references/workflow.md b/apps/cli/skills/feather-release-builds/references/workflow.md similarity index 100% rename from cli/skills/feather-release-builds/references/workflow.md rename to apps/cli/skills/feather-release-builds/references/workflow.md diff --git a/cli/skills/feather-session-replay-qa/SKILL.md b/apps/cli/skills/feather-session-replay-qa/SKILL.md similarity index 100% rename from cli/skills/feather-session-replay-qa/SKILL.md rename to apps/cli/skills/feather-session-replay-qa/SKILL.md diff --git a/cli/skills/feather-session-replay-qa/references/workflow.md b/apps/cli/skills/feather-session-replay-qa/references/workflow.md similarity index 100% rename from cli/skills/feather-session-replay-qa/references/workflow.md rename to apps/cli/skills/feather-session-replay-qa/references/workflow.md diff --git a/cli/skills/feather-shader-graph/SKILL.md b/apps/cli/skills/feather-shader-graph/SKILL.md similarity index 100% rename from cli/skills/feather-shader-graph/SKILL.md rename to apps/cli/skills/feather-shader-graph/SKILL.md diff --git a/cli/skills/feather-shader-graph/references/effect-cookbook.md b/apps/cli/skills/feather-shader-graph/references/effect-cookbook.md similarity index 100% rename from cli/skills/feather-shader-graph/references/effect-cookbook.md rename to apps/cli/skills/feather-shader-graph/references/effect-cookbook.md diff --git a/cli/skills/feather-shader-graph/references/graph-schema.md b/apps/cli/skills/feather-shader-graph/references/graph-schema.md similarity index 100% rename from cli/skills/feather-shader-graph/references/graph-schema.md rename to apps/cli/skills/feather-shader-graph/references/graph-schema.md diff --git a/cli/skills/feather-shader-graph/references/node-catalog.md b/apps/cli/skills/feather-shader-graph/references/node-catalog.md similarity index 100% rename from cli/skills/feather-shader-graph/references/node-catalog.md rename to apps/cli/skills/feather-shader-graph/references/node-catalog.md diff --git a/cli/skills/feather-shader-graph/references/visual-quality.md b/apps/cli/skills/feather-shader-graph/references/visual-quality.md similarity index 100% rename from cli/skills/feather-shader-graph/references/visual-quality.md rename to apps/cli/skills/feather-shader-graph/references/visual-quality.md diff --git a/cli/skills/feather-step-debugging/SKILL.md b/apps/cli/skills/feather-step-debugging/SKILL.md similarity index 100% rename from cli/skills/feather-step-debugging/SKILL.md rename to apps/cli/skills/feather-step-debugging/SKILL.md diff --git a/cli/skills/feather-step-debugging/references/workflow.md b/apps/cli/skills/feather-step-debugging/references/workflow.md similarity index 100% rename from cli/skills/feather-step-debugging/references/workflow.md rename to apps/cli/skills/feather-step-debugging/references/workflow.md diff --git a/cli/skills/feather-texture-lab/SKILL.md b/apps/cli/skills/feather-texture-lab/SKILL.md similarity index 100% rename from cli/skills/feather-texture-lab/SKILL.md rename to apps/cli/skills/feather-texture-lab/SKILL.md diff --git a/cli/skills/feather-texture-lab/references/workflow.md b/apps/cli/skills/feather-texture-lab/references/workflow.md similarity index 100% rename from cli/skills/feather-texture-lab/references/workflow.md rename to apps/cli/skills/feather-texture-lab/references/workflow.md diff --git a/cli/src/commands/agent-live.ts b/apps/cli/src/commands/agent-live.ts similarity index 100% rename from cli/src/commands/agent-live.ts rename to apps/cli/src/commands/agent-live.ts diff --git a/cli/src/commands/build-vendor.ts b/apps/cli/src/commands/build-vendor.ts similarity index 99% rename from cli/src/commands/build-vendor.ts rename to apps/cli/src/commands/build-vendor.ts index 38f53d26..f700282b 100644 --- a/cli/src/commands/build-vendor.ts +++ b/apps/cli/src/commands/build-vendor.ts @@ -119,7 +119,7 @@ export async function buildVendorAddCommand(targetValues: string[], opts: BuildV async function handleSkippedVendors( skipped: ConcreteBuildVendorTarget[], opts: BuildVendorCommandOptions, - originalTargets: BuildVendorTargetInput[], + _originalTargets: BuildVendorTargetInput[], ): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { printWarning(`${skipped.length} vendor(s) already exist: ${skipped.join(', ')}. Use --force to overwrite.`); diff --git a/cli/src/commands/build.ts b/apps/cli/src/commands/build.ts similarity index 100% rename from cli/src/commands/build.ts rename to apps/cli/src/commands/build.ts diff --git a/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts similarity index 100% rename from cli/src/commands/config.ts rename to apps/cli/src/commands/config.ts diff --git a/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts similarity index 100% rename from cli/src/commands/create.ts rename to apps/cli/src/commands/create.ts diff --git a/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts similarity index 100% rename from cli/src/commands/doctor.ts rename to apps/cli/src/commands/doctor.ts diff --git a/cli/src/commands/doctor/checks.ts b/apps/cli/src/commands/doctor/checks.ts similarity index 72% rename from cli/src/commands/doctor/checks.ts rename to apps/cli/src/commands/doctor/checks.ts index f2a9f635..06200b35 100644 --- a/cli/src/commands/doctor/checks.ts +++ b/apps/cli/src/commands/doctor/checks.ts @@ -139,3 +139,57 @@ export function buildPluginIndex(pluginDirs: string[]): Map, diff --git a/cli/src/commands/doctor/report.ts b/apps/cli/src/commands/doctor/report.ts similarity index 100% rename from cli/src/commands/doctor/report.ts rename to apps/cli/src/commands/doctor/report.ts diff --git a/cli/src/commands/doctor/security.ts b/apps/cli/src/commands/doctor/security.ts similarity index 100% rename from cli/src/commands/doctor/security.ts rename to apps/cli/src/commands/doctor/security.ts diff --git a/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts similarity index 100% rename from cli/src/commands/init.ts rename to apps/cli/src/commands/init.ts diff --git a/cli/src/commands/mcp-setup.ts b/apps/cli/src/commands/mcp-setup.ts similarity index 99% rename from cli/src/commands/mcp-setup.ts rename to apps/cli/src/commands/mcp-setup.ts index 3b06bf55..5419ca49 100644 --- a/cli/src/commands/mcp-setup.ts +++ b/apps/cli/src/commands/mcp-setup.ts @@ -177,7 +177,7 @@ function codexServerBlock(server: McpServerConfig): string { } function tomlSectionName(line: string): string | null { - const match = line.match(/^\s*\[([^\[\]]+)\]\s*(?:#.*)?$/); + const match = line.match(/^\s*\[([^[\]]+)\]\s*(?:#.*)?$/); return match?.[1]?.trim() ?? null; } diff --git a/cli/src/commands/mcp.ts b/apps/cli/src/commands/mcp.ts similarity index 99% rename from cli/src/commands/mcp.ts rename to apps/cli/src/commands/mcp.ts index f4ef46f8..4adfe377 100644 --- a/cli/src/commands/mcp.ts +++ b/apps/cli/src/commands/mcp.ts @@ -68,6 +68,10 @@ export type McpCommandOptions = { port?: number; desktopUrl?: string; token?: string; + /** Feather Studio's creative endpoint. Defaults to 127.0.0.1:4007. */ + studioUrl?: string; + /** Studio mints a token per launch; it is not the Inspector token. */ + studioToken?: string; }; export async function mcpCommand(options: McpCommandOptions): Promise { @@ -88,7 +92,13 @@ export async function mcpCommand(options: McpCommandOptions): Promise { }); } - const bridge = new DesktopBridgeClient(desktopUrl, token); + // One MCP server, two backends. Session and debugging traffic goes to + // Inspector; creative traffic goes to Studio, which is a separate + // application. The agent still configures a single server. + const bridge = new DesktopBridgeClient(desktopUrl, token, { + url: options.studioUrl, + token: options.studioToken, + }); const server = createFeatherMcpServer(bridge); if (transport === 'stdio') { diff --git a/cli/src/commands/package.ts b/apps/cli/src/commands/package.ts similarity index 100% rename from cli/src/commands/package.ts rename to apps/cli/src/commands/package.ts diff --git a/cli/src/commands/package/add.ts b/apps/cli/src/commands/package/add.ts similarity index 100% rename from cli/src/commands/package/add.ts rename to apps/cli/src/commands/package/add.ts diff --git a/cli/src/commands/package/audit.ts b/apps/cli/src/commands/package/audit.ts similarity index 100% rename from cli/src/commands/package/audit.ts rename to apps/cli/src/commands/package/audit.ts diff --git a/cli/src/commands/package/index.ts b/apps/cli/src/commands/package/index.ts similarity index 100% rename from cli/src/commands/package/index.ts rename to apps/cli/src/commands/package/index.ts diff --git a/cli/src/commands/package/info.ts b/apps/cli/src/commands/package/info.ts similarity index 100% rename from cli/src/commands/package/info.ts rename to apps/cli/src/commands/package/info.ts diff --git a/cli/src/commands/package/install.ts b/apps/cli/src/commands/package/install.ts similarity index 100% rename from cli/src/commands/package/install.ts rename to apps/cli/src/commands/package/install.ts diff --git a/cli/src/commands/package/json.ts b/apps/cli/src/commands/package/json.ts similarity index 100% rename from cli/src/commands/package/json.ts rename to apps/cli/src/commands/package/json.ts diff --git a/cli/src/commands/package/list.ts b/apps/cli/src/commands/package/list.ts similarity index 100% rename from cli/src/commands/package/list.ts rename to apps/cli/src/commands/package/list.ts diff --git a/cli/src/commands/package/remove.ts b/apps/cli/src/commands/package/remove.ts similarity index 100% rename from cli/src/commands/package/remove.ts rename to apps/cli/src/commands/package/remove.ts diff --git a/cli/src/commands/package/search.ts b/apps/cli/src/commands/package/search.ts similarity index 100% rename from cli/src/commands/package/search.ts rename to apps/cli/src/commands/package/search.ts diff --git a/cli/src/commands/package/shared.ts b/apps/cli/src/commands/package/shared.ts similarity index 100% rename from cli/src/commands/package/shared.ts rename to apps/cli/src/commands/package/shared.ts diff --git a/cli/src/commands/package/update.ts b/apps/cli/src/commands/package/update.ts similarity index 100% rename from cli/src/commands/package/update.ts rename to apps/cli/src/commands/package/update.ts diff --git a/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts similarity index 100% rename from cli/src/commands/plugin.ts rename to apps/cli/src/commands/plugin.ts diff --git a/cli/src/commands/plugin/index.ts b/apps/cli/src/commands/plugin/index.ts similarity index 100% rename from cli/src/commands/plugin/index.ts rename to apps/cli/src/commands/plugin/index.ts diff --git a/cli/src/commands/plugin/install.ts b/apps/cli/src/commands/plugin/install.ts similarity index 100% rename from cli/src/commands/plugin/install.ts rename to apps/cli/src/commands/plugin/install.ts diff --git a/cli/src/commands/plugin/json.ts b/apps/cli/src/commands/plugin/json.ts similarity index 100% rename from cli/src/commands/plugin/json.ts rename to apps/cli/src/commands/plugin/json.ts diff --git a/cli/src/commands/plugin/list.ts b/apps/cli/src/commands/plugin/list.ts similarity index 100% rename from cli/src/commands/plugin/list.ts rename to apps/cli/src/commands/plugin/list.ts diff --git a/cli/src/commands/plugin/remove.ts b/apps/cli/src/commands/plugin/remove.ts similarity index 100% rename from cli/src/commands/plugin/remove.ts rename to apps/cli/src/commands/plugin/remove.ts diff --git a/cli/src/commands/plugin/shared.ts b/apps/cli/src/commands/plugin/shared.ts similarity index 100% rename from cli/src/commands/plugin/shared.ts rename to apps/cli/src/commands/plugin/shared.ts diff --git a/cli/src/commands/plugin/update.ts b/apps/cli/src/commands/plugin/update.ts similarity index 100% rename from cli/src/commands/plugin/update.ts rename to apps/cli/src/commands/plugin/update.ts diff --git a/cli/src/commands/plugin/workflow.ts b/apps/cli/src/commands/plugin/workflow.ts similarity index 100% rename from cli/src/commands/plugin/workflow.ts rename to apps/cli/src/commands/plugin/workflow.ts diff --git a/cli/src/commands/release.ts b/apps/cli/src/commands/release.ts similarity index 100% rename from cli/src/commands/release.ts rename to apps/cli/src/commands/release.ts diff --git a/cli/src/commands/remove.ts b/apps/cli/src/commands/remove.ts similarity index 100% rename from cli/src/commands/remove.ts rename to apps/cli/src/commands/remove.ts diff --git a/cli/src/commands/replay.ts b/apps/cli/src/commands/replay.ts similarity index 98% rename from cli/src/commands/replay.ts rename to apps/cli/src/commands/replay.ts index 0275e624..60eb1ff5 100644 --- a/cli/src/commands/replay.ts +++ b/apps/cli/src/commands/replay.ts @@ -26,7 +26,7 @@ function replayAdapterTemplate(): string { } fail('Session Replay adapter template was not found.', { - details: [`Expected ${ADAPTER_TEMPLATE_PATH} in bundled cli/lua or src-lua.`], + details: [`Expected ${ADAPTER_TEMPLATE_PATH} in bundled cli/lua or packages/runtime-lua.`], }); } diff --git a/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts similarity index 100% rename from cli/src/commands/run.ts rename to apps/cli/src/commands/run.ts diff --git a/cli/src/commands/skills.ts b/apps/cli/src/commands/skills.ts similarity index 100% rename from cli/src/commands/skills.ts rename to apps/cli/src/commands/skills.ts diff --git a/cli/src/commands/update.ts b/apps/cli/src/commands/update.ts similarity index 100% rename from cli/src/commands/update.ts rename to apps/cli/src/commands/update.ts diff --git a/cli/src/commands/upload.ts b/apps/cli/src/commands/upload.ts similarity index 98% rename from cli/src/commands/upload.ts rename to apps/cli/src/commands/upload.ts index 69d5c58e..8a5b699f 100644 --- a/cli/src/commands/upload.ts +++ b/apps/cli/src/commands/upload.ts @@ -14,7 +14,7 @@ import { buildTargets, isBuildTarget, isUploadTarget, uploadTargets, type BuildT import { runBuild } from '../lib/build/build.js'; import { runUpload } from '../lib/build/upload.js'; import { inspectUploadArtifact, type UploadSafetyResult } from '../lib/build/upload-safety.js'; -import { chooseUploadWorkflow, type UploadWorkflowResult } from '../ui/upload-workflow.js'; +import { chooseUploadWorkflow } from '../ui/upload-workflow.js'; import type { BuildResult } from '../lib/build/build.js'; export type UploadCommandOptions = { diff --git a/cli/src/commands/watch.ts b/apps/cli/src/commands/watch.ts similarity index 100% rename from cli/src/commands/watch.ts rename to apps/cli/src/commands/watch.ts diff --git a/cli/src/generated/plugin-catalog.ts b/apps/cli/src/generated/plugin-catalog.ts similarity index 100% rename from cli/src/generated/plugin-catalog.ts rename to apps/cli/src/generated/plugin-catalog.ts diff --git a/cli/src/generated/registry.json b/apps/cli/src/generated/registry.json similarity index 100% rename from cli/src/generated/registry.json rename to apps/cli/src/generated/registry.json diff --git a/cli/src/hooks/use-text-input.tsx b/apps/cli/src/hooks/use-text-input.tsx similarity index 100% rename from cli/src/hooks/use-text-input.tsx rename to apps/cli/src/hooks/use-text-input.tsx diff --git a/cli/src/index.ts b/apps/cli/src/index.ts similarity index 98% rename from cli/src/index.ts rename to apps/cli/src/index.ts index a12e6ccd..ad5af8ad 100644 --- a/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -160,7 +160,7 @@ export function createProgram(): Command { .description('Initialize Feather in a Love2D project directory (default: current directory)') .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') + .option('--local-src ', 'Copy Lua runtime from a local packages/runtime-lua style directory') .option('--install-dir ', 'Install directory for auto/manual modes', 'feather') .option('--no-plugins', 'Skip plugin installation') .option('--plugins ', 'Comma-separated list of plugins to install') @@ -255,6 +255,8 @@ export function createProgram(): Command { .option('--port ', 'Port for HTTP transport', (value) => Number(value), 4006) .option('--desktop-url ', 'Feather desktop MCP bridge URL', 'http://127.0.0.1:4005') .option('--token ', 'MCP bridge and HTTP bearer token') + .option('--studio-url ', 'Feather Studio creative endpoint', 'http://127.0.0.1:4007') + .option('--studio-token ', 'Feather Studio MCP token (Studio mints its own per launch)') .action((opts) => runCliAction(() => mcpCommand({ @@ -263,6 +265,8 @@ export function createProgram(): Command { port: opts.port as number | undefined, desktopUrl: opts.desktopUrl as string | undefined, token: opts.token as string | undefined, + studioUrl: opts.studioUrl as string | undefined, + studioToken: opts.studioToken as string | undefined, }), ), ); @@ -736,7 +740,7 @@ export function createProgram(): Command { .description('Update the Feather core library in a project (default: current directory)') .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy Lua runtime from a local src-lua style directory') + .option('--local-src ', 'Copy Lua runtime from a local packages/runtime-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive confirmation and use the selected/default source') .action((dir: string | undefined, opts) => @@ -757,7 +761,7 @@ export function createProgram(): Command { .option('--dir ', 'Project directory (default: current directory)') .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy plugins from a local src-lua style directory') + .option('--local-src ', 'Copy plugins from a local packages/runtime-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('--managed ', 'Override managed mode detection (cli, auto, manual)') .action((opts) => @@ -798,7 +802,7 @@ export function createProgram(): Command { .option('--dir ', 'Project directory (default: current directory)') .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy plugins from a local src-lua style directory') + .option('--local-src ', 'Copy plugins from a local packages/runtime-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('--managed ', 'Override managed mode detection (cli, auto, manual)') .option('--force', 'Overwrite already-installed plugins without prompting') @@ -850,7 +854,7 @@ export function createProgram(): Command { .option('--dir ', 'Project directory (default: current directory)') .option('--remote', 'Download from GitHub instead of copying the local/bundled Lua runtime') .option('--branch ', 'GitHub branch to download from when using --remote', 'main') - .option('--local-src ', 'Copy plugins from a local src-lua style directory') + .option('--local-src ', 'Copy plugins from a local packages/runtime-lua style directory') .option('--install-dir ', 'Feather install directory', 'feather') .option('-y, --yes', 'Skip interactive selection and update all installed plugins when no id is given') .option('--managed ', 'Override managed mode detection (cli, auto, manual)') diff --git a/cli/src/lib/build/android.ts b/apps/cli/src/lib/build/android.ts similarity index 100% rename from cli/src/lib/build/android.ts rename to apps/cli/src/lib/build/android.ts diff --git a/cli/src/lib/build/archive.ts b/apps/cli/src/lib/build/archive.ts similarity index 100% rename from cli/src/lib/build/archive.ts rename to apps/cli/src/lib/build/archive.ts diff --git a/cli/src/lib/build/build.ts b/apps/cli/src/lib/build/build.ts similarity index 100% rename from cli/src/lib/build/build.ts rename to apps/cli/src/lib/build/build.ts diff --git a/cli/src/lib/build/config.ts b/apps/cli/src/lib/build/config.ts similarity index 100% rename from cli/src/lib/build/config.ts rename to apps/cli/src/lib/build/config.ts diff --git a/cli/src/lib/build/debug-stage.ts b/apps/cli/src/lib/build/debug-stage.ts similarity index 100% rename from cli/src/lib/build/debug-stage.ts rename to apps/cli/src/lib/build/debug-stage.ts diff --git a/cli/src/lib/build/desktop.ts b/apps/cli/src/lib/build/desktop.ts similarity index 100% rename from cli/src/lib/build/desktop.ts rename to apps/cli/src/lib/build/desktop.ts diff --git a/cli/src/lib/build/files.ts b/apps/cli/src/lib/build/files.ts similarity index 100% rename from cli/src/lib/build/files.ts rename to apps/cli/src/lib/build/files.ts diff --git a/cli/src/lib/build/ios.ts b/apps/cli/src/lib/build/ios.ts similarity index 100% rename from cli/src/lib/build/ios.ts rename to apps/cli/src/lib/build/ios.ts diff --git a/cli/src/lib/build/native.ts b/apps/cli/src/lib/build/native.ts similarity index 100% rename from cli/src/lib/build/native.ts rename to apps/cli/src/lib/build/native.ts diff --git a/cli/src/lib/build/release.ts b/apps/cli/src/lib/build/release.ts similarity index 100% rename from cli/src/lib/build/release.ts rename to apps/cli/src/lib/build/release.ts diff --git a/cli/src/lib/build/upload-safety.ts b/apps/cli/src/lib/build/upload-safety.ts similarity index 100% rename from cli/src/lib/build/upload-safety.ts rename to apps/cli/src/lib/build/upload-safety.ts diff --git a/cli/src/lib/build/upload.ts b/apps/cli/src/lib/build/upload.ts similarity index 100% rename from cli/src/lib/build/upload.ts rename to apps/cli/src/lib/build/upload.ts diff --git a/cli/src/lib/build/validation.ts b/apps/cli/src/lib/build/validation.ts similarity index 100% rename from cli/src/lib/build/validation.ts rename to apps/cli/src/lib/build/validation.ts diff --git a/cli/src/lib/build/vendor.ts b/apps/cli/src/lib/build/vendor.ts similarity index 100% rename from cli/src/lib/build/vendor.ts rename to apps/cli/src/lib/build/vendor.ts diff --git a/cli/src/lib/build/web.ts b/apps/cli/src/lib/build/web.ts similarity index 100% rename from cli/src/lib/build/web.ts rename to apps/cli/src/lib/build/web.ts diff --git a/cli/src/lib/clipboard.ts b/apps/cli/src/lib/clipboard.ts similarity index 100% rename from cli/src/lib/clipboard.ts rename to apps/cli/src/lib/clipboard.ts diff --git a/cli/src/lib/command.ts b/apps/cli/src/lib/command.ts similarity index 100% rename from cli/src/lib/command.ts rename to apps/cli/src/lib/command.ts diff --git a/cli/src/lib/config.ts b/apps/cli/src/lib/config.ts similarity index 99% rename from cli/src/lib/config.ts rename to apps/cli/src/lib/config.ts index 2264d25e..69e87df7 100644 --- a/cli/src/lib/config.ts +++ b/apps/cli/src/lib/config.ts @@ -216,7 +216,7 @@ export function loadConfig(gamePath: string, override?: string): FeatherConfig | const src = readFileSync(path, "utf8"); return parseLuaTable(src) as FeatherConfig; } catch (err) { - throw new Error(`Failed to parse ${path}: ${(err as Error).message}`); + throw new Error(`Failed to parse ${path}: ${(err as Error).message}`, { cause: err }); } } diff --git a/cli/src/lib/desktop-bridge.ts b/apps/cli/src/lib/desktop-bridge.ts similarity index 68% rename from cli/src/lib/desktop-bridge.ts rename to apps/cli/src/lib/desktop-bridge.ts index 32875095..d734e3ee 100644 --- a/cli/src/lib/desktop-bridge.ts +++ b/apps/cli/src/lib/desktop-bridge.ts @@ -3,18 +3,35 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; export const DEFAULT_DESKTOP_BRIDGE_URL = 'http://127.0.0.1:4005'; + +/** + * Feather Studio's creative endpoint. + * + * There is one Feather MCP server — this client — but the state behind it lives + * in two applications now. Session, log and debugger traffic belongs to + * Inspector; shaders, textures and particles belong to Studio, which is a + * separately installed application with its own process. + * + * Routing here rather than splitting the MCP keeps the agent-facing surface as + * one thing to configure. + */ +export const DEFAULT_STUDIO_BRIDGE_URL = 'http://127.0.0.1:4007'; export const DESKTOP_BRIDGE_SECTIONS = ['config', 'logs', 'performance', 'debugger', 'plugins', 'assets', 'observers', 'session-replay'] as const; export type DesktopBridgeSection = typeof DESKTOP_BRIDGE_SECTIONS[number]; export type DesktopBridgeOptions = { desktopUrl?: string; + studioUrl?: string; token?: string; + studioToken?: string; }; export type ResolvedDesktopBridgeOptions = { desktopUrl: string; + studioUrl: string; token: string; + studioToken: string; }; export type SharedDesktopBridgeConfig = { @@ -50,10 +67,20 @@ export type SessionListResponse = { }; export class DesktopBridgeClient { + private readonly studioUrl: string; + private readonly studioToken: string; + constructor( private readonly baseUrl: string, private readonly token: string, - ) {} + studio?: { url?: string; token?: string }, + ) { + this.studioUrl = normalizeBaseUrl(studio?.url || DEFAULT_STUDIO_BRIDGE_URL); + // Studio mints its own token per launch. When one has not been supplied, + // creative calls will fail authorization with a message naming Studio, + // which is more useful than silently returning nothing. + this.studioToken = studio?.token || process.env.FEATHER_STUDIO_MCP_TOKEN || ''; + } async health(): Promise { return this.request('/health'); @@ -68,11 +95,11 @@ export class DesktopBridgeClient { } async getCreative(tool: string): Promise { - return this.request(`/creative/${encodeURIComponent(tool)}`); + return this.creativeRequest(`/creative/${encodeURIComponent(tool)}`); } async runCreativeAction(tool: string, body: CreativeActionRequest): Promise { - return this.request(`/creative/${encodeURIComponent(tool)}/action`, { + return this.creativeRequest(`/creative/${encodeURIComponent(tool)}/action`, { method: 'POST', body: JSON.stringify(body), }); @@ -86,20 +113,47 @@ export class DesktopBridgeClient { } private async request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${this.baseUrl}${path}`, { - ...init, - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${this.token}`, - ...(init.headers ?? {}), - }, - }); + return this.send(this.baseUrl, this.token, 'Feather Inspector', path, init); + } + + /** + * Creative state lives in Feather Studio, a separate application. + * + * Routed rather than proxied through Inspector: Studio is useful — and worth + * asking about — with no Inspector running at all. + */ + private async creativeRequest(path: string, init: RequestInit = {}): Promise { + return this.send(this.studioUrl, this.studioToken, 'Feather Studio', path, init); + } + + private async send( + baseUrl: string, + token: string, + app: string, + path: string, + init: RequestInit, + ): Promise { + let response: Response; + try { + response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + ...(init.headers ?? {}), + }, + }); + } catch { + // Naming which application is missing is the whole point: "connection + // refused on 4007" tells an agent nothing it can act on. + throw new Error(`${app} is not running. Start it and try again.`); + } const text = await response.text(); const body = text ? safeJson(text) : null; if (!response.ok) { const detail = isRecord(body) && typeof body.error === 'string' ? body.error : response.statusText; - throw new Error(`Desktop bridge ${response.status}: ${detail}`); + throw new Error(`${app} ${response.status}: ${detail}`); } return body as T; } @@ -109,7 +163,9 @@ export function resolveDesktopBridgeOptions(options: DesktopBridgeOptions = {}): const sharedConfig = readSharedDesktopBridgeConfig(); return { desktopUrl: normalizeBaseUrl(options.desktopUrl || sharedConfig?.bridgeUrl || DEFAULT_DESKTOP_BRIDGE_URL), + studioUrl: normalizeBaseUrl(options.studioUrl || process.env.FEATHER_STUDIO_MCP_URL || DEFAULT_STUDIO_BRIDGE_URL), token: options.token || process.env.FEATHER_MCP_TOKEN || sharedConfig?.token || '', + studioToken: options.studioToken || process.env.FEATHER_STUDIO_MCP_TOKEN || '', }; } diff --git a/cli/src/lib/github.ts b/apps/cli/src/lib/github.ts similarity index 100% rename from cli/src/lib/github.ts rename to apps/cli/src/lib/github.ts diff --git a/cli/src/lib/install.ts b/apps/cli/src/lib/install.ts similarity index 97% rename from cli/src/lib/install.ts rename to apps/cli/src/lib/install.ts index c5ae61a3..eaf8b34e 100644 --- a/cli/src/lib/install.ts +++ b/apps/cli/src/lib/install.ts @@ -11,9 +11,9 @@ import { import { assertSafeProjectTarget, assertSafeRelativePath } from "./path-safety.js"; const GITHUB_RAW = - "https://raw.githubusercontent.com/Kyonru/feather/{branch}/src-lua/{path}"; + "https://raw.githubusercontent.com/Kyonru/feather/{branch}/packages/runtime-lua/{path}"; const MANIFEST_URL = - "https://raw.githubusercontent.com/Kyonru/feather/{branch}/src-lua/manifest.txt"; + "https://raw.githubusercontent.com/Kyonru/feather/{branch}/packages/runtime-lua/manifest.txt"; export interface ManifestEntry { type: "core" | "plugin"; diff --git a/cli/src/lib/love.ts b/apps/cli/src/lib/love.ts similarity index 100% rename from cli/src/lib/love.ts rename to apps/cli/src/lib/love.ts diff --git a/cli/src/lib/output.ts b/apps/cli/src/lib/output.ts similarity index 100% rename from cli/src/lib/output.ts rename to apps/cli/src/lib/output.ts diff --git a/cli/src/lib/package/add-plan.ts b/apps/cli/src/lib/package/add-plan.ts similarity index 100% rename from cli/src/lib/package/add-plan.ts rename to apps/cli/src/lib/package/add-plan.ts diff --git a/cli/src/lib/package/aliases.ts b/apps/cli/src/lib/package/aliases.ts similarity index 100% rename from cli/src/lib/package/aliases.ts rename to apps/cli/src/lib/package/aliases.ts diff --git a/cli/src/lib/package/audit.ts b/apps/cli/src/lib/package/audit.ts similarity index 100% rename from cli/src/lib/package/audit.ts rename to apps/cli/src/lib/package/audit.ts diff --git a/cli/src/lib/package/checksum.ts b/apps/cli/src/lib/package/checksum.ts similarity index 100% rename from cli/src/lib/package/checksum.ts rename to apps/cli/src/lib/package/checksum.ts diff --git a/cli/src/lib/package/compat.ts b/apps/cli/src/lib/package/compat.ts similarity index 100% rename from cli/src/lib/package/compat.ts rename to apps/cli/src/lib/package/compat.ts diff --git a/cli/src/lib/package/custom-add.ts b/apps/cli/src/lib/package/custom-add.ts similarity index 100% rename from cli/src/lib/package/custom-add.ts rename to apps/cli/src/lib/package/custom-add.ts diff --git a/cli/src/lib/package/git-source.ts b/apps/cli/src/lib/package/git-source.ts similarity index 100% rename from cli/src/lib/package/git-source.ts rename to apps/cli/src/lib/package/git-source.ts diff --git a/cli/src/lib/package/install.ts b/apps/cli/src/lib/package/install.ts similarity index 100% rename from cli/src/lib/package/install.ts rename to apps/cli/src/lib/package/install.ts diff --git a/cli/src/lib/package/licenses.ts b/apps/cli/src/lib/package/licenses.ts similarity index 100% rename from cli/src/lib/package/licenses.ts rename to apps/cli/src/lib/package/licenses.ts diff --git a/cli/src/lib/package/lockfile.ts b/apps/cli/src/lib/package/lockfile.ts similarity index 100% rename from cli/src/lib/package/lockfile.ts rename to apps/cli/src/lib/package/lockfile.ts diff --git a/cli/src/lib/package/provenance.ts b/apps/cli/src/lib/package/provenance.ts similarity index 100% rename from cli/src/lib/package/provenance.ts rename to apps/cli/src/lib/package/provenance.ts diff --git a/cli/src/lib/package/registry.ts b/apps/cli/src/lib/package/registry.ts similarity index 100% rename from cli/src/lib/package/registry.ts rename to apps/cli/src/lib/package/registry.ts diff --git a/cli/src/lib/package/resolve.ts b/apps/cli/src/lib/package/resolve.ts similarity index 100% rename from cli/src/lib/package/resolve.ts rename to apps/cli/src/lib/package/resolve.ts diff --git a/cli/src/lib/package/target.ts b/apps/cli/src/lib/package/target.ts similarity index 100% rename from cli/src/lib/package/target.ts rename to apps/cli/src/lib/package/target.ts diff --git a/cli/src/lib/path-safety.ts b/apps/cli/src/lib/path-safety.ts similarity index 100% rename from cli/src/lib/path-safety.ts rename to apps/cli/src/lib/path-safety.ts diff --git a/cli/src/lib/paths.ts b/apps/cli/src/lib/paths.ts similarity index 97% rename from cli/src/lib/paths.ts rename to apps/cli/src/lib/paths.ts index 44af2968..251a37a7 100644 --- a/cli/src/lib/paths.ts +++ b/apps/cli/src/lib/paths.ts @@ -23,7 +23,7 @@ export function bundledSkillsRoot(): string { } export function repoLuaRoot(): string | null { - const candidate = resolve(MODULE_DIR, '../../../src-lua'); + const candidate = resolve(MODULE_DIR, '../../../../packages/runtime-lua'); return existsSync(join(candidate, 'feather', 'init.lua')) ? candidate : null; } diff --git a/cli/src/lib/plugin-utils.ts b/apps/cli/src/lib/plugin-utils.ts similarity index 100% rename from cli/src/lib/plugin-utils.ts rename to apps/cli/src/lib/plugin-utils.ts diff --git a/cli/src/lib/redact.ts b/apps/cli/src/lib/redact.ts similarity index 100% rename from cli/src/lib/redact.ts rename to apps/cli/src/lib/redact.ts diff --git a/cli/src/lib/run/mobile.ts b/apps/cli/src/lib/run/mobile.ts similarity index 100% rename from cli/src/lib/run/mobile.ts rename to apps/cli/src/lib/run/mobile.ts diff --git a/cli/src/lib/run/watch.ts b/apps/cli/src/lib/run/watch.ts similarity index 100% rename from cli/src/lib/run/watch.ts rename to apps/cli/src/lib/run/watch.ts diff --git a/cli/src/lib/run/web.ts b/apps/cli/src/lib/run/web.ts similarity index 100% rename from cli/src/lib/run/web.ts rename to apps/cli/src/lib/run/web.ts diff --git a/cli/src/lib/shim.ts b/apps/cli/src/lib/shim.ts similarity index 96% rename from cli/src/lib/shim.ts rename to apps/cli/src/lib/shim.ts index fefc1969..18720969 100644 --- a/cli/src/lib/shim.ts +++ b/apps/cli/src/lib/shim.ts @@ -5,10 +5,10 @@ import { fileURLToPath } from 'node:url'; // Path to the bundled Lua library shipped with this CLI package. // In a source checkout `npm run build` does not run the publish-time Lua bundle, -// so fall back to the repository's src-lua directory for local development. +// so fall back to the repository's packages/runtime-lua directory for local development. const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)); const PACKAGED_LUA = resolve(MODULE_DIR, '../../lua'); -const SOURCE_LUA = resolve(MODULE_DIR, '../../../src-lua'); +const SOURCE_LUA = resolve(MODULE_DIR, '../../../../packages/runtime-lua'); export function bundledLuaRoot(): string { // When running as a compiled binary, lua/ ships next to the executable. @@ -142,6 +142,12 @@ function buildMainLua(opts: ShimOptions, featherDir: string, pluginsDir?: string ${packagePathLine} ${pluginListLine} +local gamePath = os.getenv("FEATHER_GAME_PATH") +if gamePath and gamePath ~= "" then + local normalizedGamePath = gamePath:gsub("\\\\", "/") + package.path = package.path .. ";" .. normalizedGamePath .. "/?.lua;" .. normalizedGamePath .. "/?/init.lua" +end + FEATHER_PATH = "feather" FEATHER_PLUGIN_PATH = "" ${opts.noPlugins ? 'FEATHER_SKIP_PLUGINS = true' : ''} @@ -154,7 +160,6 @@ ${configLines} require("feather.auto") -- Load the game's main.lua via absolute OS path to avoid shadowing by this file -local gamePath = os.getenv("FEATHER_GAME_PATH") if gamePath then local chunk, err = loadfile(gamePath .. "/main.lua") if not chunk then diff --git a/cli/src/lib/trust.ts b/apps/cli/src/lib/trust.ts similarity index 100% rename from cli/src/lib/trust.ts rename to apps/cli/src/lib/trust.ts diff --git a/cli/src/lib/url.ts b/apps/cli/src/lib/url.ts similarity index 100% rename from cli/src/lib/url.ts rename to apps/cli/src/lib/url.ts diff --git a/cli/src/ui/components.tsx b/apps/cli/src/ui/components.tsx similarity index 100% rename from cli/src/ui/components.tsx rename to apps/cli/src/ui/components.tsx diff --git a/cli/src/ui/confirm.tsx b/apps/cli/src/ui/confirm.tsx similarity index 100% rename from cli/src/ui/confirm.tsx rename to apps/cli/src/ui/confirm.tsx diff --git a/cli/src/ui/create-workflow.tsx b/apps/cli/src/ui/create-workflow.tsx similarity index 100% rename from cli/src/ui/create-workflow.tsx rename to apps/cli/src/ui/create-workflow.tsx diff --git a/cli/src/ui/init/config.ts b/apps/cli/src/ui/init/config.ts similarity index 100% rename from cli/src/ui/init/config.ts rename to apps/cli/src/ui/init/config.ts diff --git a/cli/src/ui/init/index.ts b/apps/cli/src/ui/init/index.ts similarity index 100% rename from cli/src/ui/init/index.ts rename to apps/cli/src/ui/init/index.ts diff --git a/cli/src/ui/init/model.ts b/apps/cli/src/ui/init/model.ts similarity index 98% rename from cli/src/ui/init/model.ts rename to apps/cli/src/ui/init/model.ts index 47aa81c0..b9507d81 100644 --- a/cli/src/ui/init/model.ts +++ b/apps/cli/src/ui/init/model.ts @@ -102,7 +102,7 @@ export const installSources: Option<"local" | "remote">[] = [ { value: "local", label: "Bundled/local copy", - description: "Copy the CLI-bundled Lua runtime, or src-lua when running from the repo.", + description: "Copy the CLI-bundled Lua runtime, or packages/runtime-lua when running from the repo.", }, { value: "remote", diff --git a/cli/src/ui/init/prompts.tsx b/apps/cli/src/ui/init/prompts.tsx similarity index 100% rename from cli/src/ui/init/prompts.tsx rename to apps/cli/src/ui/init/prompts.tsx diff --git a/cli/src/ui/init/summary.tsx b/apps/cli/src/ui/init/summary.tsx similarity index 100% rename from cli/src/ui/init/summary.tsx rename to apps/cli/src/ui/init/summary.tsx diff --git a/cli/src/ui/init/workflow.tsx b/apps/cli/src/ui/init/workflow.tsx similarity index 100% rename from cli/src/ui/init/workflow.tsx rename to apps/cli/src/ui/init/workflow.tsx diff --git a/cli/src/ui/package/add-helpers.ts b/apps/cli/src/ui/package/add-helpers.ts similarity index 100% rename from cli/src/ui/package/add-helpers.ts rename to apps/cli/src/ui/package/add-helpers.ts diff --git a/cli/src/ui/package/add-steps.tsx b/apps/cli/src/ui/package/add-steps.tsx similarity index 100% rename from cli/src/ui/package/add-steps.tsx rename to apps/cli/src/ui/package/add-steps.tsx diff --git a/cli/src/ui/package/add.tsx b/apps/cli/src/ui/package/add.tsx similarity index 100% rename from cli/src/ui/package/add.tsx rename to apps/cli/src/ui/package/add.tsx diff --git a/cli/src/ui/package/index.ts b/apps/cli/src/ui/package/index.ts similarity index 100% rename from cli/src/ui/package/index.ts rename to apps/cli/src/ui/package/index.ts diff --git a/cli/src/ui/package/progress.tsx b/apps/cli/src/ui/package/progress.tsx similarity index 99% rename from cli/src/ui/package/progress.tsx rename to apps/cli/src/ui/package/progress.tsx index 08f458d0..22b368fe 100644 --- a/cli/src/ui/package/progress.tsx +++ b/apps/cli/src/ui/package/progress.tsx @@ -233,7 +233,8 @@ function InstallProgress({ })(); return () => { cancelled = true; }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Mount-only on purpose: the effect owns its own `cancelled` guard. + }, []); const succeededPkgs = allResults.length > 0 ? packages.filter((_, i) => allResults[i]?.ok) : []; diff --git a/cli/src/ui/package/workflow.tsx b/apps/cli/src/ui/package/workflow.tsx similarity index 100% rename from cli/src/ui/package/workflow.tsx rename to apps/cli/src/ui/package/workflow.tsx diff --git a/cli/src/ui/plugin-workflow.tsx b/apps/cli/src/ui/plugin-workflow.tsx similarity index 98% rename from cli/src/ui/plugin-workflow.tsx rename to apps/cli/src/ui/plugin-workflow.tsx index 06c46eeb..51911bfe 100644 --- a/cli/src/ui/plugin-workflow.tsx +++ b/apps/cli/src/ui/plugin-workflow.tsx @@ -35,7 +35,7 @@ const actions: Option[] = [ ]; const sources: Option[] = [ - { value: "local", label: "Bundled/local copy", description: "Use the CLI-bundled Lua runtime, or repo src-lua in development." }, + { value: "local", label: "Bundled/local copy", description: "Use the CLI-bundled Lua runtime, or repo packages/runtime-lua in development." }, { value: "remote", label: "GitHub download", description: "Fetch plugin files from GitHub using a branch or tag." }, ]; diff --git a/cli/src/ui/remove-workflow.tsx b/apps/cli/src/ui/remove-workflow.tsx similarity index 100% rename from cli/src/ui/remove-workflow.tsx rename to apps/cli/src/ui/remove-workflow.tsx diff --git a/cli/src/ui/run-workflow.tsx b/apps/cli/src/ui/run-workflow.tsx similarity index 100% rename from cli/src/ui/run-workflow.tsx rename to apps/cli/src/ui/run-workflow.tsx diff --git a/cli/src/ui/update-workflow.tsx b/apps/cli/src/ui/update-workflow.tsx similarity index 100% rename from cli/src/ui/update-workflow.tsx rename to apps/cli/src/ui/update-workflow.tsx diff --git a/cli/src/ui/upload-workflow.tsx b/apps/cli/src/ui/upload-workflow.tsx similarity index 100% rename from cli/src/ui/upload-workflow.tsx rename to apps/cli/src/ui/upload-workflow.tsx diff --git a/cli/stubs/react-devtools-core/index.js b/apps/cli/stubs/react-devtools-core/index.js similarity index 100% rename from cli/stubs/react-devtools-core/index.js rename to apps/cli/stubs/react-devtools-core/index.js diff --git a/cli/stubs/react-devtools-core/package.json b/apps/cli/stubs/react-devtools-core/package.json similarity index 100% rename from cli/stubs/react-devtools-core/package.json rename to apps/cli/stubs/react-devtools-core/package.json diff --git a/cli/test/commands/agent-live.test.mjs b/apps/cli/test/commands/agent-live.test.mjs similarity index 99% rename from cli/test/commands/agent-live.test.mjs rename to apps/cli/test/commands/agent-live.test.mjs index c50222a0..714a2787 100644 --- a/cli/test/commands/agent-live.test.mjs +++ b/apps/cli/test/commands/agent-live.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { createServer } from 'node:http'; import { assert, outputOf, spawnCli, test } from './helpers.mjs'; diff --git a/cli/test/commands/build-android.test.mjs b/apps/cli/test/commands/build-android.test.mjs similarity index 99% rename from cli/test/commands/build-android.test.mjs rename to apps/cli/test/commands/build-android.test.mjs index 975e4a41..cfac1349 100644 --- a/cli/test/commands/build-android.test.mjs +++ b/apps/cli/test/commands/build-android.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { ANSI_RE, assert, diff --git a/cli/test/commands/build-ios.test.mjs b/apps/cli/test/commands/build-ios.test.mjs similarity index 99% rename from cli/test/commands/build-ios.test.mjs rename to apps/cli/test/commands/build-ios.test.mjs index 0356101b..6ab032e4 100644 --- a/cli/test/commands/build-ios.test.mjs +++ b/apps/cli/test/commands/build-ios.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { ANSI_RE, assert, diff --git a/cli/test/commands/build-vendor.test.mjs b/apps/cli/test/commands/build-vendor.test.mjs similarity index 99% rename from cli/test/commands/build-vendor.test.mjs rename to apps/cli/test/commands/build-vendor.test.mjs index 19fa3c1d..979fb6d9 100644 --- a/cli/test/commands/build-vendor.test.mjs +++ b/apps/cli/test/commands/build-vendor.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { ANSI_RE, assert, diff --git a/cli/test/commands/build.test.mjs b/apps/cli/test/commands/build.test.mjs similarity index 99% rename from cli/test/commands/build.test.mjs rename to apps/cli/test/commands/build.test.mjs index 31a702ba..c996d68f 100644 --- a/cli/test/commands/build.test.mjs +++ b/apps/cli/test/commands/build.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { ANSI_RE, assert, diff --git a/cli/test/commands/config.test.mjs b/apps/cli/test/commands/config.test.mjs similarity index 99% rename from cli/test/commands/config.test.mjs rename to apps/cli/test/commands/config.test.mjs index 8e6ff579..8af0050b 100644 --- a/cli/test/commands/config.test.mjs +++ b/apps/cli/test/commands/config.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { assert, join, diff --git a/cli/test/commands/create.test.mjs b/apps/cli/test/commands/create.test.mjs similarity index 99% rename from cli/test/commands/create.test.mjs rename to apps/cli/test/commands/create.test.mjs index 56a83430..5f71471e 100644 --- a/cli/test/commands/create.test.mjs +++ b/apps/cli/test/commands/create.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { assert, chmodSync, diff --git a/cli/test/commands/doctor.test.mjs b/apps/cli/test/commands/doctor.test.mjs similarity index 99% rename from cli/test/commands/doctor.test.mjs rename to apps/cli/test/commands/doctor.test.mjs index 86e45221..920b2fe0 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/apps/cli/test/commands/doctor.test.mjs @@ -1,7 +1,5 @@ -/* eslint-disable no-undef */ import { ANSI_RE, - LOCAL_SRC, assert, envWithPath, join, diff --git a/cli/test/commands/help.test.mjs b/apps/cli/test/commands/help.test.mjs similarity index 98% rename from cli/test/commands/help.test.mjs rename to apps/cli/test/commands/help.test.mjs index 174a2411..61cc0837 100644 --- a/cli/test/commands/help.test.mjs +++ b/apps/cli/test/commands/help.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { assert, join, makeTmp, outputOf, run, symlinkSync, test } from './helpers.mjs'; diff --git a/cli/test/commands/helpers.mjs b/apps/cli/test/commands/helpers.mjs similarity index 99% rename from cli/test/commands/helpers.mjs rename to apps/cli/test/commands/helpers.mjs index 157a3f7b..741e802e 100644 --- a/cli/test/commands/helpers.mjs +++ b/apps/cli/test/commands/helpers.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ /** * Focused compiled-CLI coverage for non-package commands. */ @@ -22,8 +21,8 @@ import { delimiter, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); -const ROOT = fileURLToPath(new URL('../../..', import.meta.url)); -const LOCAL_SRC = join(ROOT, 'src-lua'); +const ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); +const LOCAL_SRC = join(ROOT, 'packages/runtime-lua'); // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/; const sha256 = (value) => createHash('sha256').update(value).digest('hex'); @@ -789,6 +788,7 @@ function parseDoctorJsonResult(dir, extra = []) { export { ANSI_RE, + ROOT, LOCAL_SRC, assert, chmodSync, diff --git a/cli/test/commands/init.test.mjs b/apps/cli/test/commands/init.test.mjs similarity index 99% rename from cli/test/commands/init.test.mjs rename to apps/cli/test/commands/init.test.mjs index 07f88c13..71d52047 100644 --- a/cli/test/commands/init.test.mjs +++ b/apps/cli/test/commands/init.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { LOCAL_SRC, assert, diff --git a/cli/test/commands/mcp.test.mjs b/apps/cli/test/commands/mcp.test.mjs similarity index 98% rename from cli/test/commands/mcp.test.mjs rename to apps/cli/test/commands/mcp.test.mjs index b731f32e..8cabea18 100644 --- a/cli/test/commands/mcp.test.mjs +++ b/apps/cli/test/commands/mcp.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { createServer } from 'node:http'; import { once } from 'node:events'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; @@ -633,8 +632,15 @@ test('mcp setup rejects unsupported clients', () => { test('mcp: stdio initializes, lists tools, and calls the fake bridge without stdout logs', async () => { const bridge = await startFakeBridge(); - const child = spawnCli(['mcp', '--transport', 'stdio', '--desktop-url', bridge.url], { - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_MCP_TOKEN: TOKEN }, + const child = spawnCli(['mcp', '--transport', 'stdio', '--desktop-url', bridge.url, '--studio-url', bridge.url], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + FEATHER_MCP_TOKEN: TOKEN, + // Studio mints its own token per launch; the fake bridge accepts one. + FEATHER_STUDIO_MCP_TOKEN: TOKEN, + }, }); try { @@ -858,8 +864,15 @@ test('mcp: stdio initializes, lists tools, and calls the fake bridge without std test('mcp: resources/list keeps static resources discoverable when bridge is unavailable', async () => { const port = await freePort(); - const child = spawnCli(['mcp', '--transport', 'stdio', '--desktop-url', `http://127.0.0.1:${port}`], { - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', FEATHER_MCP_TOKEN: TOKEN }, + const child = spawnCli(['mcp', '--transport', 'stdio', '--desktop-url', `http://127.0.0.1:${port}`, '--studio-url', `http://127.0.0.1:${port}`], { + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + FEATHER_MCP_TOKEN: TOKEN, + // Studio mints its own token per launch; the fake bridge accepts one. + FEATHER_STUDIO_MCP_TOKEN: TOKEN, + }, }); try { @@ -893,7 +906,7 @@ test('mcp: HTTP transport requires bearer auth and accepts an authorized initial const bridge = await startFakeBridge(); const port = await freePort(); const child = spawnCli( - ['mcp', '--transport', 'http', '--port', String(port), '--desktop-url', bridge.url, '--token', TOKEN], + ['mcp', '--transport', 'http', '--port', String(port), '--desktop-url', bridge.url, '--studio-url', bridge.url, '--token', TOKEN], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } }, ); diff --git a/cli/test/commands/package.test.mjs b/apps/cli/test/commands/package.test.mjs similarity index 99% rename from cli/test/commands/package.test.mjs rename to apps/cli/test/commands/package.test.mjs index afcf2660..583fbf3e 100644 --- a/cli/test/commands/package.test.mjs +++ b/apps/cli/test/commands/package.test.mjs @@ -26,10 +26,10 @@ import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; const CLI = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); -const ROOT = fileURLToPath(new URL('../../..', import.meta.url)); -const LOCAL_SRC = join(ROOT, 'src-lua'); +const ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); +const LOCAL_SRC = join(ROOT, 'packages/runtime-lua'); const LOCK_FIXTURES = fileURLToPath(new URL('../fixtures/package-locks/', import.meta.url)); -const CLI_PACKAGE = JSON.parse(readFileSync(join(ROOT, 'cli', 'package.json'), 'utf8')); +const CLI_PACKAGE = JSON.parse(readFileSync(join(ROOT, 'apps', 'cli', 'package.json'), 'utf8')); const LOCK_FEATURE_REQUIREMENT = `>=${CLI_PACKAGE.version}`; const sha256 = (s) => createHash('sha256').update(s).digest('hex'); @@ -989,12 +989,12 @@ test('output: NO_COLOR keeps package search readable without ANSI escapes', () = test('output: command and lib sources route terminal writes through output helpers', () => { const allowed = new Set([ - join(ROOT, 'cli', 'src', 'lib', 'output.ts'), - join(ROOT, 'cli', 'src', 'lib', 'command.ts'), + join(ROOT, 'apps', 'cli', 'src', 'lib', 'output.ts'), + join(ROOT, 'apps', 'cli', 'src', 'lib', 'command.ts'), ]); const files = [ - ...sourceFiles(join(ROOT, 'cli', 'src', 'commands')), - ...sourceFiles(join(ROOT, 'cli', 'src', 'lib')), + ...sourceFiles(join(ROOT, 'apps', 'cli', 'src', 'commands')), + ...sourceFiles(join(ROOT, 'apps', 'cli', 'src', 'lib')), ].filter((file) => !allowed.has(file)); const offenders = files.flatMap((file) => { @@ -2960,7 +2960,7 @@ test('custom add: failed repo fetch does not write lockfile', async () => { }); test('package registry: top-level packages resolve offline in dry-run mode', () => { - const registryPath = join(ROOT, 'cli', 'dist', 'generated', 'registry.json'); + const registryPath = join(ROOT, 'apps', 'cli', 'dist', 'generated', 'registry.json'); assert.ok(existsSync(registryPath), 'cli/dist/generated/registry.json missing; run build first'); const registry = JSON.parse(readFileSync(registryPath, 'utf8')); const topLevel = Object.entries(registry.packages).filter(([, entry]) => !entry.parent); diff --git a/cli/test/commands/plugins-managed.test.mjs b/apps/cli/test/commands/plugins-managed.test.mjs similarity index 99% rename from cli/test/commands/plugins-managed.test.mjs rename to apps/cli/test/commands/plugins-managed.test.mjs index 678a39e6..ef797722 100644 --- a/cli/test/commands/plugins-managed.test.mjs +++ b/apps/cli/test/commands/plugins-managed.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ /** * Tests for `--managed ` override and automatic managed-mode detection * across all plugin subcommands (install, remove, update, list). diff --git a/cli/test/commands/plugins.test.mjs b/apps/cli/test/commands/plugins.test.mjs similarity index 98% rename from cli/test/commands/plugins.test.mjs rename to apps/cli/test/commands/plugins.test.mjs index 625f6c71..cf250db3 100644 --- a/cli/test/commands/plugins.test.mjs +++ b/apps/cli/test/commands/plugins.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { LOCAL_SRC, assert, @@ -96,7 +95,7 @@ test('plugin install: unknown local plugin exits 1', () => { test('plugin install: local manifest is validated before copying', () => { const dir = makeTmp(); writeMinimalRuntime(dir); - const source = join(makeTmp(), 'src-lua'); + const source = join(makeTmp(), 'packages/runtime-lua'); writeLocalPluginSource(source, 'bad-plugin', { version: null }); const result = run(['plugin', 'install', 'bad-plugin', '--local-src', source, '--dir', dir]); @@ -108,7 +107,7 @@ test('plugin install: local manifest is validated before copying', () => { test('plugin install: local manifest id must match plugin path', () => { const dir = makeTmp(); writeMinimalRuntime(dir); - const source = join(makeTmp(), 'src-lua'); + const source = join(makeTmp(), 'packages/runtime-lua'); writeLocalPluginSource(source, 'console', { manifestId: 'other-plugin' }); const result = run(['plugin', 'install', 'console', '--local-src', source, '--dir', dir]); @@ -139,7 +138,7 @@ test('plugin install: refuses install directory symlink escaping project', () => test('plugin update: explicit local update fails on invalid manifest', () => { const dir = makeTmp(); - const source = join(makeTmp(), 'src-lua'); + const source = join(makeTmp(), 'packages/runtime-lua'); writeLocalPluginSource(source, 'bad-plugin', { version: 'not valid' }); const result = run(['plugin', 'update', 'bad-plugin', '--local-src', source, '--dir', dir, '--yes']); diff --git a/cli/test/commands/release.test.mjs b/apps/cli/test/commands/release.test.mjs similarity index 99% rename from cli/test/commands/release.test.mjs rename to apps/cli/test/commands/release.test.mjs index 7532fa61..5471e081 100644 --- a/cli/test/commands/release.test.mjs +++ b/apps/cli/test/commands/release.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { assert, envWithPath, diff --git a/cli/test/commands/replay.test.mjs b/apps/cli/test/commands/replay.test.mjs similarity index 98% rename from cli/test/commands/replay.test.mjs rename to apps/cli/test/commands/replay.test.mjs index e0d341a4..d35fd48d 100644 --- a/cli/test/commands/replay.test.mjs +++ b/apps/cli/test/commands/replay.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { assert, existsSync, join, makeTmp, outputOf, readFileSync, run, test, writeFileSync, writeGame } from './helpers.mjs'; test('replay init: creates centralized adapter and enables session-replay plugin', () => { diff --git a/cli/test/commands/run.test.mjs b/apps/cli/test/commands/run.test.mjs similarity index 97% rename from cli/test/commands/run.test.mjs rename to apps/cli/test/commands/run.test.mjs index 7d3b57d2..642faf5b 100644 --- a/cli/test/commands/run.test.mjs +++ b/apps/cli/test/commands/run.test.mjs @@ -1,5 +1,5 @@ -/* eslint-disable no-undef */ import { + ROOT, LOCAL_SRC, assert, delimiter, @@ -102,12 +102,27 @@ test('run: source checkout build exposes feather.auto without a bundled cli/lua const record = JSON.parse(readFileSync(recordPath, 'utf8')); assert.equal(record.featherAutoExists, true); assert.ok( - [join(dirname(LOCAL_SRC), 'cli', 'lua'), LOCAL_SRC].some((root) => + [join(ROOT, 'apps', 'cli', 'lua'), LOCAL_SRC].some((root) => record.shimMain.includes(`${root.replace(/\\/g, '/')}/?.lua`), ), ); }); +test('run: shim adds game root to package.path for local module requires', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + writeFileSync(join(gameDir, 'explosion.lua'), 'return {}\n'); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.shimMain.includes('local normalizedGamePath = gamePath:gsub(')); + assert.ok(record.shimMain.includes('normalizedGamePath .. "/?.lua;" .. normalizedGamePath .. "/?/init.lua"')); +}); + test('run: accepts configPath aliases and recovers npm-stripped config path argument', () => { const dir = makeTmp(); const gameDir = join(dir, 'game'); @@ -383,7 +398,7 @@ test('run --target android --config: embeds selected raw Feather config in mobil test('run --target android: uses root build config for a nested game path', () => { const dir = makeTmp(); - const gameDir = join(dir, 'src-lua', 'example', 'test_cli'); + const gameDir = join(dir, 'packages/runtime-lua', 'example', 'test_cli'); writeGame(gameDir); const { recordPath: gradleRecordPath } = writeFakeLoveAndroid(dir); writeBuildConfig(dir, { diff --git a/cli/test/commands/runtime.test.mjs b/apps/cli/test/commands/runtime.test.mjs similarity index 99% rename from cli/test/commands/runtime.test.mjs rename to apps/cli/test/commands/runtime.test.mjs index eb4f5fc0..9d82277f 100644 --- a/cli/test/commands/runtime.test.mjs +++ b/apps/cli/test/commands/runtime.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { ANSI_RE, assert, diff --git a/cli/test/commands/skills.test.mjs b/apps/cli/test/commands/skills.test.mjs similarity index 99% rename from cli/test/commands/skills.test.mjs rename to apps/cli/test/commands/skills.test.mjs index bf7d2d8b..dcc8f291 100644 --- a/cli/test/commands/skills.test.mjs +++ b/apps/cli/test/commands/skills.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { assert, existsSync, diff --git a/cli/test/commands/upload-safety.test.mjs b/apps/cli/test/commands/upload-safety.test.mjs similarity index 99% rename from cli/test/commands/upload-safety.test.mjs rename to apps/cli/test/commands/upload-safety.test.mjs index 8565588c..6eb27936 100644 --- a/cli/test/commands/upload-safety.test.mjs +++ b/apps/cli/test/commands/upload-safety.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { assert, join, makeTmp, mkdirSync, test, writeFileSync } from './helpers.mjs'; import { createZipBuffer } from '../../dist/lib/build/archive.js'; import { inspectUploadArtifact } from '../../dist/lib/build/upload-safety.js'; diff --git a/cli/test/commands/upload.test.mjs b/apps/cli/test/commands/upload.test.mjs similarity index 99% rename from cli/test/commands/upload.test.mjs rename to apps/cli/test/commands/upload.test.mjs index d689068e..7e632907 100644 --- a/cli/test/commands/upload.test.mjs +++ b/apps/cli/test/commands/upload.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { ANSI_RE, assert, diff --git a/cli/test/commands/watch.test.mjs b/apps/cli/test/commands/watch.test.mjs similarity index 99% rename from cli/test/commands/watch.test.mjs rename to apps/cli/test/commands/watch.test.mjs index 54cd4635..60cf47eb 100644 --- a/cli/test/commands/watch.test.mjs +++ b/apps/cli/test/commands/watch.test.mjs @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { assert, envWithPath, diff --git a/cli/test/fixtures/package-locks/custom-url-v1.json b/apps/cli/test/fixtures/package-locks/custom-url-v1.json similarity index 100% rename from cli/test/fixtures/package-locks/custom-url-v1.json rename to apps/cli/test/fixtures/package-locks/custom-url-v1.json diff --git a/cli/test/fixtures/package-locks/fixed-layout-menori.json b/apps/cli/test/fixtures/package-locks/fixed-layout-menori.json similarity index 100% rename from cli/test/fixtures/package-locks/fixed-layout-menori.json rename to apps/cli/test/fixtures/package-locks/fixed-layout-menori.json diff --git a/cli/test/fixtures/package-locks/future-feature.json b/apps/cli/test/fixtures/package-locks/future-feature.json similarity index 100% rename from cli/test/fixtures/package-locks/future-feature.json rename to apps/cli/test/fixtures/package-locks/future-feature.json diff --git a/cli/test/fixtures/package-locks/generated-alias.json b/apps/cli/test/fixtures/package-locks/generated-alias.json similarity index 100% rename from cli/test/fixtures/package-locks/generated-alias.json rename to apps/cli/test/fixtures/package-locks/generated-alias.json diff --git a/cli/test/fixtures/package-locks/git-source.json b/apps/cli/test/fixtures/package-locks/git-source.json similarity index 100% rename from cli/test/fixtures/package-locks/git-source.json rename to apps/cli/test/fixtures/package-locks/git-source.json diff --git a/cli/test/fixtures/package-locks/install-dir-v1.json b/apps/cli/test/fixtures/package-locks/install-dir-v1.json similarity index 100% rename from cli/test/fixtures/package-locks/install-dir-v1.json rename to apps/cli/test/fixtures/package-locks/install-dir-v1.json diff --git a/cli/test/fixtures/package-locks/old-feel-vendored-flux.json b/apps/cli/test/fixtures/package-locks/old-feel-vendored-flux.json similarity index 100% rename from cli/test/fixtures/package-locks/old-feel-vendored-flux.json rename to apps/cli/test/fixtures/package-locks/old-feel-vendored-flux.json diff --git a/cli/test/fixtures/package-locks/plain-v1.json b/apps/cli/test/fixtures/package-locks/plain-v1.json similarity index 100% rename from cli/test/fixtures/package-locks/plain-v1.json rename to apps/cli/test/fixtures/package-locks/plain-v1.json diff --git a/cli/tsconfig.json b/apps/cli/tsconfig.json similarity index 100% rename from cli/tsconfig.json rename to apps/cli/tsconfig.json diff --git a/docs/assets.md b/apps/docs/assets.md similarity index 100% rename from docs/assets.md rename to apps/docs/assets.md diff --git a/apps/docs/ci.md b/apps/docs/ci.md new file mode 100644 index 00000000..911dc546 --- /dev/null +++ b/apps/docs/ci.md @@ -0,0 +1,166 @@ +# CI and automation + +Feather works in a pipeline, but not all of it does. This page covers what to +install, which commands make sense without a terminal, and where the release +gate goes. + +## Pick an install path + +=== "Standalone binary (hermetic)" + + No Node toolchain, one download. This is usually what you want on a lean + runner. Binaries are attached to every CLI release, so you can pin an exact + version — including a CLI-only patch. + + ```sh + curl -fsSL -o feather \ + https://github.com/Kyonru/feather/releases/download/v4.0.0/feather-cli-linux-x64-4.0.0.bin + chmod +x feather + ./feather --version + ``` + + Available targets: `linux-x64`, `darwin-arm64`, `darwin-x64`, and + `windows-x64` (`.exe`). + +=== "npm" + + Requires **Node 22 or newer** — the package declares that engine. + + ```sh + npm install -g @kyonru/feather@4.0.0 + ``` + +Pin the version either way. Feather releases each component on its own train, so +`@kyonru/feather@4.0.0` is independent of the desktop app and runtime versions. + +!!! tip "Which versions work together" + + Every `v*` release publishes `feather-platform.json` listing the CLI, Lua + runtime, desktop app and extension versions that were built and tested as a + set, with the wire protocol version. Pin from there and you do not have to + match components up yourself. `feather doctor` reports whether your local + combination matches. + +## Install LÖVE + +Feather drives LÖVE; it does not replace it. A runner needs it too, plus `xvfb` +for anything headless that opens a window. + +```sh +sudo apt-get update +sudo apt-get install -y love xvfb +``` + +## Commands that belong in CI + +| Command | Use | +| --- | --- | +| `feather doctor --production --json` | The release gate. See below. | +| `feather build ` | Produce a shippable artifact. | +| `feather upload --yes` | Ship it. | +| `feather package install` | Restore Love2D dependencies from the lockfile. | +| `feather package audit` | Verify installed package checksums. | + +`build` takes a platform **subcommand**, not a flag: `love`, `web`, `android`, +`ios`, `windows`, `macos`, `linux`, `steamos`. + +```sh +feather build linux +feather build android +``` + +## Commands that do not + +`feather run` and `feather watch` expect a live game process and a Feather +desktop app on the other end of the WebSocket. Neither exists on a build runner, +so they will not do anything useful there. Use them locally. + +## The release gate + +`doctor --production` is the check to fail a pipeline on. It fails when the +project is configured in a way that should not ship: + +- unsafe or development-oriented config, +- an embedded debug runtime in a release build, +- hot reload persisting to disk, +- Console exposed, +- replay and debug artifacts left in the tree, +- missing signing or upload dependencies for the target you asked about. + +```sh +feather doctor --production --json > doctor.json || { + echo "Feather production checks failed" + cat doctor.json + exit 1 +} +``` + +Add `--target ` to include build and upload dependency checks for that +platform, and `--release` with it for mobile release signing checks. + +For a security-focused report, `feather doctor --security --json` emits a +sterile report with no secrets in it — safe to archive as a build artifact. + +## Run non-interactively + +The CLI has an Ink-based interface for humans. In a pipeline it needs to be told +not to wait for anyone: + +- **Pass `--yes` to anything destructive.** These refuse to proceed without it + when there is no TTY, rather than guessing: + + | Command | Without `--yes` | + | --- | --- | + | `feather upload` | Refuses, and requires an explicit target | + | `feather remove` | Refuses to remove Feather files | + | `feather package remove` | Refuses | + | `feather plugin remove` | Refuses | + + That refusal is deliberate. It is what stops a pipeline silently deleting a + runtime or a dependency. + +- **`package install` has its own gate.** Installing an experimental or + unreviewed source needs `--allow-untrusted` non-interactively, and repairing + lockfile entries does too. A pipeline that hits this should be fixed by + reviewing the package, not by adding the flag reflexively. +- **Prefer `--json` where it exists** (`doctor`, `upload`, `init`) so a step can + parse the result instead of scraping human output. +- **`package install --dry-run`** reports what would change without writing, which + is useful as a drift check on a pull request. + +## A worked example + +```yaml +name: Ship + +on: + push: + tags: ["game-v*"] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install LÖVE + run: sudo apt-get update && sudo apt-get install -y love xvfb + + - name: Install Feather + run: | + curl -fsSL -o /usr/local/bin/feather \ + https://github.com/Kyonru/feather/releases/download/v4.0.0/feather-cli-linux-x64-4.0.0.bin + chmod +x /usr/local/bin/feather + + - name: Restore Love2D dependencies + run: feather package install + + - name: Verify package checksums + run: feather package audit + + - name: Production checks + run: feather doctor --production --target linux --json + + - name: Build + run: feather build linux +``` diff --git a/docs/cli.md b/apps/docs/cli.md similarity index 100% rename from docs/cli.md rename to apps/docs/cli.md diff --git a/docs/configuration.md b/apps/docs/configuration.md similarity index 100% rename from docs/configuration.md rename to apps/docs/configuration.md diff --git a/apps/docs/console.md b/apps/docs/console.md new file mode 120000 index 00000000..2dcdaa45 --- /dev/null +++ b/apps/docs/console.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/console/README.md \ No newline at end of file diff --git a/docs/debugger.md b/apps/docs/debugger.md similarity index 100% rename from docs/debugger.md rename to apps/docs/debugger.md diff --git a/apps/docs/hot-reload.md b/apps/docs/hot-reload.md new file mode 120000 index 00000000..fa1ab395 --- /dev/null +++ b/apps/docs/hot-reload.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/hot-reload/README.md \ No newline at end of file diff --git a/docs/images/assets.png b/apps/docs/images/assets.png similarity index 100% rename from docs/images/assets.png rename to apps/docs/images/assets.png diff --git a/docs/images/debugger.png b/apps/docs/images/debugger.png similarity index 100% rename from docs/images/debugger.png rename to apps/docs/images/debugger.png diff --git a/docs/images/logs.png b/apps/docs/images/logs.png similarity index 100% rename from docs/images/logs.png rename to apps/docs/images/logs.png diff --git a/docs/images/observable.png b/apps/docs/images/observable.png similarity index 100% rename from docs/images/observable.png rename to apps/docs/images/observable.png diff --git a/docs/images/performance.png b/apps/docs/images/performance.png similarity index 100% rename from docs/images/performance.png rename to apps/docs/images/performance.png diff --git a/docs/index.md b/apps/docs/index.md similarity index 100% rename from docs/index.md rename to apps/docs/index.md diff --git a/docs/installation.md b/apps/docs/installation.md similarity index 100% rename from docs/installation.md rename to apps/docs/installation.md diff --git a/docs/logs.md b/apps/docs/logs.md similarity index 100% rename from docs/logs.md rename to apps/docs/logs.md diff --git a/docs/mcp.md b/apps/docs/mcp.md similarity index 100% rename from docs/mcp.md rename to apps/docs/mcp.md diff --git a/docs/observability.md b/apps/docs/observability.md similarity index 100% rename from docs/observability.md rename to apps/docs/observability.md diff --git a/apps/docs/packages.md b/apps/docs/packages.md new file mode 120000 index 00000000..67f1b844 --- /dev/null +++ b/apps/docs/packages.md @@ -0,0 +1 @@ +../../catalog/packages/README.md \ No newline at end of file diff --git a/apps/docs/particle-system-playground.md b/apps/docs/particle-system-playground.md new file mode 120000 index 00000000..759ac2ae --- /dev/null +++ b/apps/docs/particle-system-playground.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/particle-system-playground/README.md \ No newline at end of file diff --git a/docs/performance.md b/apps/docs/performance.md similarity index 100% rename from docs/performance.md rename to apps/docs/performance.md diff --git a/apps/docs/plugins-ui.md b/apps/docs/plugins-ui.md new file mode 120000 index 00000000..7601a6ed --- /dev/null +++ b/apps/docs/plugins-ui.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/plugins-ui.md \ No newline at end of file diff --git a/apps/docs/plugins.md b/apps/docs/plugins.md new file mode 120000 index 00000000..684c85ff --- /dev/null +++ b/apps/docs/plugins.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/README.md \ No newline at end of file diff --git a/apps/docs/plugins/animation-inspector.md b/apps/docs/plugins/animation-inspector.md new file mode 120000 index 00000000..64ebc956 --- /dev/null +++ b/apps/docs/plugins/animation-inspector.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/animation-inspector/README.md \ No newline at end of file diff --git a/apps/docs/plugins/audio-debug.md b/apps/docs/plugins/audio-debug.md new file mode 120000 index 00000000..d6c2d49e --- /dev/null +++ b/apps/docs/plugins/audio-debug.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/audio-debug/README.md \ No newline at end of file diff --git a/apps/docs/plugins/bookmark.md b/apps/docs/plugins/bookmark.md new file mode 120000 index 00000000..d646f6b6 --- /dev/null +++ b/apps/docs/plugins/bookmark.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/bookmark/README.md \ No newline at end of file diff --git a/apps/docs/plugins/collision-debug.md b/apps/docs/plugins/collision-debug.md new file mode 120000 index 00000000..5be5ea58 --- /dev/null +++ b/apps/docs/plugins/collision-debug.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/collision-debug/README.md \ No newline at end of file diff --git a/apps/docs/plugins/config-tweaker.md b/apps/docs/plugins/config-tweaker.md new file mode 120000 index 00000000..16f28cda --- /dev/null +++ b/apps/docs/plugins/config-tweaker.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/config-tweaker/README.md \ No newline at end of file diff --git a/apps/docs/plugins/coroutine-monitor.md b/apps/docs/plugins/coroutine-monitor.md new file mode 120000 index 00000000..0a21867b --- /dev/null +++ b/apps/docs/plugins/coroutine-monitor.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/coroutine-monitor/README.md \ No newline at end of file diff --git a/apps/docs/plugins/entity-inspector.md b/apps/docs/plugins/entity-inspector.md new file mode 120000 index 00000000..1a3cc600 --- /dev/null +++ b/apps/docs/plugins/entity-inspector.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/entity-inspector/README.md \ No newline at end of file diff --git a/apps/docs/plugins/feel-inspector.md b/apps/docs/plugins/feel-inspector.md new file mode 120000 index 00000000..63ec6900 --- /dev/null +++ b/apps/docs/plugins/feel-inspector.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/feel-inspector/README.md \ No newline at end of file diff --git a/apps/docs/plugins/filesystem.md b/apps/docs/plugins/filesystem.md new file mode 120000 index 00000000..18737929 --- /dev/null +++ b/apps/docs/plugins/filesystem.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/filesystem/README.md \ No newline at end of file diff --git a/apps/docs/plugins/hump-signal.md b/apps/docs/plugins/hump-signal.md new file mode 120000 index 00000000..18235414 --- /dev/null +++ b/apps/docs/plugins/hump-signal.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/hump/signal/README.md \ No newline at end of file diff --git a/apps/docs/plugins/ingame-overlay.md b/apps/docs/plugins/ingame-overlay.md new file mode 120000 index 00000000..87c32efd --- /dev/null +++ b/apps/docs/plugins/ingame-overlay.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/ingame-overlay/README.md \ No newline at end of file diff --git a/apps/docs/plugins/input-replay.md b/apps/docs/plugins/input-replay.md new file mode 120000 index 00000000..5cd9c3f9 --- /dev/null +++ b/apps/docs/plugins/input-replay.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/input-replay/README.md \ No newline at end of file diff --git a/apps/docs/plugins/lua-state-machine.md b/apps/docs/plugins/lua-state-machine.md new file mode 120000 index 00000000..7aa2d0e0 --- /dev/null +++ b/apps/docs/plugins/lua-state-machine.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/lua-state-machine/README.md \ No newline at end of file diff --git a/apps/docs/plugins/memory-snapshot.md b/apps/docs/plugins/memory-snapshot.md new file mode 120000 index 00000000..fd872e4d --- /dev/null +++ b/apps/docs/plugins/memory-snapshot.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/memory-snapshot/README.md \ No newline at end of file diff --git a/apps/docs/plugins/network-inspector.md b/apps/docs/plugins/network-inspector.md new file mode 120000 index 00000000..53f29293 --- /dev/null +++ b/apps/docs/plugins/network-inspector.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/network-inspector/README.md \ No newline at end of file diff --git a/apps/docs/plugins/particle-editor.md b/apps/docs/plugins/particle-editor.md new file mode 120000 index 00000000..2dd87311 --- /dev/null +++ b/apps/docs/plugins/particle-editor.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/particle-editor/README.md \ No newline at end of file diff --git a/apps/docs/plugins/physics-debug.md b/apps/docs/plugins/physics-debug.md new file mode 120000 index 00000000..3f272fab --- /dev/null +++ b/apps/docs/plugins/physics-debug.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/physics-debug/README.md \ No newline at end of file diff --git a/apps/docs/plugins/runtime-snapshot.md b/apps/docs/plugins/runtime-snapshot.md new file mode 120000 index 00000000..31ccd481 --- /dev/null +++ b/apps/docs/plugins/runtime-snapshot.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/runtime-snapshot/README.md \ No newline at end of file diff --git a/apps/docs/plugins/screenshots.md b/apps/docs/plugins/screenshots.md new file mode 120000 index 00000000..6a36bdb6 --- /dev/null +++ b/apps/docs/plugins/screenshots.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/screenshots/README.md \ No newline at end of file diff --git a/apps/docs/plugins/timer-inspector.md b/apps/docs/plugins/timer-inspector.md new file mode 120000 index 00000000..878f8ce4 --- /dev/null +++ b/apps/docs/plugins/timer-inspector.md @@ -0,0 +1 @@ +../../../packages/runtime-lua/plugins/timer-inspector/README.md \ No newline at end of file diff --git a/docs/profiler.md b/apps/docs/profiler.md similarity index 100% rename from docs/profiler.md rename to apps/docs/profiler.md diff --git a/docs/recommendations.md b/apps/docs/recommendations.md similarity index 100% rename from docs/recommendations.md rename to apps/docs/recommendations.md diff --git a/apps/docs/session-replay.md b/apps/docs/session-replay.md new file mode 120000 index 00000000..f3b714aa --- /dev/null +++ b/apps/docs/session-replay.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/session-replay/README.md \ No newline at end of file diff --git a/docs/session.md b/apps/docs/session.md similarity index 100% rename from docs/session.md rename to apps/docs/session.md diff --git a/apps/docs/shader-graph.md b/apps/docs/shader-graph.md new file mode 120000 index 00000000..4726ef09 --- /dev/null +++ b/apps/docs/shader-graph.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/shader-graph/README.md \ No newline at end of file diff --git a/docs/skills.md b/apps/docs/skills.md similarity index 100% rename from docs/skills.md rename to apps/docs/skills.md diff --git a/docs/standalone-showcase.md b/apps/docs/standalone-showcase.md similarity index 100% rename from docs/standalone-showcase.md rename to apps/docs/standalone-showcase.md diff --git a/apps/docs/studio.md b/apps/docs/studio.md new file mode 100644 index 00000000..5b814f12 --- /dev/null +++ b/apps/docs/studio.md @@ -0,0 +1,82 @@ +# Feather Studio + +Feather Studio is the creative half of Feather: the shader graph, the texture +lab and the particle playground. It is a **separate application** from Feather +Inspector, with its own installer, its own updates and its own version. + +## Why it is separate + +Inspector is a debugger. It attaches to a running game, streams logs and +performance, and holds a live session. Studio is an editor. It makes shaders, +textures and particle systems, and none of that needs a game running at all. + +Keeping them apart means a Studio update does not make you reinstall your +debugger, and a debugger fix does not touch your creative tools. It also means +you can install only what you use. + +## Installing + +Studio and Inspector install independently. Neither requires the other. + +- **Feather Inspector** — from a `v*` release on the + [releases page](https://github.com/Kyonru/feather/releases). +- **Feather Studio** — from a `studio-v*` release on the same page. + +Studio also runs in a browser with no install at all, at +[the showcase](https://kyonru.github.io/feather/standalone-showcase/). The +browser build is the same tools, minus anything that needs a local game. + +## Using Studio on its own + +This is the normal case, and everything works: + +- author shader graphs, generate textures, build particle systems +- save and load projects from disk +- keep workspaces, saved recipes and timeline settings + +Studio says *"Not connected"* when no Inspector is running. That is a state, not +an error. + +## Pairing with Inspector + +Connecting lets you push work straight into a running game — a shader, a +particle system, a texture — and see it live. + +1. Start Feather Inspector and attach your game as usual. +2. In Studio, choose **Connect to Feather Inspector**. + +Pairing is explicit. Studio never searches for Inspector on its own. + +### What pairing does + +- **Version check first.** The two applications release separately, so they + negotiate before anything else. If they cannot agree, Studio says which one to + update rather than failing later in a confusing way. +- **A short-lived key.** Inspector issues a capability that Studio presents on + every request. It expires, and Inspector can revoke it. +- **Local only.** The connection is loopback; nothing is reachable from your + network. + +### Moving your settings across + +If you used the creative tools when they lived inside Inspector, your texture +recipes, saved workspaces and timeline settings are still there. The first time +you pair, Studio offers to bring them over. + +The copy in Inspector is **not deleted**. If anything goes wrong you still have +it, and you can pair again later — importing twice is harmless. + +If you never pair, Studio simply starts empty. + +## Agents and MCP + +There is one Feather MCP server, not two. `feather mcp` serves both +applications: session and debugging tools come from Inspector, creative tools +from Studio. Configure it once. + +If an application is not running, its tools say so by name rather than failing +with a connection error. + +```sh +feather mcp setup --client claude +``` diff --git a/docs/texture-lab.md b/apps/docs/texture-lab.md similarity index 100% rename from docs/texture-lab.md rename to apps/docs/texture-lab.md diff --git a/apps/docs/time-travel.md b/apps/docs/time-travel.md new file mode 120000 index 00000000..2e5a778c --- /dev/null +++ b/apps/docs/time-travel.md @@ -0,0 +1 @@ +../../packages/runtime-lua/plugins/time-travel/README.md \ No newline at end of file diff --git a/docs/usage.md b/apps/docs/usage.md similarity index 100% rename from docs/usage.md rename to apps/docs/usage.md diff --git a/docs/vscode-extension.md b/apps/docs/vscode-extension.md similarity index 100% rename from docs/vscode-extension.md rename to apps/docs/vscode-extension.md diff --git a/e2e/app.spec.ts b/apps/inspector/e2e/app.spec.ts similarity index 67% rename from e2e/app.spec.ts rename to apps/inspector/e2e/app.spec.ts index 1615b965..6d7a613e 100644 --- a/e2e/app.spec.ts +++ b/apps/inspector/e2e/app.spec.ts @@ -1,313 +1,20 @@ -import { expect, test, type Locator, type Page } from '@playwright/test'; -import { shaderPreviewTextureFiles, textureHeavyPreviewGraph } from './helpers/shader-preview-fixture'; +import { expect, test, type Page } from '@playwright/test'; const NARROW_VIEWPORT = { width: 900, height: 720 }; -async function openShaderOutput(page: Page) { - await page.getByRole('tab', { name: 'Output' }).click(); -} -async function openShaderControls(page: Page) { - await page.getByRole('tab', { name: 'Controls' }).click(); -} -async function dragLocatorBy(page: Page, locator: Locator, dx: number, dy = 0) { - const box = await locator.boundingBox(); - expect(box).not.toBeNull(); - await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); - await page.mouse.down(); - await page.mouse.move(box!.x + box!.width / 2 + dx, box!.y + box!.height / 2 + dy, { steps: 6 }); - await page.mouse.up(); -} -async function keepNotificationsFromBlockingPointers(page: Page) { - await page.evaluate(() => { - const win = window as Window & { __FEATHER_E2E_NONBLOCKING_NOTIFICATIONS__?: boolean }; - if (win.__FEATHER_E2E_NONBLOCKING_NOTIFICATIONS__) return; - win.__FEATHER_E2E_NONBLOCKING_NOTIFICATIONS__ = true; - const apply = () => { - for (const element of document.querySelectorAll('[data-sonner-toaster], [data-sonner-toaster] *')) { - if (element instanceof HTMLElement) element.style.pointerEvents = 'none'; - } - }; - apply(); - new MutationObserver(apply).observe(document.body, { childList: true, subtree: true }); - }); -} - -async function waitForTimelineSelection(locator: Locator, selectedCount: number) { - await expect - .poll(() => - locator.evaluateAll((items) => items.filter((item) => (item as HTMLElement).dataset.selected === 'true').length), - ) - .toBe(selectedCount); -} -async function selectTimelineClipGroup(track: Locator, trackLabel: string, clips: Locator, additionalClip: Locator) { - await track.getByText(trackLabel).click(); - await waitForTimelineSelection(clips, 1); - await additionalClip.evaluate((element, modifier) => { - element.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - cancelable: true, - button: 0, - ctrlKey: modifier === 'Control', - metaKey: modifier === 'Meta', - }), - ); - }, multiSelectModifier()); - await waitForTimelineSelection(clips, 2); -} -async function dragLocatorToX(page: Page, locator: Locator, x: number) { - const box = await locator.boundingBox(); - expect(box).not.toBeNull(); - await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); - await page.mouse.down(); - await page.mouse.move(x, box!.y + box!.height / 2, { steps: 6 }); - await page.mouse.up(); -} -type TimelineClipTimes = { start: number; end: number }; -async function readTimelineClipTimes(locator: Locator): Promise { - return locator.evaluateAll((items) => - items.map((item) => ({ - start: Number((item as HTMLElement).dataset.clipStart), - end: Number((item as HTMLElement).dataset.clipEnd), - })), - ); -} -async function expectTimelineClipTimes( - locator: Locator, - predicate: (clips: TimelineClipTimes[]) => boolean, -): Promise { - await expect - .poll(async () => { - const clips = await readTimelineClipTimes(locator); - return predicate(clips) ? 'ready' : JSON.stringify(clips); - }) - .toBe('ready'); - return readTimelineClipTimes(locator); -} -function multiSelectModifier(): 'Control' | 'Meta' { - // @ts-expect-error process not defined - return process.platform === 'darwin' ? 'Meta' : 'Control'; -} -async function uploadShaderPreviewTexture( - page: Page, - trigger: Locator, - // @ts-expect-error Buffer not defined - file: { name: string; mimeType: string; buffer: Buffer }, -) { - const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 1000 }).catch(() => null); - await trigger.click({ force: true }); - const fileChooser = await fileChooserPromise; - if (fileChooser) { - await fileChooser.setFiles(file); - } else { - if ((await page.getByTestId('shader-texture-upload-input').count()) === 0) { - await trigger.evaluate((element) => (element as HTMLElement).click()); - } - await page.getByTestId('shader-texture-upload-input').last().setInputFiles(file); - } -} -async function particlePreviewFrame(page: Page) { - const iframe = page.locator('iframe[title="Particle Preview"]').first(); - await expect(page.frameLocator('iframe[title="Particle Preview"]').locator('canvas')).toBeVisible(); - const handle = await iframe.elementHandle(); - const frame = await handle?.contentFrame(); - expect(frame).not.toBeNull(); - return frame!; -} -async function particlePreviewStatus(page: Page) { - const frame = await particlePreviewFrame(page); - return frame.evaluate(() => { - type StatusLike = { - time?: number; - playing?: boolean; - mode?: string; - particleCount?: number; - lastBurstCount?: number; - }; - type PayloadLike = { - composite?: { timelineState?: { time?: number; playing?: boolean }; timeline?: { mode?: string } }; - }; - const status = (window as Window & { _featherParticlePreviewStatus?: StatusLike })._featherParticlePreviewStatus; - const payload = (window as Window & { _featherPayload?: PayloadLike })._featherPayload; - return { - time: status?.time ?? payload?.composite?.timelineState?.time, - playing: status?.playing ?? payload?.composite?.timelineState?.playing, - mode: status?.mode ?? payload?.composite?.timeline?.mode, - particleCount: status?.particleCount ?? 0, - lastBurstCount: status?.lastBurstCount ?? 0, - }; - }); -} -async function expectTextureProbePayload(page: Page) { - const probe = page.locator('.react-flow__node').filter({ hasText: 'Texture Probe' }); - const iframe = probe.locator('iframe[title="Texture Probe love.js preview"]'); - await expect(probe.frameLocator('iframe[title="Texture Probe love.js preview"]').locator('canvas')).toBeVisible(); - const frameHandle = await iframe.elementHandle(); - const frame = await frameHandle?.contentFrame(); - expect(frame).not.toBeNull(); - await expect - .poll( - async () => - frame!.evaluate(() => { - type UploadLike = { dataBase64?: string; dataKey?: string }; - type PayloadLike = { - tool?: unknown; - pixel?: string; - baseTexture?: UploadLike; - textures?: UploadLike[]; - textureUniforms?: unknown[]; - }; - type StatusLike = { textureCount?: number; error?: string }; - const payload = (window as Window & { _featherPayload?: PayloadLike })._featherPayload; - const status = (window as Window & { _featherShaderPreviewStatus?: StatusLike })._featherShaderPreviewStatus; - return { - tool: payload?.tool, - hasPixel: - typeof payload?.pixel === 'string' && - payload.pixel.includes('noiseTexture') && - payload.pixel.includes('maskTexture'), - hasBaseTexture: Boolean(payload?.baseTexture?.dataBase64 || payload?.baseTexture?.dataKey), - baseTextureBytes: - payload?.baseTexture?.dataBase64?.length || - (payload?.baseTexture?.dataKey && - (window as Window & { _featherUploadCache?: Record })._featherUploadCache?.[ - payload.baseTexture.dataKey - ]?.length) || - (payload?.baseTexture?.dataKey && - (window.parent as Window & { __featherPreviewUploadCache?: Record }) - .__featherPreviewUploadCache?.[payload.baseTexture.dataKey]?.length) || - 0, - textureCount: Array.isArray(payload?.textures) - ? payload.textures.filter((texture) => texture?.dataBase64 || texture?.dataKey).length - : 0, - textureBytes: Array.isArray(payload?.textures) - ? payload.textures.map( - (texture) => - texture?.dataBase64?.length || - (texture?.dataKey && - (window as Window & { _featherUploadCache?: Record })._featherUploadCache?.[ - texture.dataKey - ]?.length) || - (texture?.dataKey && - (window.parent as Window & { __featherPreviewUploadCache?: Record }) - .__featherPreviewUploadCache?.[texture.dataKey]?.length) || - 0, - ) - : [], - uniformCount: Array.isArray(payload?.textureUniforms) ? payload.textureUniforms.length : 0, - runtimeTextureCount: typeof status?.textureCount === 'number' ? status.textureCount : 2, - runtimeError: status?.error ?? '', - }; - }), - { timeout: 10_000 }, - ) - .toMatchObject({ - tool: 'shader-graph', - hasPixel: true, - hasBaseTexture: true, - baseTextureBytes: expect.any(Number), - textureCount: 2, - textureBytes: [expect.any(Number), expect.any(Number)], - uniformCount: 2, - runtimeTextureCount: 2, - runtimeError: '', - }); - const payloadStats = await frame!.evaluate(() => { - type UploadLike = { dataBase64?: string; dataKey?: string }; - type PayloadLike = { baseTexture?: UploadLike; textures?: UploadLike[] }; - const payload = (window as Window & { _featherPayload?: PayloadLike })._featherPayload; - const iframeCache = (window as Window & { _featherUploadCache?: Record })._featherUploadCache; - const parentCache = (window.parent as Window & { __featherPreviewUploadCache?: Record }) - .__featherPreviewUploadCache; - return { - baseTextureBytes: - payload?.baseTexture?.dataBase64?.length || - (payload?.baseTexture?.dataKey && iframeCache?.[payload.baseTexture.dataKey]?.length) || - (payload?.baseTexture?.dataKey && parentCache?.[payload.baseTexture.dataKey]?.length) || - 0, - textureBytes: Array.isArray(payload?.textures) - ? payload.textures.map( - (texture) => - texture?.dataBase64?.length || - (texture?.dataKey && iframeCache?.[texture.dataKey]?.length) || - (texture?.dataKey && parentCache?.[texture.dataKey]?.length) || - 0, - ) - : [], - }; - }); - expect(payloadStats.baseTextureBytes).toBeGreaterThan(0); - expect(payloadStats.textureBytes.every((length) => length > 0)).toBe(true); - await expect - .poll( - async () => - frame!.evaluate(async () => { - const canvas = document.querySelector('canvas') as HTMLCanvasElement | null; - if (!canvas || canvas.width === 0 || canvas.height === 0) return { colorBuckets: 0, texturedBuckets: 0 }; - const image = new Image(); - const loaded = new Promise((resolve, reject) => { - image.onload = () => resolve(image); - image.onerror = reject; - }); - image.src = canvas.toDataURL('image/png'); - await loaded; - const sampleCanvas = document.createElement('canvas'); - const width = Math.min(canvas.width, 96); - const height = Math.min(canvas.height, 96); - sampleCanvas.width = width; - sampleCanvas.height = height; - const context = sampleCanvas.getContext('2d'); - if (!context) return { colorBuckets: 0, texturedBuckets: 0 }; - context.drawImage( - image, - Math.max(0, Math.floor((canvas.width - width) / 2)), - Math.max(0, Math.floor((canvas.height - height) / 2)), - width, - height, - 0, - 0, - width, - height, - ); - const pixels = context.getImageData(0, 0, width, height).data; - const buckets = new Set(); - const textured = new Set(); - for (let index = 0; index < pixels.length; index += 4) { - const r = pixels[index]; - const g = pixels[index + 1]; - const b = pixels[index + 2]; - if (r + g + b < 48) continue; - const bucket = `${r >> 4}:${g >> 4}:${b >> 4}`; - buckets.add(bucket); - if (Math.abs(r - g) > 16 || Math.abs(g - b) > 16 || Math.abs(r - b) > 16) textured.add(bucket); - } - return { - colorBuckets: buckets.size, - texturedBuckets: textured.size, - textureVisible: buckets.size > 6 && textured.size > 4, - }; - }), - { timeout: 10_000 }, - ) - .toMatchObject({ - colorBuckets: expect.any(Number), - texturedBuckets: expect.any(Number), - textureVisible: true, - }); -} async function seedNoSession(page: Page) { await page.addInitScript(() => { @@ -758,8 +465,8 @@ async function seedPartialSessionConfig(page: Page) { }); } -async function seedHealthySessionConfig(page: Page) { - await page.evaluate(() => { +async function seedHealthySessionConfig(page: Page, overrides: Record = {}) { + await page.evaluate((extra) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const client = (window as any).__FEATHER_QUERY_CLIENT__; client?.setQueryData(['demo', 'config'], { @@ -772,7 +479,12 @@ async function seedHealthySessionConfig(page: Page) { }, root_path: '/tmp/demo', sourceDir: '/tmp/demo', - version: '4.0.0', + // Deliberately NOT the desktop app version. Under v2 the Lua runtime and + // the desktop app ship on separate release trains (V4.md section 6), so + // they are expected to differ. Keeping them different here asserts that + // runtime/desktop version drift alone does not degrade session health — + // API is the compatibility signal, not string equality. + version: '3.9.0', API: 5, sampleRate: 1, outfile: '', @@ -794,159 +506,12 @@ async function seedHealthySessionConfig(page: Page) { failedModules: [], }, }, + ...extra, }); - }); + }, overrides); } -async function seedShaderGraphConfig(page: Page) { - await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const client = (window as any).__FEATHER_QUERY_CLIENT__; - client?.setQueryData(['demo', 'config'], { - plugins: { - 'shader-graph': { - tabName: 'Shader Graph', - icon: 'blend', - capabilities: [], - }, - }, - root_path: '/tmp/demo', - sourceDir: '/tmp/demo', - version: '2.0.0', - API: 5, - sampleRate: 1, - outfile: '', - language: 'lua', - captureScreenshot: false, - location: '/tmp/demo', - sessionName: 'Demo Session', - }); - }); -} -async function seedParticlePlaygroundConfig(page: Page, sessionId = 'demo') { - await page.evaluate((targetSession) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const client = (window as any).__FEATHER_QUERY_CLIENT__; - client?.setQueryData([targetSession, 'config'], { - plugins: { - 'particle-system-playground': { - tabName: 'Particles Playground', - icon: 'sparkles', - capabilities: [], - }, - }, - root_path: '/tmp/demo', - sourceDir: '/tmp/demo', - version: '2.0.0', - API: 5, - sampleRate: 1, - outfile: '', - language: 'lua', - captureScreenshot: false, - location: '/tmp/demo', - sessionName: 'Demo Session', - }); - client?.setQueryData([targetSession, 'plugin', 'particle-system-playground'], { - type: 'particle-system-playground', - composites: ['Demo Particles'], - activeComposite: 'Demo Particles', - activeSystem: 1, - data: { - compositeType: 'scratch', - x: 400, - y: 300, - previewEnabled: true, - movement: { pattern: 'none', radius: 80, radiusX: 120, radiusY: 60, speed: 1, scale: 1 }, - systems: [ - { - index: 1, - title: 'Fire', - blendMode: 'add', - enabled: true, - x: 0, - y: 0, - kickStartSteps: 0, - kickStartDt: 1 / 60, - emitAtStart: 24, - texturePath: '', - texturePreset: 'circle', - textureFilename: 'circle.png', - shaderPath: '', - shaderFilename: '', - shaderSource: '', - exportReady: true, - properties: { - emissionRate: 100, - emitterLifetime: 0.8, - particleLifetimeMin: 0.35, - particleLifetimeMax: 1.3, - direction: -Math.PI / 2, - spread: Math.PI / 3, - speedMin: 40, - speedMax: 140, - sizes: '1, 0', - offsetX: 0, - offsetY: 0, - count: 0, - bufferSize: 1000, - }, - }, - { - index: 2, - title: 'Smoke', - blendMode: 'alpha', - enabled: true, - x: 0, - y: 0, - kickStartSteps: 0, - kickStartDt: 1 / 60, - emitAtStart: 12, - texturePath: '', - texturePreset: 'light', - textureFilename: 'light.png', - shaderPath: '', - shaderFilename: '', - shaderSource: '', - exportReady: true, - properties: { - emissionRate: 60, - emitterLifetime: -1, - particleLifetimeMin: 0.5, - particleLifetimeMax: 1.6, - direction: -Math.PI / 2, - spread: Math.PI / 2, - speedMin: 20, - speedMax: 90, - sizes: '1, 0', - offsetX: 0, - offsetY: 0, - count: 0, - bufferSize: 1000, - }, - }, - ], - timeline: { - duration: 3, - loop: true, - tracks: [ - { - systemIndex: 1, - clips: [{ id: 'clip-1', start: 0, end: 3, emit: 24 }], - lanes: {}, - }, - { - systemIndex: 2, - clips: [{ id: 'clip-2', start: 0, end: 3, emit: 12 }], - lanes: {}, - }, - ], - }, - timelineState: { time: 0, playing: false, scrubVersion: 0 }, - }, - }); - }, sessionId); -} async function seedMissingProfilerConfig(page: Page) { await page.evaluate(() => { @@ -1715,244 +1280,8 @@ test('shows no-session empty state and opens settings', async ({ page }) => { await expect(page.getByLabel('Connection Timeout (seconds)')).toHaveValue('15'); }); -test('texture lab is available without a connected session in the app', async ({ page }) => { - await seedNoSession(page); - await page.goto('/texture-lab'); - - await expect(page.getByRole('heading', { name: 'Texture Lab' })).toBeVisible(); - await expect(page.getByTestId('texture-lab-page')).toHaveCSS('overflow', 'hidden'); - await expect(page.getByTestId('texture-lab-controls-panel')).toHaveCSS('overflow-y', 'auto'); - await expect(page.getByTestId('texture-lab-main-panel')).toHaveCSS('overflow-y', 'auto'); - const textureHeader = page.getByTestId('texture-lab-page').locator('header'); - await expect(textureHeader.getByRole('button', { name: /reset values/i })).toBeVisible(); - await expect(textureHeader.getByRole('button', { name: /regenerate/i })).toBeVisible(); - await expect(textureHeader.getByRole('button', { name: /export png/i })).toBeVisible(); - await expect(textureHeader.getByRole('button', { name: /use as shader preview/i })).toBeVisible(); - await expect(page.getByLabel('Texture background color')).toHaveValue('#000000'); - await expect(page.getByLabel('Texture background alpha')).toHaveValue('0'); - const preview = page.getByTestId('texture-lab-preview'); - await expect(preview).toBeVisible(); - await page.getByLabel('Texture color ramp').click(); - await page.getByRole('option', { name: 'Solid Color' }).click(); - await expect(page.getByLabel('Texture solid color')).toHaveValue('#ffffff'); - const solidBefore = await preview.getAttribute('src'); - await page.getByLabel('Texture solid color').fill('#ff3366'); - await expect(page.getByLabel('Texture solid color')).toHaveValue('#ff3366'); - await expect.poll(() => preview.getAttribute('src')).not.toBe(solidBefore); - await page.getByLabel('Saved recipe name').fill('Blue Spark'); - await page.getByRole('button', { name: 'Save recipe' }).click(); - await expect(page.getByRole('button', { name: 'Load saved recipe Blue Spark' })).toBeVisible(); - await page.getByLabel('Texture generator').click(); - await page.getByRole('option', { name: 'Cloud Noise' }).click(); - await expect(page.getByLabel('Texture generator')).toHaveText(/Cloud Noise/); - const cloudPreview = await preview.getAttribute('src'); - await page.getByLabel('Texture generator').click(); - await page.getByRole('option', { name: 'Normal From Height' }).click(); - await expect(page.getByLabel('Texture generator')).toHaveText(/Normal From Height/); - await expect(page.getByLabel('Texture alpha mode')).toHaveText(/Opaque/); - await expect.poll(() => preview.getAttribute('src')).not.toBe(cloudPreview); - await page.getByLabel('Texture generator').click(); - await page.getByRole('option', { name: 'Image Luminance Mask' }).click(); - await expect(page.getByTestId('texture-lab-image-mask')).toBeVisible(); - const imageMaskBefore = await preview.getAttribute('src'); - await page.getByTestId('texture-lab-image-mask-input').setInputFiles(shaderPreviewTextureFiles().water); - await expect(page.getByTestId('texture-lab-image-mask').getByText(/water\.png/)).toBeVisible(); - await expect.poll(() => preview.getAttribute('src')).not.toBe(imageMaskBefore); - const imageMaskPreview = await preview.getAttribute('src'); - await page.getByLabel('Texture generator').click(); - await page.getByRole('option', { name: 'Soft Outline' }).click(); - await expect(page.getByLabel('Texture generator')).toHaveText(/Soft Outline/); - await expect(page.getByLabel('Texture alpha mode')).toHaveText(/Shape/); - await expect.poll(() => preview.getAttribute('src')).not.toBe(imageMaskPreview); - await page.getByRole('button', { name: 'Load saved recipe Blue Spark' }).click(); - await expect(page.getByLabel('Texture generator')).toHaveText(/Soft Circle/); - await expect(page.getByLabel('Texture solid color')).toHaveValue('#ff3366'); - await textureHeader.getByRole('button', { name: /create atlas/i }).click(); - const atlasPanel = page.getByTestId('texture-lab-atlas-panel'); - await expect(atlasPanel).toBeVisible(); - await expect(page.getByTestId('texture-lab-atlas-sheet')).toBeVisible(); - await expect(page.getByTestId('texture-lab-atlas-frame-grid')).toBeVisible(); - await expect(textureHeader.getByRole('button', { name: /export atlas zip/i })).toBeVisible(); - await page.getByLabel('Seeded fill preset').click(); - await page.getByRole('option', { name: 'Smoke Variants' }).click(); - await page.getByRole('button', { name: /fill frames/i }).click(); - await expect(page.getByLabel('Texture generator')).toHaveText(/Smoke Puff/); - await expect(page.getByLabel('Atlas particle playback')).toHaveText(/Variants/); - await expect(page.getByRole('button', { name: /replace frame/i })).toBeVisible(); - await page.getByRole('button', { name: 'Select atlas frame 2' }).click(); - await expect(page.getByTestId('texture-lab-onion-past')).toBeVisible(); - await expect(page.getByTestId('texture-lab-onion-future')).toBeVisible(); - const selectedAtlasFramePreview = page.locator('img[alt="Selected atlas frame"]'); - const atlasFrameBeforeEdit = await selectedAtlasFramePreview.getAttribute('src'); - await page.getByLabel('Texture softness').fill('0.12'); - await expect.poll(() => selectedAtlasFramePreview.getAttribute('src')).not.toBe(atlasFrameBeforeEdit); - await page.getByTestId('texture-lab-custom-frame-input').setInputFiles(shaderPreviewTextureFiles().water); - await expect(page.getByTestId('texture-lab-uploaded-frame-readonly')).toContainText(/replace-only/i); - await page.getByRole('button', { name: /copy selected to all/i }).click(); - const copyFramesDialog = page.getByRole('dialog', { name: /copy selected frame to all/i }); - await expect(copyFramesDialog).toBeVisible(); - await copyFramesDialog.getByRole('button', { name: /cancel/i }).click(); - await page.getByRole('button', { name: /empty all frames/i }).click(); - const emptyFramesDialog = page.getByRole('dialog', { name: /empty all atlas frames/i }); - await expect(emptyFramesDialog).toBeVisible(); - await emptyFramesDialog.getByRole('button', { name: /cancel/i }).click(); - await atlasPanel.getByLabel('Onion skin').click(); - await expect(page.getByTestId('texture-lab-onion-past')).toHaveCount(0); - await textureHeader.getByRole('button', { name: /exit atlas/i }).click(); - await expect(page.getByTestId('texture-lab-preview')).toBeVisible(); - await page.reload(); - await expect(page.getByRole('heading', { name: 'Texture Lab' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Load saved recipe Blue Spark' })).toBeVisible(); - await expect(textureHeader.getByRole('button', { name: /export png/i })).toBeVisible(); -}); - -test('texture lab spline and shape editors stay responsive in the app', async ({ page }) => { - await seedNoSession(page); - await page.goto('/texture-lab'); - - await expect(page.getByRole('heading', { name: 'Texture Lab' })).toBeVisible(); - const textureHeader = page.getByTestId('texture-lab-page').locator('header'); - const preview = page.getByTestId('texture-lab-preview'); - await expect(preview).toBeVisible(); - const before = await preview.getAttribute('src'); - await page.getByTitle('Randomize seed').click(); - await expect.poll(() => preview.getAttribute('src')).not.toBe(before); - await page.getByLabel('Texture generator').click(); - await page.getByRole('option', { name: 'Spline Trail' }).click(); - await expect(page.getByLabel('Texture seed')).toHaveValue('1337'); - await expect(page.getByTestId('texture-lab-spline-editor')).toBeVisible(); - await expect(page.getByLabel('Spline overlap resolution')).toHaveText(/Merge/); - const firstPointStyle = await page.getByTestId('texture-lab-spline-point-1').evaluate((point) => ({ - fill: point.getAttribute('fill'), - stroke: point.getAttribute('stroke'), - strokeWidth: point.getAttribute('stroke-width'), - })); - const secondPointStyle = await page.getByTestId('texture-lab-spline-point-2').evaluate((point) => ({ - fill: point.getAttribute('fill'), - stroke: point.getAttribute('stroke'), - strokeWidth: point.getAttribute('stroke-width'), - })); - expect(firstPointStyle).toEqual(secondPointStyle); - expect(firstPointStyle.stroke).toBeTruthy(); - expect(Number(firstPointStyle.strokeWidth)).toBeGreaterThan(0); - await page.getByTestId('texture-lab-spline-point-1').click(); - await expect - .poll(() => - page.getByTestId('texture-lab-spline-point-1').evaluate((point) => ({ - fill: point.getAttribute('fill'), - stroke: point.getAttribute('stroke'), - strokeWidth: point.getAttribute('stroke-width'), - })), - ) - .toEqual({ fill: '#facc15', stroke: '#111827', strokeWidth: '3' }); - const splineBefore = await preview.getAttribute('src'); - const splinePoint = await page.getByTestId('texture-lab-spline-point-1').boundingBox(); - expect(splinePoint).not.toBeNull(); - await page.mouse.move(splinePoint!.x + splinePoint!.width / 2, splinePoint!.y + splinePoint!.height / 2); - await page.mouse.down(); - await page.mouse.move(splinePoint!.x + splinePoint!.width / 2 + 28, splinePoint!.y + splinePoint!.height / 2 + 16); - await page.mouse.up(); - await expect.poll(() => preview.getAttribute('src')).not.toBe(splineBefore); - await page.getByTitle('Randomize seed').click(); - await page.getByRole('button', { name: 'Comet Tail' }).click(); - await expect(page.getByLabel('Texture seed')).toHaveValue('1337'); - await page.getByLabel('Texture generator').click(); - await page.getByRole('option', { name: 'Shapes & Polygons' }).click(); - await expect(page.getByTestId('texture-lab-shape-editor')).toBeVisible(); - await expect(page.getByTestId('texture-lab-shape-layer-stack')).toBeVisible(); - await expect(page.getByTestId('texture-lab-shape-move-handle')).toBeVisible(); - await expect(page.getByTestId('texture-lab-shape-size-handle')).toBeVisible(); - const shapeBeforePointEdit = await preview.getAttribute('src'); - const shapePoint = await page.getByTestId('texture-lab-shape-point-0').boundingBox(); - expect(shapePoint).not.toBeNull(); - await page.mouse.move(shapePoint!.x + shapePoint!.width / 2, shapePoint!.y + shapePoint!.height / 2); - await page.mouse.down(); - await page.mouse.move(shapePoint!.x + shapePoint!.width / 2 + 18, shapePoint!.y + shapePoint!.height / 2 + 12); - await page.mouse.up(); - await expect.poll(() => preview.getAttribute('src')).not.toBe(shapeBeforePointEdit); - await page.getByRole('button', { name: 'Rect' }).click(); - await expect(page.getByTitle('Disable Rect')).toBeVisible(); - const withRect = await preview.getAttribute('src'); - await page.getByTitle('Disable Rect').click(); - await expect.poll(() => preview.getAttribute('src')).not.toBe(withRect); - const beforeSplineLayer = await preview.getAttribute('src'); - await page.getByTestId('texture-lab-shape-layer-stack').getByRole('button', { name: 'Spline', exact: true }).click(); - await expect(page.getByTitle('Disable Spline')).toBeVisible(); - await expect.poll(() => preview.getAttribute('src')).not.toBe(beforeSplineLayer); - const shapeSplinePointLocator = page.getByTestId('texture-lab-shape-point-1'); - const shapeSplinePointBefore = await shapeSplinePointLocator.getAttribute('data-point'); - const shapeSplinePoint = await shapeSplinePointLocator.boundingBox(); - expect(shapeSplinePoint).not.toBeNull(); - await shapeSplinePointLocator.hover(); - await page.mouse.down(); - await page.mouse.move( - shapeSplinePoint!.x + shapeSplinePoint!.width / 2 + 40, - shapeSplinePoint!.y + shapeSplinePoint!.height / 2 + 30, - { - steps: 8, - }, - ); - await page.mouse.up(); - await expect.poll(() => shapeSplinePointLocator.getAttribute('data-point')).not.toBe(shapeSplinePointBefore); - await page.getByRole('button', { name: 'Scatter Dots' }).click(); - await expect(page.getByLabel('Shape repeat mode')).toHaveText(/Scatter/); - await textureHeader.getByRole('button', { name: /reset values/i }).click(); - await expect(page.getByLabel('Texture seed')).toHaveValue('1337'); - await page.getByRole('button', { name: /expand texture presets/i }).click(); - await expect(page.getByRole('button', { name: /smoke puff/i })).toBeVisible(); - await expect(textureHeader.getByRole('button', { name: /export png/i })).toBeVisible(); -}); - -test('creative sessions unlock local creative tools and persist as workspace tabs', async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('feather-e2e-query-client', '1'); - }); - await page.goto('/'); - - await page.getByTitle('Add session or workspace').click(); - await page.getByRole('menuitem', { name: 'New creative workspace' }).click(); - const dialog = page.getByRole('dialog', { name: 'New creative workspace' }); - await expect(dialog).toBeVisible(); - await dialog.getByLabel('Name').fill('Local FX'); - await dialog.getByRole('button', { name: 'Create' }).click(); - - await expect(page.getByRole('button', { name: /Local FX/ })).toBeVisible(); - await expect(page).toHaveURL(/\/shader-graph$/); - await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible(); - await expect(page.getByText('Local workspace')).toBeVisible(); - await expect(page.getByText('Shader Graph is disabled')).toHaveCount(0); - - await page - .getByTestId('sidebar-tool-particle-system-playground') - .getByRole('link', { name: 'Particles Playground' }) - .click(); - await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); - await expect(page.getByTestId('love-js-preview-floating')).toBeVisible(); - await particlePreviewFrame(page); - await expect(page.getByRole('button', { name: /show in game/i })).toHaveCount(0); - await page.getByRole('tab', { name: 'Timeline' }).click(); - await page.getByTitle('Play timeline').click(); - await expect.poll(async () => (await particlePreviewStatus(page)).time ?? 0, { timeout: 2500 }).toBeGreaterThan(0.05); - await expect - .poll(async () => - page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string } }) => - item.message?.action === 'timeline-control' || item.message?.action === 'runtime-preview', - ).length; - }), - ) - .toBe(0); - await page.getByTitle('Pause timeline').click(); - await page.goto('/'); - await expect(page.getByText('Select a session')).toBeVisible(); - await expect(page.getByText('Choose a game session from the header before opening Feather tools.')).toBeVisible(); - await page.reload(); - await expect(page.getByRole('button', { name: /Local FX/ })).toBeVisible(); -}); test('opens redesigned about modal from the sidebar', async ({ page }) => { await seedNoSession(page); @@ -1987,13 +1316,163 @@ test('sidebar groups pinned defaults without duplicating tools', async ({ page } await expect(core.getByText('Core')).toBeVisible(); await expect(core.getByTestId('sidebar-tool-compare')).toBeVisible(); await expect(page.getByTestId('sidebar-group-inspect').getByText('Inspect')).toBeVisible(); - await expect(page.getByTestId('sidebar-group-creative').getByText('Creative')).toBeVisible(); await expect(page.getByTestId('sidebar-group-history').getByText('History')).toBeVisible(); await expect(page.getByTestId('sidebar-tool-logs')).toHaveCount(1); await expect(page.getByTestId('sidebar-tool-performance')).toHaveCount(1); await expect(page.getByTestId('sidebar-tool-session')).toHaveCount(1); }); +test('typing in the log search survives a log the runtime sent without a message', async ({ page }) => { + // Reported crash: TypeError: undefined is not an object (evaluating + // 'log.str.toLowerCase') on the first keystroke. Logs are cast rather than + // parsed on the WebSocket path, so a runtime that omits `str` reaches the + // filter intact. + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + + await seedSession(page); + await page.addInitScript(() => { + const logs = [ + { id: 'ok', count: 1, time: Date.now(), type: 'output', str: 'player spawned', trace: '' }, + // The malformed one: no `str` at all. + { id: 'broken', count: 1, time: Date.now(), type: 'error', trace: '' }, + ]; + localStorage.setItem( + 'feather-log-history-v1', + JSON.stringify({ + state: { + logsBySession: { demo: { logs, label: 'Demo Session', updatedAt: Date.now() } }, + logsByHistoryKey: { 'session:demo': { logs, label: 'Demo Session', updatedAt: Date.now() } }, + sessionHistoryKeys: { demo: ['session:demo'] }, + }, + version: 0, + }), + ); + }); + await page.goto('/'); + await page.getByRole('button', { name: /Demo Session/ }).click(); + + // The malformed log has to actually be on screen, or this test proves nothing. + await expect(page.getByText('player spawned')).toBeVisible(); + + await page.getByTestId('logs-toolbar-search').getByRole('textbox').fill('player'); + await page.waitForTimeout(300); + + expect(pageErrors, `page threw while searching:\n${pageErrors.join('\n')}`).toEqual([]); + await expect(page.getByText('player spawned')).toBeVisible(); +}); + +test('settings are findable from the command palette', async ({ page }) => { + // The palette advertised pages, plugins, snippets, sessions and docs, and + // could not find a single setting — 1,789 lines of them, reachable only by + // opening the dialog and hunting. V4-ENHANCED 6.12. + await seedSession(page); + await page.goto('/'); + await page.getByRole('button', { name: /Demo Session/ }).click(); + + await pressCommandCenterShortcut(page); + await expect(page.getByTestId('command-center')).toBeVisible(); + await page.getByRole('textbox', { name: 'Command Center search' }).fill('api key'); + + // Searching for what a setting *is about* finds the section holding it. + const security = page.getByText('Settings — Security').first(); + await expect(security).toBeVisible(); + await security.click(); + + // And it lands on that section, not merely on the dialog's first tab. Asserted + // through the UI rather than storage: the section is navigation for this app + // run, deliberately not persisted. + const dialog = page.getByRole('dialog', { name: 'Settings' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('tab', { name: 'Security' })).toHaveAttribute('data-state', 'active'); + await expect(dialog.getByLabel('API Key').first()).toBeVisible(); +}); + +test('an empty panel caused by a stale filter offers a way out', async ({ page }) => { + // C1 made filters outlive the visit that set them, which created this: you can + // arrive at an empty panel because of a filter you set days ago. The state + // that reports it has to be the one that undoes it. + await seedSession(page); + await page.addInitScript(() => { + localStorage.setItem( + 'feather-panel-state', + JSON.stringify({ state: { values: { 'observability.search': 'zzz-nothing-matches-this' } }, version: 1 }), + ); + }); + await page.goto('/observability'); + await page.getByRole('button', { name: /Demo Session/ }).click(); + + const clear = page.getByTestId('observability-clear-filters'); + await expect(clear).toBeVisible(); + await clear.click(); + + // The filter is actually gone, not merely the message hiding it. Reading the + // persisted store rather than the input, because with no observers attached + // the panel drops to a different empty state and the toolbar goes with it. + await expect(clear).toHaveCount(0); + await expect + .poll(async () => + page.evaluate(() => { + const raw = localStorage.getItem('feather-panel-state'); + return raw ? (JSON.parse(raw).state.values['observability.search'] ?? '') : null; + }), + ) + .toBe(''); +}); + +test('panel filters survive leaving the panel and coming back', async ({ page }) => { + // The most frequent cost this tool imposed: filter the log, step away to check + // a number, come back to unfiltered output and type it again. V4-ENHANCED C1. + await seedSession(page); + await page.goto('/'); + await page.getByRole('button', { name: /Demo Session/ }).click(); + + const search = page.getByTestId('logs-toolbar-search').getByRole('textbox'); + await search.fill('collision'); + await expect(search).toHaveValue('collision'); + + await page.getByTestId('sidebar-tool-assets').getByRole('link', { name: 'Assets' }).click(); + await expect(page).toHaveURL(/\/assets$/); + + await page.getByTestId('sidebar-tool-logs').getByRole('link', { name: 'Logs' }).click(); + await expect(page.getByTestId('logs-toolbar-search').getByRole('textbox')).toHaveValue('collision'); +}); + +test('panel filters survive a full reload, the way a game restart leaves them', async ({ page }) => { + // A restart mints a new session id, which is why this state is global rather + // than keyed to the session — keying it would clear filters on exactly the + // event they most need to survive. + await seedSession(page); + await page.goto('/'); + await page.getByRole('button', { name: /Demo Session/ }).click(); + + await page.getByTestId('logs-toolbar-search').getByRole('textbox').fill('nil value'); + await expect(page.getByTestId('logs-toolbar-search').getByRole('textbox')).toHaveValue('nil value'); + + await page.reload(); + await page.getByRole('button', { name: /Demo Session/ }).click(); + await expect(page.getByTestId('logs-toolbar-search').getByRole('textbox')).toHaveValue('nil value'); +}); + +test('the creative tools are gone from Inspector, not merely unreachable', async ({ page }) => { + // They live in Feather Studio as of v4. Inspector kept advertising them after + // the move, so every one of these navigated to a route that no longer exists + // and rendered an empty page. Assert the absence, not just the routing. + await seedSession(page); + await page.goto('/'); + await page.getByRole('button', { name: /Demo Session/ }).click(); + + await expect(page.getByTestId('sidebar-group-creative')).toHaveCount(0); + for (const id of ['shader-graph', 'particle-system-playground', 'texture-lab']) { + await expect(page.getByTestId(`sidebar-tool-${id}`)).toHaveCount(0); + } + + // The command palette advertised them too. + await page.keyboard.press('ControlOrMeta+k'); + await page.getByRole('textbox', { name: 'Command Center search' }).fill('shader'); + await expect(page.getByText('Shader Graph', { exact: false })).toHaveCount(0); +}); + test('sidebar pin actions persist and settings can clear and restore pins', async ({ page }) => { await seedSession(page); await page.goto('/'); @@ -2087,7 +1566,9 @@ test('settings exposes MCP access controls with browser fallback state', async ( await page.getByRole('tab', { name: 'Security' }).click(); await expect(page.getByText('Expose live Feather sessions to local MCP clients with token-protected full-control tools.')).toBeVisible(); - await expect(page.getByText('Sessions, plugin catalog/live state, Shader Graph, Particles Playground, and Texture Lab')).toBeVisible(); + await expect(page.getByText('Sessions, runtime state, and plugin catalog/live state')).toBeVisible(); + // The creative tools are Studio's half of the one MCP server, not Inspector's. + await expect(page.getByText('served by Feather Studio through the same')).toBeVisible(); await expect(page.getByLabel('Enable MCP access')).toBeDisabled(); await expect(page.getByText('Unavailable')).toBeVisible(); }); @@ -2437,6 +1918,45 @@ test('session health hub summarizes a healthy active session', async ({ page }) await expectNoBrokenText(page); }); +test('session health flags a runtime speaking an unsupported wire protocol', async ({ page }) => { + await seedSession(page); + await page.setViewportSize({ width: 1180, height: 760 }); + await page.goto('/'); + // A runtime newer than this desktop build can parse. + await seedHealthySessionConfig(page, { protocolVersion: 99 }); + await page.getByRole('button', { name: /Demo Session/ }).click(); + await page.getByRole('link', { name: 'Session', exact: true }).click(); + + const hub = page.getByTestId('session-health-hub'); + await expect(hub).toBeVisible(); + // The verdict and the protocol chip live in the hub; warnings render below it. + await expect(hub.getByText('Needs attention')).toBeVisible(); + await expect(hub.getByText('v99', { exact: true })).toBeVisible(); + await expect(page.getByText('Protocol mismatch', { exact: true })).toBeVisible(); + // The warning has to say what to actually do about it. + await expect(page.getByText(/Update the Feather desktop app/)).toBeVisible(); + await expectNoBrokenText(page); +}); + +test('session health tolerates a runtime that predates protocol negotiation', async ({ page }) => { + await seedSession(page); + await page.setViewportSize({ width: 1180, height: 760 }); + await page.goto('/'); + // No protocolVersion at all. Every runtime shipped before negotiation existed + // looks like this, so it must stay Healthy rather than degrade the verdict. + await seedHealthySessionConfig(page); + await page.getByRole('button', { name: /Demo Session/ }).click(); + await page.getByRole('link', { name: 'Session', exact: true }).click(); + + const hub = page.getByTestId('session-health-hub'); + await expect(hub).toBeVisible(); + await expect(hub.getByText('Healthy')).toBeVisible(); + await expect(page.getByText('Protocol mismatch', { exact: true })).toHaveCount(0); + await expect(hub.getByText('legacy', { exact: true })).toBeVisible(); + await expect(page.getByText('No urgent session issues detected.', { exact: true })).toBeVisible(); + await expectNoBrokenText(page); +}); + test('session health hub tolerates partial config and degraded plugin state', async ({ page }) => { await seedSession(page); await page.setViewportSize({ width: 1180, height: 760 }); @@ -3392,10 +2912,10 @@ test('runtime interest follows active app panels', async ({ page }) => { ) .toBe(true); - await page - .getByTestId('sidebar-tool-particle-system-playground') - .getByRole('link', { name: 'Particles Playground' }) - .click(); + // Previously reached through the Particles Playground page. That tool moved to + // Feather Studio, so this now covers plugin interest through the route that + // actually still exists. + await page.goto('/plugins/console'); await expect .poll(async () => page.evaluate(() => { @@ -3411,8 +2931,8 @@ test('runtime interest follows active app panels', async ({ page }) => { }), ) .toMatchObject({ - particlePlayground: true, - pluginIds: expect.arrayContaining(['particle-system-playground']), + plugins: true, + pluginIds: expect.arrayContaining(['console']), }); await expect .poll(async () => @@ -3425,498 +2945,13 @@ test('runtime interest follows active app panels', async ({ page }) => { .toBe(true); }); -test('particle playground uses connected-game preview on demand in the app', async ({ page }) => { - await page.setViewportSize({ width: 980, height: 420 }); - await seedTauriConnectedGame(page); - await page.goto('/'); - await expect(page.getByRole('button', { name: /CLI Example/ })).toBeVisible(); - await seedParticlePlaygroundConfig(page, 'live-game-session'); - await page - .getByTestId('sidebar-tool-particle-system-playground') - .getByRole('link', { name: 'Particles Playground' }) - .click(); - - await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); - await expect(page.getByRole('button', { name: /^Play$/ })).toBeVisible(); - await expect(page.getByRole('button', { name: /^Emit$/ })).toHaveCount(0); - const mainViewport = page.getByTestId('particle-playground-main').locator('[data-slot="scroll-area-viewport"]'); - await expect - .poll(async () => mainViewport.evaluate((element) => element.scrollHeight > element.clientHeight)) - .toBe(true); - await expect - .poll(async () => - mainViewport.evaluate((element) => { - element.scrollTop = 120; - return element.scrollTop; - }), - ) - .toBeGreaterThan(0); - expect(await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0)).toBe(0); - await expect(page.getByTestId('particle-preview-monitor')).toBeVisible(); - await expect(page.getByText('game runtime')).toBeVisible(); - await expect(page.locator('iframe[title="Particle Preview"]')).toHaveCount(0); - await page.waitForTimeout(150); - expect( - await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string; params?: { active?: boolean } } }) => - item.message?.action === 'runtime-preview' && item.message.params?.active === true, - ).length; - }), - ).toBe(0); - await page.getByRole('tab', { name: 'Timeline' }).click(); - await page.getByTitle('Play timeline').click(); - await page.waitForTimeout(150); - expect( - await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string } }) => item.message?.action === 'timeline-control', - ).length; - }), - ).toBe(0); - await page.getByTitle('Pause timeline').click(); - - await page.getByRole('button', { name: 'Show in Game' }).click(); - await expect - .poll(async () => - page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string; params?: { active?: boolean } } }) => - item.message?.action === 'runtime-preview' && item.message.params?.active === true, - ).length; - }), - ) - .toBeGreaterThan(0); - - await page.getByRole('button', { name: 'Hide in Game' }).click(); - await expect - .poll(async () => - page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string; params?: { active?: boolean } } }) => - item.message?.action === 'runtime-preview' && item.message.params?.active === false, - ).length; - }), - ) - .toBeGreaterThan(0); - - const clearsBeforeLeave = await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string; params?: { active?: boolean } } }) => - item.message?.action === 'runtime-preview' && item.message.params?.active === false, - ).length; - }); - await page.getByTestId('sidebar-tool-logs').getByRole('link', { name: 'Logs' }).click(); - await page.waitForTimeout(150); - expect( - await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string; params?: { active?: boolean } } }) => - item.message?.action === 'runtime-preview' && item.message.params?.active === false, - ).length; - }), - ).toBe(clearsBeforeLeave); -}); - -test('particle playground timeline mode control updates in the app', async ({ page }) => { - await seedSession(page); - await page.goto('/'); - await seedParticlePlaygroundConfig(page); - await page.getByRole('button', { name: /Demo Session/ }).click(); - await page - .getByTestId('sidebar-tool-particle-system-playground') - .getByRole('link', { name: 'Particles Playground' }) - .click(); - - await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); - await page.getByRole('tab', { name: 'Timeline' }).click(); - const mode = page.getByTestId('particle-timeline-mode'); - await expect(mode.getByRole('button', { name: 'Loop' })).toHaveAttribute('aria-pressed', 'true'); - await mode.getByRole('button', { name: 'Ambient' }).click(); - await expect(mode.getByRole('button', { name: 'Ambient' })).toHaveAttribute('aria-pressed', 'true'); - await expect(page.getByText(/Ambient starts once/)).toBeVisible(); - await mode.getByRole('button', { name: 'One-shot' }).click(); - await expect(mode.getByRole('button', { name: 'One-shot' })).toHaveAttribute('aria-pressed', 'true'); -}); -test('particle playground undo redo history works in the app', async ({ page }) => { - await seedTauriConnectedGame(page); - await page.goto('/'); - await expect(page.getByRole('button', { name: /CLI Example/ })).toBeVisible(); - await seedParticlePlaygroundConfig(page, 'live-game-session'); - await page - .getByTestId('sidebar-tool-particle-system-playground') - .getByRole('link', { name: 'Particles Playground' }) - .click(); - await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); - const undoButton = page.getByLabel('Undo particle edit'); - const redoButton = page.getByLabel('Redo particle edit'); - await expect(undoButton).toBeDisabled(); - await expect(redoButton).toBeDisabled(); - const restoreCommandCount = () => - page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((window as any).__FEATHER_E2E_TAURI__?.commands ?? []).filter( - (item: { message?: { action?: string } }) => item.message?.action === 'restore-composite', - ).length; - }); - const restoresBeforeEdit = await restoreCommandCount(); - const rate = page.getByText('Rate', { exact: true }).locator('..').getByRole('spinbutton'); - await expect(rate).toHaveValue('100'); - await rate.fill('150'); - await expect(rate).toHaveValue('150'); - await expect(undoButton).toBeEnabled(); - await expect(redoButton).toBeDisabled(); - await expect.poll(restoreCommandCount).toBe(restoresBeforeEdit); - - await page.getByRole('heading', { name: 'Particles Playground' }).click(); - await page.keyboard.press(`${multiSelectModifier()}+Z`); - await expect(rate).toHaveValue('100'); - await expect(redoButton).toBeEnabled(); - await expect.poll(restoreCommandCount).toBe(restoresBeforeEdit + 1); - - await page.keyboard.press(`${multiSelectModifier()}+Shift+Z`); - await expect(rate).toHaveValue('150'); - await expect(undoButton).toBeEnabled(); - await expect.poll(restoreCommandCount).toBe(restoresBeforeEdit + 2); - - await page.getByRole('tab', { name: 'Timeline' }).click(); - await page.getByTestId('particle-timeline-track-1').click(); - const strip = await page.getByTestId('particle-timeline-track-strip-1').boundingBox(); - expect(strip).not.toBeNull(); - await page.getByLabel('Stop at').first().fill('2.2'); - await expect(page.getByLabel('Stop at').first()).toHaveValue('2.2'); - await dragLocatorBy(page, page.getByTestId('particle-timeline-clip-1').first(), strip!.width * (0.2 / 3)); - await expect(page.getByLabel('Emit at').first()).toHaveValue('0.2'); - await undoButton.click(); - await expect(page.getByLabel('Emit at').first()).toHaveValue('0'); - - await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const client = (window as any).__FEATHER_QUERY_CLIENT__; - const current = client?.getQueryData(['live-game-session', 'plugin', 'particle-system-playground']); - if (!current?.data) return; - client.setQueryData(['live-game-session', 'plugin', 'particle-system-playground'], { - ...current, - data: { ...current.data, compositeType: 'game' }, - }); - }); - await expect(undoButton).toBeDisabled(); - await expect(redoButton).toBeDisabled(); -}); - -test('particle playground timeline toggles emitters and moves grouped items in the app', async ({ page }) => { - await seedSession(page); - await page.goto('/'); - await keepNotificationsFromBlockingPointers(page); - await seedParticlePlaygroundConfig(page); - await page.getByRole('button', { name: /Demo Session/ }).click(); - await page - .getByTestId('sidebar-tool-particle-system-playground') - .getByRole('link', { name: 'Particles Playground' }) - .click(); - - await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); - await page.getByRole('tab', { name: 'Timeline' }).click(); - - await page.getByTestId('particle-timeline-track-1').getByRole('button', { name: 'Disable emitter Fire' }).click(); - await expect(page.getByTestId('particle-timeline-track-1').getByText('muted')).toBeVisible(); - await page.getByTestId('particle-timeline-track-1').getByRole('button', { name: 'Enable emitter Fire' }).click(); - await expect(page.getByTestId('particle-timeline-track-1').getByText('muted')).toHaveCount(0); - - await page.getByTestId('particle-timeline-track-1').click(); - const strip1 = await page.getByTestId('particle-timeline-track-strip-1').boundingBox(); - expect(strip1).not.toBeNull(); - await page.getByLabel('Stop at').first().fill('0.7'); - await expect(page.getByLabel('Stop at').first()).toHaveValue('0.7'); - await page.getByTestId('particle-timeline-playhead').fill('1.2'); - await page.getByTestId('particle-timeline-playhead').dispatchEvent('change'); - await page - .getByTestId('particle-timeline-inspector') - .getByRole('button', { name: /add clip at playhead/i }) - .click(); - await expect(page.getByTestId('particle-timeline-clip-1')).toHaveCount(2); - - const fireClips = page.getByTestId('particle-timeline-clip-1'); - const secondClip = fireClips.nth(1); - const beforeGroupedMove = await readTimelineClipTimes(fireClips); - await selectTimelineClipGroup(page.getByTestId('particle-timeline-track-1'), 'Fire', fireClips, secondClip); - await dragLocatorBy(page, secondClip, strip1!.width * (0.4 / 3)); - const afterGroupedMove = await expectTimelineClipTimes(fireClips, (clips) => { - if (clips.length !== 2) return false; - const firstDelta = Number((clips[0].start - beforeGroupedMove[0].start).toFixed(1)); - const secondDelta = Number((clips[1].start - beforeGroupedMove[1].start).toFixed(1)); - return ( - firstDelta > 0 && - firstDelta === secondDelta && - clips[0].end > beforeGroupedMove[0].end && - clips[1].end > beforeGroupedMove[1].end - ); - }); - - await selectTimelineClipGroup(page.getByTestId('particle-timeline-track-1'), 'Fire', fireClips, secondClip); - await dragLocatorBy(page, secondClip, strip1!.width); - await expectTimelineClipTimes(fireClips, (clips) => { - if (clips.length !== 2) return false; - const firstDelta = Number((clips[0].start - afterGroupedMove[0].start).toFixed(1)); - const secondDelta = Number((clips[1].start - afterGroupedMove[1].start).toFixed(1)); - return ( - firstDelta > 0 && - firstDelta === secondDelta && - Math.abs(clips[1].end - 3) < 0.01 && - Number((clips[1].start - clips[0].start).toFixed(1)) === - Number((afterGroupedMove[1].start - afterGroupedMove[0].start).toFixed(1)) - ); - }); -}); - -test('shader graph template presets expose public controls in the app', async ({ page }) => { - await seedSession(page); - await page.goto('/'); - await seedShaderGraphConfig(page); - await page.getByRole('button', { name: /Demo Session/ }).click(); - await page.getByTestId('sidebar-tool-shader-graph').getByRole('link', { name: 'Shader Graph' }).click(); - await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible(); - page.on('dialog', (dialog) => dialog.accept()); - - await page.getByRole('combobox', { name: 'Load preset' }).click(); - await page.getByRole('option', { name: /^outline$/i }).click(); - - const templateControls = page.getByTestId('shader-template-controls'); - await expect(templateControls).toBeVisible(); - await expect(templateControls.getByText('Outline', { exact: true })).toBeVisible(); - await expect(templateControls.getByText('Thickness')).toBeVisible(); - await expect(templateControls.getByText('Outline Color', { exact: true })).toBeVisible(); - - await templateControls.getByRole('spinbutton').first().fill('6'); - await openShaderOutput(page); - await expect(page.getByText(/,\s*6\.0,\s*vec4/i)).toBeVisible(); - - await page.locator('.react-flow__node').filter({ hasText: 'Outline' }).dblclick(); - await expect(page.locator('.react-flow__node').filter({ hasText: 'Source Color' })).toBeVisible(); - await expect(page.locator('.react-flow__node').filter({ hasText: 'RGBA Output' })).toBeVisible(); -}); - -test('shader graph right panel exposes root shader controls in the app', async ({ page }) => { - await seedSession(page); - await page.goto('/'); - await seedShaderGraphConfig(page); - await page.getByRole('button', { name: /Demo Session/ }).click(); - await page.getByTestId('sidebar-tool-shader-graph').getByRole('link', { name: 'Shader Graph' }).click(); - await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible(); - - await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 260, y: 260 } }); - await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('float parameter'); - await page - .getByTestId('shader-node-picker') - .getByRole('button', { name: /^float parameter input$/i }) - .click(); - - await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 420, y: 260 } }); - await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('color parameter'); - await page - .getByTestId('shader-node-picker') - .getByRole('button', { name: /^color parameter input$/i }) - .click(); - - const controls = page.getByTestId('shader-controls-panel'); - await expect(controls).toBeVisible(); - await expect(controls.getByText('float', { exact: true })).toBeVisible(); - await expect(controls.getByText('color', { exact: true })).toBeVisible(); - - await controls.getByLabel('Float Parameter label').fill('Strength'); - await controls.getByLabel('Strength value').fill('0.5'); - await controls.getByLabel('Color Parameter label').fill('Tint'); - await controls.getByLabel('Tint alpha').fill('0.6'); - - await expect - .poll(async () => { - return page.evaluate(() => { - const state = JSON.parse(localStorage.getItem('feather-shader-graph') || '{}')?.state; - const strength = state?.nodes?.find((node: { data?: { label?: string } }) => node.data?.label === 'Strength'); - const tint = state?.nodes?.find((node: { data?: { label?: string } }) => node.data?.label === 'Tint'); - return { - strength: strength?.data?.values?.val, - tintAlpha: tint?.data?.values?.val?.[3], - }; - }); - }) - .toEqual({ strength: 0.5, tintAlpha: 0.6 }); - - await openShaderOutput(page); - await page.locator('.react-flow__node').filter({ hasText: 'Strength' }).first().click(); - await expect(page.getByTestId('shader-right-panel-selection')).toBeVisible(); - await openShaderControls(page); - await page.getByTestId('shader-canvas').click({ position: { x: 24, y: 96 } }); - await expect(page.getByLabel('Select canvas mode')).toHaveAttribute('aria-pressed', 'true'); - await page.keyboard.press('Space'); - await expect(page.getByLabel('Pan canvas mode')).toHaveAttribute('aria-pressed', 'true'); - await page.keyboard.press('Space'); - await expect(page.getByLabel('Select canvas mode')).toHaveAttribute('aria-pressed', 'true'); - - await controls.getByTitle('Select parameter node').first().click(); - await expect(page.getByTestId('shader-right-panel-selection')).toBeVisible(); - await expect(page.locator('input[value="Strength"]')).toBeVisible(); - - await page.getByText('Insert preset').click(); - await page.getByRole('option', { name: /^outline$/i }).click(); - await openShaderControls(page); - await expect(page.getByTestId('shader-template-controls')).toBeVisible(); - await page.getByTestId('shader-template-controls').getByTitle('Select boundary node').first().click(); - await expect(page.getByRole('button', { name: 'Back to parent graph' })).toBeEnabled(); - await openShaderControls(page); - await expect(controls.getByText(/root graph controls/i)).toBeVisible(); - await controls.getByTitle('Select parameter node').first().click(); - await expect(page.getByRole('button', { name: 'Back to parent graph' })).toBeDisabled(); - await expect(page.locator('input[value="Strength"]')).toBeVisible(); -}); - -test('shader graph preview probes render texture-heavy uploads in the app', async ({ page }) => { - await seedSession(page); - await page.goto('/'); - await seedShaderGraphConfig(page); - await page.getByRole('button', { name: /Demo Session/ }).click(); - await page.getByTestId('sidebar-tool-shader-graph').getByRole('link', { name: 'Shader Graph' }).click(); - await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible(); - const files = shaderPreviewTextureFiles(); - - await page.locator('input[type="file"]').setInputFiles({ - name: 'texture-heavy-preview.feathershgh', - mimeType: 'application/json', - // @ts-expect-error buffer time - buffer: Buffer.from(JSON.stringify(textureHeavyPreviewGraph())), - }); - - await uploadShaderPreviewTexture(page, page.getByTitle('Upload preview texture'), files.water); - - const noiseNode = page.locator('.react-flow__node').filter({ hasText: 'Noise Texture' }); - await noiseNode.click(); - await page.getByRole('tab', { name: 'Selection' }).click(); - await uploadShaderPreviewTexture( - page, - page.getByTestId('shader-right-panel-selection').getByTitle('Upload texture file'), - files.noise, - ); - await expect(page.getByTestId('shader-right-panel-selection').getByText('simplex-noise-64.png')).toBeVisible(); - - const maskNode = page.locator('.react-flow__node').filter({ hasText: 'Mask Texture' }); - await maskNode.click(); - await page.getByRole('tab', { name: 'Selection' }).click(); - await uploadShaderPreviewTexture( - page, - page.getByTestId('shader-right-panel-selection').getByTitle('Upload texture file'), - files.mask, - ); - await expect(page.getByTestId('shader-right-panel-selection').getByText('3-mask.png')).toBeVisible(); - - const probe = page.locator('.react-flow__node').filter({ hasText: 'Texture Probe' }); - await probe.click(); - await expectTextureProbePayload(page); -}); - -test('particle playground timeline is editable in the app', async ({ page }) => { - await seedSession(page); - await page.goto('/'); - await seedParticlePlaygroundConfig(page); - await page.getByRole('button', { name: /Demo Session/ }).click(); - await page - .getByTestId('sidebar-tool-particle-system-playground') - .getByRole('link', { name: 'Particles Playground' }) - .click(); - - await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); - await page.getByRole('tab', { name: 'Timeline' }).click(); - await expect(page.getByTestId('particle-timeline-panel')).toBeVisible(); - const mode = page.getByTestId('particle-timeline-mode'); - await expect(mode.getByRole('button', { name: 'Loop' })).toHaveAttribute('aria-pressed', 'true'); - const mainWidth = await page - .getByTestId('particle-playground-main') - .evaluate((element) => element.getBoundingClientRect().width); - const panelWidth = await page - .getByTestId('particle-timeline-panel') - .evaluate((element) => element.getBoundingClientRect().width); - expect(panelWidth).toBeGreaterThan(mainWidth * 0.85); - - const timelineScroll = page.getByTestId('particle-timeline-scroll'); - const defaultOverflow = await timelineScroll.evaluate((element) => element.scrollWidth - element.clientWidth); - expect(defaultOverflow).toBeLessThanOrEqual(2); - - const playhead = page.getByTestId('particle-timeline-playhead'); - await page.getByTitle('Play timeline').click(); - await expect.poll(async () => Number(await playhead.inputValue()), { timeout: 1500 }).toBeGreaterThan(0.05); - const animatedTime = Number(await playhead.inputValue()); - expect(animatedTime).toBeLessThan(1); - await page.getByTitle('Pause timeline').click(); - await page.getByTitle('Reset playhead').click(); - - await page.getByTestId('particle-timeline-track-1').click(); - const clipBox = await page.getByTestId('particle-timeline-clip-1').first().boundingBox(); - const trackStripBox = await page.getByTestId('particle-timeline-track-strip-1').boundingBox(); - expect(clipBox).not.toBeNull(); - expect(trackStripBox).not.toBeNull(); - expect(clipBox!.x).toBeGreaterThanOrEqual(trackStripBox!.x - 1); - expect(clipBox!.x + clipBox!.width).toBeLessThanOrEqual(trackStripBox!.x + trackStripBox!.width + 1); - - await dragLocatorToX( - page, - page.getByTestId('particle-timeline-clip-end-handle-1').first(), - trackStripBox!.x + trackStripBox!.width * (2.2 / 3), - ); - await expect(page.getByLabel('Stop at').first()).toHaveValue('2.2'); - await dragLocatorBy(page, page.getByTestId('particle-timeline-clip-1').first(), trackStripBox!.width * (0.2 / 3)); - await expect(page.getByLabel('Emit at').first()).toHaveValue('0.2'); - await expect(page.getByLabel('Stop at').first()).toHaveValue('2.4'); - const emissionWindow = page.getByTestId('particle-timeline-emission-window-1').first(); - await expect(emissionWindow).toBeVisible(); - const resizedClipBox = await page.getByTestId('particle-timeline-clip-1').first().boundingBox(); - const emissionBox = await emissionWindow.boundingBox(); - expect(resizedClipBox).not.toBeNull(); - expect(emissionBox).not.toBeNull(); - expect(emissionBox!.width).toBeLessThan(resizedClipBox!.width * 0.65); - await expect(page.getByTestId('particle-timeline-emission-window-2')).toHaveCount(0); - const tail = page.getByTestId('particle-timeline-tail-1').first(); - await expect(tail).toBeVisible(); - const tailBox = await tail.boundingBox(); - expect(tailBox).not.toBeNull(); - expect(tailBox!.x).toBeGreaterThanOrEqual(trackStripBox!.x - 1); - expect(tailBox!.x + tailBox!.width).toBeLessThanOrEqual(trackStripBox!.x + trackStripBox!.width + 1); - - await page.getByRole('button', { name: /duplicate clip/i }).click(); - await expect(page.getByTestId('particle-timeline-clip-1')).toHaveCount(2); - await page.keyboard.press('Delete'); - await expect(page.getByTestId('particle-timeline-clip-1')).toHaveCount(1); - - await page.getByText('Opacity').click(); - await page.getByRole('button', { name: /add key at playhead/i }).click(); - await page.getByLabel('Opacity key value').first().fill('0.4'); - await expect(page.getByTestId('particle-timeline-inspector').getByLabel('Opacity key curve')).toBeDisabled(); - await expect(page.getByTestId('particle-timeline-lane-opacity-1').getByText('1 keys')).toBeVisible(); - const keyframe = page.locator('[title="Opacity 0.00s = 0.4"]').first(); - await expect(keyframe).toBeVisible(); - await dragLocatorBy(page, keyframe, trackStripBox!.width * (0.3 / 3)); - await expect(page.getByLabel('Opacity key time').first()).toHaveValue('0.3'); - - await page.getByTestId('particle-emitter-row-1').dragTo(page.getByTestId('particle-emitter-row-2')); - await page.getByTestId('particle-timeline-track-2').click(); - await expect(page.getByLabel('Stop at').first()).toHaveValue('2.4'); - await page.getByTestId('particle-timeline-track-1').click(); - await expect(page.getByLabel('Stop at').first()).toHaveValue('3'); - - await page.getByTitle('Play timeline').click(); - await expect(page.getByTitle('Pause timeline')).toBeVisible(); -}); test('assets degraded matrix handles empty and partial catalogs', async ({ page }) => { await seedSession(page); @@ -3937,6 +2972,23 @@ test('assets degraded matrix handles empty and partial catalogs', async ({ page await expectNarrowStable(page, 'assets-partial-catalog-narrow.png', ['Preview on', 'runtime-texture']); }); +test('a breakpoint problem count can be opened to see which breakpoints', async ({ page }) => { + // It was a bare badge reading "1 condition error" — a number with no route to + // which breakpoint, in which file, or why. V4-ENHANCED C3 at the panel level. + await seedDebuggerSession(page); + await page.setViewportSize({ width: 1366, height: 820 }); + await page.goto('/debugger'); + await page.getByRole('button', { name: /Demo Session/ }).click(); + + const badge = page.getByTestId('breakpoint-issues'); + await expect(badge).toBeVisible(); + await badge.click(); + + // The file and line, and the runtime's own reason — not just a count. + await expect(page.getByText('main.lua:18')).toBeVisible(); + await expect(page.getByText('unexpected symbol near end of expression')).toBeVisible(); +}); + test('debugger renders stable single-row header and three panels', async ({ page }) => { await seedDebuggerSession(page); await page.setViewportSize({ width: 1366, height: 820 }); @@ -4001,6 +3053,95 @@ test('debugger renders stable single-row header and three panels', async ({ page await page.screenshot({ path: 'test-results/debugger-layout-narrow.png', fullPage: true }); }); +test('the debugger can be driven from the keyboard while paused', async ({ page }) => { + // Half of V4-ENHANCED 6.4's acceptance line: hit a breakpoint, inspect, and + // step out mostly from the keyboard. Bindings that exist but do not fire are + // worse than none, because the tooltip promises them. + await seedDebuggerProbeSession(page); + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/debugger'); + await expect(page.getByText('Paused')).toBeVisible(); + + const sent = async () => + page.evaluate(() => { + const commands = + (window as unknown as { __FEATHER_E2E_TAURI__?: { commands?: Array<{ message?: { type?: string } }> } }) + .__FEATHER_E2E_TAURI__?.commands ?? []; + return commands.map((item) => item.message?.type).filter(Boolean); + }); + + await page.keyboard.press('F10'); + await expect.poll(sent).toContain('cmd:debugger:step_over'); + + await page.keyboard.press('F11'); + await expect.poll(sent).toContain('cmd:debugger:step_into'); + + await page.keyboard.press('Shift+F11'); + await expect.poll(sent).toContain('cmd:debugger:step_out'); + + await page.keyboard.press('F8'); + await expect.poll(sent).toContain('cmd:debugger:continue'); +}); + +test('a breakpoint and its condition can be set from the keyboard while paused', async ({ page }) => { + // Completes 6.4's acceptance line. The gutter is one button per line, so + // reaching line 1,847 of a real main.lua by tabbing is not a path anyone + // takes — F9 and Shift+F9 act on the line you are already paused at. + await seedDebuggerProbeSession(page); + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/debugger'); + await expect(page.getByText('Paused')).toBeVisible(); + + const breakpointCount = () => + page.evaluate(() => { + const raw = localStorage.getItem('feather-debugger'); + return raw ? (JSON.parse(raw).state.breakpoints ?? []).length : 0; + }); + + const before = await breakpointCount(); + + // A toggle, not a one-way add. + await page.keyboard.press('F9'); + await expect.poll(breakpointCount).toBe(before + 1); + await page.keyboard.press('F9'); + await expect.poll(breakpointCount).toBe(before); + + // Shift+F9 opens the condition editor for the paused line. + await page.keyboard.press('Shift+F9'); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('dialog').getByText(/condition/i).first()).toBeVisible(); +}); + +test('debugger shortcuts stay out of the way while typing', async ({ page }) => { + // A stepping key that fires while you are writing a breakpoint condition + // would be its own bug, so the guard is worth pinning. + await seedDebuggerProbeSession(page); + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/debugger'); + await expect(page.getByText('Paused')).toBeVisible(); + + const before = await page.evaluate( + () => + ((window as unknown as { __FEATHER_E2E_TAURI__?: { commands?: unknown[] } }).__FEATHER_E2E_TAURI__?.commands ?? []) + .length, + ); + + // The variable filter in the inspector panel: a plain text field that is + // always present while paused. + const filter = page.getByPlaceholder('filter…').first(); + await filter.fill('player'); + await expect(filter).toBeFocused(); + await page.keyboard.press('F10'); + await page.waitForTimeout(250); + + const after = await page.evaluate( + () => + ((window as unknown as { __FEATHER_E2E_TAURI__?: { commands?: unknown[] } }).__FEATHER_E2E_TAURI__?.commands ?? []) + .length, + ); + expect(after, 'a stepping key fired while typing in a text field').toBe(before); +}); + test('debugger profiler probes sync and cycle from the gutter', async ({ page }) => { await seedDebuggerProbeSession(page); await page.setViewportSize({ width: 1280, height: 800 }); diff --git a/e2e/golden.spec.ts b/apps/inspector/e2e/golden.spec.ts similarity index 100% rename from e2e/golden.spec.ts rename to apps/inspector/e2e/golden.spec.ts diff --git a/e2e/helpers/shader-preview-fixture.ts b/apps/inspector/e2e/helpers/shader-preview-fixture.ts similarity index 100% rename from e2e/helpers/shader-preview-fixture.ts rename to apps/inspector/e2e/helpers/shader-preview-fixture.ts diff --git a/index.html b/apps/inspector/index.html similarity index 100% rename from index.html rename to apps/inspector/index.html diff --git a/apps/inspector/playwright.config.ts b/apps/inspector/playwright.config.ts new file mode 100644 index 00000000..cda21dfe --- /dev/null +++ b/apps/inspector/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Playwright runs webServer.command from this config's directory; the Inspector's +// Vite config lives at the repository root. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 1421); +const url = `http://127.0.0.1:${port}`; + +/** + * Feather Inspector's suite. + * + * One product per config: Inspector, Studio and the Showcase are separate + * applications now, and a shared config would have hidden that. Each runs + * against its own server. + */ +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + use: { baseURL: url, trace: 'on-first-retry', screenshot: 'only-on-failure' }, + webServer: { + // `pnpm exec` rather than `pnpm run`: pnpm passes `--` through literally, so + // flags after it never reach Vite. + command: `pnpm exec vite --host 127.0.0.1 --port ${port}`, + url, + cwd: repoRoot, + reuseExistingServer: process.env.PLAYWRIGHT_REUSE_SERVER === '1', + timeout: 120_000, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/src-tauri/.gitignore b/apps/inspector/src-tauri/.gitignore similarity index 100% rename from src-tauri/.gitignore rename to apps/inspector/src-tauri/.gitignore diff --git a/src-tauri/Cargo.lock b/apps/inspector/src-tauri/Cargo.lock similarity index 100% rename from src-tauri/Cargo.lock rename to apps/inspector/src-tauri/Cargo.lock diff --git a/src-tauri/Cargo.toml b/apps/inspector/src-tauri/Cargo.toml similarity index 100% rename from src-tauri/Cargo.toml rename to apps/inspector/src-tauri/Cargo.toml diff --git a/src-tauri/binaries/.gitignore b/apps/inspector/src-tauri/binaries/.gitignore similarity index 100% rename from src-tauri/binaries/.gitignore rename to apps/inspector/src-tauri/binaries/.gitignore diff --git a/src-tauri/build.rs b/apps/inspector/src-tauri/build.rs similarity index 100% rename from src-tauri/build.rs rename to apps/inspector/src-tauri/build.rs diff --git a/src-tauri/capabilities/default.json b/apps/inspector/src-tauri/capabilities/default.json similarity index 100% rename from src-tauri/capabilities/default.json rename to apps/inspector/src-tauri/capabilities/default.json diff --git a/src-tauri/icons/128x128.png b/apps/inspector/src-tauri/icons/128x128.png similarity index 100% rename from src-tauri/icons/128x128.png rename to apps/inspector/src-tauri/icons/128x128.png diff --git a/src-tauri/icons/128x128@2x.png b/apps/inspector/src-tauri/icons/128x128@2x.png similarity index 100% rename from src-tauri/icons/128x128@2x.png rename to apps/inspector/src-tauri/icons/128x128@2x.png diff --git a/src-tauri/icons/32x32.png b/apps/inspector/src-tauri/icons/32x32.png similarity index 100% rename from src-tauri/icons/32x32.png rename to apps/inspector/src-tauri/icons/32x32.png diff --git a/src-tauri/icons/64x64.png b/apps/inspector/src-tauri/icons/64x64.png similarity index 100% rename from src-tauri/icons/64x64.png rename to apps/inspector/src-tauri/icons/64x64.png diff --git a/src-tauri/icons/Square107x107Logo.png b/apps/inspector/src-tauri/icons/Square107x107Logo.png similarity index 100% rename from src-tauri/icons/Square107x107Logo.png rename to apps/inspector/src-tauri/icons/Square107x107Logo.png diff --git a/src-tauri/icons/Square142x142Logo.png b/apps/inspector/src-tauri/icons/Square142x142Logo.png similarity index 100% rename from src-tauri/icons/Square142x142Logo.png rename to apps/inspector/src-tauri/icons/Square142x142Logo.png diff --git a/src-tauri/icons/Square150x150Logo.png b/apps/inspector/src-tauri/icons/Square150x150Logo.png similarity index 100% rename from src-tauri/icons/Square150x150Logo.png rename to apps/inspector/src-tauri/icons/Square150x150Logo.png diff --git a/src-tauri/icons/Square284x284Logo.png b/apps/inspector/src-tauri/icons/Square284x284Logo.png similarity index 100% rename from src-tauri/icons/Square284x284Logo.png rename to apps/inspector/src-tauri/icons/Square284x284Logo.png diff --git a/src-tauri/icons/Square30x30Logo.png b/apps/inspector/src-tauri/icons/Square30x30Logo.png similarity index 100% rename from src-tauri/icons/Square30x30Logo.png rename to apps/inspector/src-tauri/icons/Square30x30Logo.png diff --git a/src-tauri/icons/Square310x310Logo.png b/apps/inspector/src-tauri/icons/Square310x310Logo.png similarity index 100% rename from src-tauri/icons/Square310x310Logo.png rename to apps/inspector/src-tauri/icons/Square310x310Logo.png diff --git a/src-tauri/icons/Square44x44Logo.png b/apps/inspector/src-tauri/icons/Square44x44Logo.png similarity index 100% rename from src-tauri/icons/Square44x44Logo.png rename to apps/inspector/src-tauri/icons/Square44x44Logo.png diff --git a/src-tauri/icons/Square71x71Logo.png b/apps/inspector/src-tauri/icons/Square71x71Logo.png similarity index 100% rename from src-tauri/icons/Square71x71Logo.png rename to apps/inspector/src-tauri/icons/Square71x71Logo.png diff --git a/src-tauri/icons/Square89x89Logo.png b/apps/inspector/src-tauri/icons/Square89x89Logo.png similarity index 100% rename from src-tauri/icons/Square89x89Logo.png rename to apps/inspector/src-tauri/icons/Square89x89Logo.png diff --git a/src-tauri/icons/StoreLogo.png b/apps/inspector/src-tauri/icons/StoreLogo.png similarity index 100% rename from src-tauri/icons/StoreLogo.png rename to apps/inspector/src-tauri/icons/StoreLogo.png diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/inspector/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src-tauri/icons/android/mipmap-hdpi/ic_launcher.png rename to apps/inspector/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/inspector/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png rename to apps/inspector/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/inspector/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png rename to apps/inspector/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/inspector/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src-tauri/icons/android/mipmap-mdpi/ic_launcher.png rename to apps/inspector/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/inspector/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png rename to apps/inspector/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/inspector/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png rename to apps/inspector/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/inspector/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png rename to apps/inspector/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/inspector/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png rename to apps/inspector/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/inspector/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png rename to apps/inspector/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/inspector/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png rename to apps/inspector/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/inspector/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png rename to apps/inspector/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/inspector/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png rename to apps/inspector/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/inspector/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png rename to apps/inspector/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/inspector/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png rename to apps/inspector/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/inspector/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png rename to apps/inspector/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/src-tauri/icons/base.png b/apps/inspector/src-tauri/icons/base.png similarity index 100% rename from src-tauri/icons/base.png rename to apps/inspector/src-tauri/icons/base.png diff --git a/src-tauri/icons/icon.icns b/apps/inspector/src-tauri/icons/icon.icns similarity index 100% rename from src-tauri/icons/icon.icns rename to apps/inspector/src-tauri/icons/icon.icns diff --git a/src-tauri/icons/icon.ico b/apps/inspector/src-tauri/icons/icon.ico similarity index 100% rename from src-tauri/icons/icon.ico rename to apps/inspector/src-tauri/icons/icon.ico diff --git a/src-tauri/icons/icon.png b/apps/inspector/src-tauri/icons/icon.png similarity index 100% rename from src-tauri/icons/icon.png rename to apps/inspector/src-tauri/icons/icon.png diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-20x20@1x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-20x20@1x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-20x20@1x.png diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/inspector/src-tauri/icons/ios/AppIcon-20x20@2x-1.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-20x20@2x-1.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-20x20@2x-1.png diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-20x20@2x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-20x20@2x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-20x20@2x.png diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-20x20@3x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-20x20@3x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-20x20@3x.png diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-29x29@1x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-29x29@1x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-29x29@1x.png diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/inspector/src-tauri/icons/ios/AppIcon-29x29@2x-1.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-29x29@2x-1.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-29x29@2x-1.png diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-29x29@2x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-29x29@2x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-29x29@2x.png diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-29x29@3x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-29x29@3x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-29x29@3x.png diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-40x40@1x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-40x40@1x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-40x40@1x.png diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/inspector/src-tauri/icons/ios/AppIcon-40x40@2x-1.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-40x40@2x-1.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-40x40@2x-1.png diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-40x40@2x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-40x40@2x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-40x40@2x.png diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-40x40@3x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-40x40@3x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-40x40@3x.png diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-512@2x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-512@2x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-512@2x.png diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-60x60@2x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-60x60@2x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-60x60@2x.png diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-60x60@3x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-60x60@3x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-60x60@3x.png diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-76x76@1x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-76x76@1x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-76x76@1x.png diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-76x76@2x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-76x76@2x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-76x76@2x.png diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/inspector/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png similarity index 100% rename from src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png rename to apps/inspector/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png diff --git a/src-tauri/src/cli_actions.rs b/apps/inspector/src-tauri/src/cli_actions.rs similarity index 100% rename from src-tauri/src/cli_actions.rs rename to apps/inspector/src-tauri/src/cli_actions.rs diff --git a/src-tauri/src/cli_status.rs b/apps/inspector/src-tauri/src/cli_status.rs similarity index 100% rename from src-tauri/src/cli_status.rs rename to apps/inspector/src-tauri/src/cli_status.rs diff --git a/src-tauri/src/lib.rs b/apps/inspector/src-tauri/src/lib.rs similarity index 79% rename from src-tauri/src/lib.rs rename to apps/inspector/src-tauri/src/lib.rs index 2b0725ed..ada3b8da 100644 --- a/src-tauri/src/lib.rs +++ b/apps/inspector/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod cli_actions; mod cli_status; mod mcp_bridge; +mod studio_bridge; mod ws_server; use std::{ @@ -77,6 +78,10 @@ pub fn run() { let mcp_bridge = mcp_bridge::new_state(sessions.clone(), app_id.clone()); let cli_actions = cli_actions::CliActionState::default(); + // Answers Studio's one-time preferences handoff; the payload lives in this + // app's webview storage, so the export has to be relayed through it. + let preferences_exporter = studio_bridge::PreferencesExporter::default(); + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -86,6 +91,7 @@ pub fn run() { .manage(app_id.clone()) .manage(mcp_bridge.clone()) .manage(cli_actions) + .manage(preferences_exporter.clone()) .setup(move |app| { let handle = app.handle().clone(); mcp_bridge.set_app_handle(handle.clone()); @@ -105,9 +111,25 @@ pub fn run() { mcp_bridge.clone(), ); mcp_bridge::start(mcp_bridge.clone(), mcp_port); + + // Feather Studio is a separate application; this is how it asks + // Inspector to push work into a live game. Loopback only, and + // capability gated — see studio_bridge for why both matter. + let studio_bridge_port: u16 = std::env::var("FEATHER_STUDIO_BRIDGE_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(4008); + preferences_exporter.set_app_handle(app.handle().clone()); + studio_bridge::start_bridge_server( + sessions.clone(), + studio_bridge_port, + env!("CARGO_PKG_VERSION").to_string(), + preferences_exporter.clone(), + ); Ok(()) }) .invoke_handler(tauri::generate_handler![ + studio_bridge::resolve_studio_preferences_export, ws_server::send_command, ws_server::get_active_sessions, ws_server::close_session, @@ -116,8 +138,6 @@ pub fn run() { mcp_bridge::set_mcp_bridge_enabled, mcp_bridge::regenerate_mcp_bridge_token, mcp_bridge::set_mcp_api_keys, - mcp_bridge::set_mcp_creative_snapshot, - mcp_bridge::resolve_mcp_creative_request, cli_actions::start_cli_job, cli_actions::get_cli_job, cli_actions::cancel_cli_job, diff --git a/src-tauri/src/main.rs b/apps/inspector/src-tauri/src/main.rs similarity index 100% rename from src-tauri/src/main.rs rename to apps/inspector/src-tauri/src/main.rs diff --git a/src-tauri/src/mcp_bridge.rs b/apps/inspector/src-tauri/src/mcp_bridge.rs similarity index 79% rename from src-tauri/src/mcp_bridge.rs rename to apps/inspector/src-tauri/src/mcp_bridge.rs index 8bc201e8..0552707f 100644 --- a/src-tauri/src/mcp_bridge.rs +++ b/apps/inspector/src-tauri/src/mcp_bridge.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use crate::ws_server::{AppId, Sessions}; use axum::extract::ws::Message; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; const DEFAULT_BRIDGE_PORT: u16 = 4005; const MAX_LOGS: usize = 500; @@ -46,8 +46,6 @@ struct McpBridgeInner { api_keys: Mutex, snapshots: Mutex>, waiters: Mutex>, - creative_snapshots: Mutex>, - creative_waiters: Mutex>>, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -169,14 +167,6 @@ pub struct CommandRequest { wait_for: Option, } -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreativeActionRequest { - action: String, - params: Option, - timeout_ms: Option, -} - #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WaitCondition { @@ -194,23 +184,6 @@ struct ResponseWaiter { sender: oneshot::Sender, } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct CreativeActionEvent { - id: String, - tool: String, - action: String, - params: Value, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreativeActionResponse { - ok: bool, - response: Option, - error: Option, -} - #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ErrorPayload { @@ -264,8 +237,6 @@ pub fn new_state(sessions: Sessions, app_id: AppId) -> McpBridgeState { api_keys: Mutex::new(ApiKeys::default()), snapshots: Mutex::new(HashMap::new()), waiters: Mutex::new(Vec::new()), - creative_snapshots: Mutex::new(HashMap::new()), - creative_waiters: Mutex::new(HashMap::new()), }), }; @@ -292,8 +263,6 @@ pub fn router(state: McpBridgeState) -> Router { .route("/sessions", get(list_sessions)) .route("/sessions/{session_id}", get(get_session)) .route("/sessions/{session_id}/command", post(send_command_to_session)) - .route("/creative/{tool}", get(get_creative_snapshot)) - .route("/creative/{tool}/action", post(send_creative_action)) .with_state(state) } @@ -326,26 +295,6 @@ pub fn set_mcp_api_keys( state.set_api_keys(api_key, session_api_keys); } -#[tauri::command] -pub fn set_mcp_creative_snapshot( - tool: String, - snapshot: Value, - state: tauri::State, -) { - state.set_creative_snapshot(&tool, snapshot); -} - -#[tauri::command] -pub fn resolve_mcp_creative_request( - id: String, - ok: bool, - response: Option, - error: Option, - state: tauri::State, -) { - state.resolve_creative_request(&id, CreativeActionResponse { ok, response, error }); -} - impl McpBridgeState { pub fn set_app_handle(&self, app_handle: AppHandle) { let mut handle = self.inner.app_handle.lock().unwrap_or_else(|e| e.into_inner()); @@ -384,28 +333,6 @@ impl McpBridgeState { keys.session_api_keys = session_api_keys; } - pub fn set_creative_snapshot(&self, tool: &str, mut snapshot: Value) { - redact_secrets(&mut snapshot); - let mut snapshots = self - .inner - .creative_snapshots - .lock() - .unwrap_or_else(|e| e.into_inner()); - snapshots.insert(tool.to_string(), snapshot); - } - - pub fn resolve_creative_request(&self, id: &str, response: CreativeActionResponse) { - let sender = self - .inner - .creative_waiters - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(id); - if let Some(sender) = sender { - let _ = sender.send(response); - } - } - pub fn session_started(&self, session_id: &str) { let mut snapshots = self.inner.snapshots.lock().unwrap_or_else(|e| e.into_inner()); snapshots @@ -637,75 +564,6 @@ impl McpBridgeState { } } - async fn dispatch_creative_action( - &self, - tool: String, - request: CreativeActionRequest, - ) -> Result { - if tool.trim().is_empty() || request.action.trim().is_empty() { - return Err(error_response(StatusCode::BAD_REQUEST, "creative tool and action are required")); - } - - let app_handle = self - .inner - .app_handle - .lock() - .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("app handle lock failed: {e}")))? - .clone() - .ok_or_else(|| error_response(StatusCode::SERVICE_UNAVAILABLE, "creative executor is not available"))?; - - let request_id = format!("mcp-creative-{}", Uuid::new_v4()); - let (sender, receiver) = oneshot::channel(); - self.inner - .creative_waiters - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(request_id.clone(), sender); - - let mut params = request.params.unwrap_or(Value::Object(Default::default())); - redact_secrets(&mut params); - let event = CreativeActionEvent { - id: request_id.clone(), - tool, - action: request.action, - params, - }; - - if let Err(err) = app_handle.emit("feather://mcp-creative-request", event) { - self.inner - .creative_waiters - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&request_id); - return Err(error_response( - StatusCode::SERVICE_UNAVAILABLE, - &format!("creative executor event failed: {err}"), - )); - } - - let wait_ms = request.timeout_ms.unwrap_or(10_000).min(MAX_WAIT_MS); - match timeout(Duration::from_millis(wait_ms), receiver).await { - Ok(Ok(response)) => Ok(response), - Ok(Err(_)) => Ok(CreativeActionResponse { - ok: false, - response: None, - error: Some("creative executor was cancelled".to_string()), - }), - Err(_) => { - self.inner - .creative_waiters - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&request_id); - Ok(CreativeActionResponse { - ok: false, - response: None, - error: Some("creative executor timed out".to_string()), - }) - } - } - } - fn attach_command_auth(&self, session_id: &str, message: &mut Value) { let Some(object) = message.as_object_mut() else { return; @@ -832,40 +690,6 @@ async fn send_command_to_session( } } -async fn get_creative_snapshot( - headers: HeaderMap, - State(state): State, - Path(tool): Path, -) -> Response { - if let Err(response) = authorize(&headers, &state) { - return response; - } - let snapshots = state - .inner - .creative_snapshots - .lock() - .unwrap_or_else(|e| e.into_inner()); - let Some(snapshot) = snapshots.get(&tool) else { - return error_response(StatusCode::NOT_FOUND, "creative snapshot is not available"); - }; - Json(snapshot).into_response() -} - -async fn send_creative_action( - headers: HeaderMap, - State(state): State, - Path(tool): Path, - Json(request): Json, -) -> Response { - if let Err(response) = authorize(&headers, &state) { - return response; - } - match state.dispatch_creative_action(tool, request).await { - Ok(response) => Json(response).into_response(), - Err(response) => response, - } -} - fn authorize(headers: &HeaderMap, state: &McpBridgeState) -> Result<(), Response> { let settings = state.settings(); if !settings.enabled { @@ -1086,60 +910,4 @@ mod tests { assert_eq!(rx.try_recv().unwrap()["status"], "success"); } - #[test] - fn creative_snapshots_are_sanitized() { - let (state, _) = test_state(); - state.set_creative_snapshot( - "texture-lab", - json!({ - "recipe": { "generator": "soft-circle" }, - "token": "secret", - "nested": { "apiKey": "secret" } - }), - ); - let snapshots = state.inner.creative_snapshots.lock().unwrap(); - let snapshot = snapshots.get("texture-lab").unwrap(); - assert_eq!(snapshot["token"], "[redacted]"); - assert_eq!(snapshot["nested"]["apiKey"], "[redacted]"); - } - - #[tokio::test] - async fn creative_action_requires_executor() { - let (state, _) = test_state(); - let response = state - .dispatch_creative_action( - "texture-lab".to_string(), - CreativeActionRequest { - action: "generate".to_string(), - params: Some(json!({ "recipe": { "generator": "soft-circle" } })), - timeout_ms: Some(10), - }, - ) - .await - .unwrap_err(); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } - - #[test] - fn creative_waiters_resolve_response_shape() { - let (state, _) = test_state(); - let (tx, mut rx) = oneshot::channel(); - state - .inner - .creative_waiters - .lock() - .unwrap() - .insert("creative-1".to_string(), tx); - state.resolve_creative_request( - "creative-1", - CreativeActionResponse { - ok: true, - response: Some(json!({ "filename": "texture.png" })), - error: None, - }, - ); - let response = rx.try_recv().unwrap(); - assert!(response.ok); - assert_eq!(response.response.unwrap()["filename"], "texture.png"); - } } diff --git a/apps/inspector/src-tauri/src/studio_bridge.rs b/apps/inspector/src-tauri/src/studio_bridge.rs new file mode 100644 index 00000000..8f463c1d --- /dev/null +++ b/apps/inspector/src-tauri/src/studio_bridge.rs @@ -0,0 +1,390 @@ +//! The Inspector side of the Feather Studio bridge. +//! +//! Feather Studio is a separate installed application. It edits shaders, +//! textures and particles locally and does not own game sessions — Inspector +//! does. When Studio wants to push work into a running game it asks here. +//! +//! Three properties this endpoint has to hold, because a request that reaches +//! this far can execute inside someone's game: +//! +//! * **Loopback only.** Bound to 127.0.0.1, never a wildcard, so the bridge is +//! not reachable from the network the way the game WebSocket deliberately is. +//! * **Capability gated.** Everything except the handshake requires a +//! short-lived token this process issued. Tokens live in memory only and die +//! with the process. +//! * **Version negotiated first.** An incompatible Studio is refused at the +//! handshake, before it can name a method — the two applications release on +//! separate trains, so version skew is expected rather than exceptional. +//! +//! The contract mirrored here is `packages/session-bridge/src/contract.ts`. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::{extract::State, routing::post, Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use axum::extract::ws::Message; +use tauri::{AppHandle, Emitter}; +use tokio::sync::oneshot; + +use crate::ws_server::Sessions; + +/// Keep in step with `BRIDGE_VERSION` in the TypeScript contract. +pub const BRIDGE_VERSION: u32 = 1; +pub const BRIDGE_MIN_SUPPORTED: u32 = 1; + +/// Short enough that a leaked token is not a standing key to someone's game. +const CAPABILITY_TTL: Duration = Duration::from_secs(60 * 30); + +#[derive(Clone, Default)] +pub struct BridgeCapabilities { + /// token -> expiry (unix millis) + issued: Arc>>, +} + +fn now_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +impl BridgeCapabilities { + fn issue(&self) -> (String, u128) { + let token = format!("studio-{}", uuid::Uuid::new_v4()); + let expires = now_millis() + CAPABILITY_TTL.as_millis(); + let mut issued = self.issued.lock().unwrap_or_else(|e| e.into_inner()); + // Drop anything already expired rather than letting the map grow. + issued.retain(|_, exp| *exp > now_millis()); + issued.insert(token.clone(), expires); + (token, expires) + } + + fn verify(&self, token: &str) -> bool { + let mut issued = self.issued.lock().unwrap_or_else(|e| e.into_inner()); + match issued.get(token) { + Some(expires) if *expires > now_millis() => true, + Some(_) => { + issued.remove(token); + false + } + None => false, + } + } + + /// Invalidate every outstanding capability. Used when the user turns the + /// bridge off; an in-flight Studio must re-handshake. + pub fn revoke_all(&self) { + self.issued + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clear(); + } +} + +#[derive(Clone)] +struct BridgeState { + sessions: Sessions, + capabilities: BridgeCapabilities, + server_version: String, + /// Needed for `preferences.export`: the payload lives in the Inspector + /// webview's local storage, which this process cannot read directly. + exporter: PreferencesExporter, +} + +/// Relays a one-time preferences export from the Inspector webview. +/// +/// Studio is a separately installed application with its own storage origin, so +/// it can never read Inspector's `settings-storage` key. On first pairing it +/// asks for the payload instead, and Inspector answers from the copy the 05a +/// migration deliberately never deleted. +#[derive(Clone, Default)] +pub struct PreferencesExporter { + app_handle: Arc>>, + waiters: Arc>>>, +} + +impl PreferencesExporter { + pub fn set_app_handle(&self, handle: AppHandle) { + *self.app_handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle); + } + + pub fn resolve(&self, id: &str, payload: Value) { + if let Some(sender) = self + .waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(id) + { + let _ = sender.send(payload); + } + } + + async fn export(&self) -> Option { + let handle = self + .app_handle + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone()?; + + let id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id.clone(), tx); + + if handle + .emit("feather://export-studio-preferences", json!({ "id": id })) + .is_err() + { + self.waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + return None; + } + + match tokio::time::timeout(Duration::from_secs(10), rx).await { + Ok(Ok(payload)) => Some(payload), + _ => { + self.waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + None + } + } + } +} + +#[derive(Deserialize)] +struct BridgeRequest { + id: String, + #[serde(rename = "bridgeVersion", default)] + bridge_version: u32, + method: String, + #[serde(default)] + capability: Option, + #[serde(default)] + params: Value, +} + +#[derive(Serialize)] +struct BridgeErrorBody { + code: String, + message: String, +} + +fn ok(id: &str, result: Value) -> Json { + Json(json!({ "id": id, "bridgeVersion": BRIDGE_VERSION, "ok": true, "result": result })) +} + +fn fail(id: &str, code: &str, message: &str) -> Json { + Json(json!({ + "id": id, + "bridgeVersion": BRIDGE_VERSION, + "ok": false, + "error": BridgeErrorBody { code: code.into(), message: message.into() }, + })) +} + +/// Mirrors `negotiate()` in the TypeScript contract: a range overlap, not +/// equality. Equality would force the two applications to release together, +/// which is the coupling v4 exists to remove. +fn negotiate(remote_version: u32, remote_min: u32) -> Result { + let shared = BRIDGE_VERSION.min(remote_version); + if shared < BRIDGE_MIN_SUPPORTED || shared < remote_min { + return Err(format!( + "No shared bridge version. Inspector speaks v{BRIDGE_MIN_SUPPORTED}–v{BRIDGE_VERSION}; \ + Studio speaks v{remote_min}–v{remote_version}. Update the older application." + )); + } + Ok(shared) +} + +async fn handle(State(state): State, Json(request): Json) -> Json { + let id = request.id.clone(); + + if request.method == "handshake" { + let remote_version = request + .params + .get("bridgeVersion") + .and_then(Value::as_u64) + .unwrap_or(request.bridge_version as u64) as u32; + let remote_min = request + .params + .get("minSupported") + .and_then(Value::as_u64) + .unwrap_or(0) as u32; + + return match negotiate(remote_version, remote_min) { + Err(message) => fail(&id, "incompatible-version", &message), + Ok(version) => { + let (capability, expires_at) = state.capabilities.issue(); + ok( + &id, + json!({ + "server": "inspector", + "serverVersion": state.server_version, + "bridgeVersion": version, + "capability": capability, + "expiresAt": expires_at as u64, + }), + ) + } + }; + } + + let authorized = request + .capability + .as_deref() + .map(|token| state.capabilities.verify(token)) + .unwrap_or(false); + + if !authorized { + return fail( + &id, + "unauthenticated", + "Missing or expired Inspector capability. Reconnect.", + ); + } + + match request.method.as_str() { + "session.describe" => { + let sessions = match state.sessions.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let first = sessions.keys().next().cloned(); + ok( + &id, + json!({ + "sessionId": first, + "name": null, + "connected": !sessions.is_empty(), + }), + ) + } + + "session.send" => { + let session_id = request.params.get("sessionId").and_then(Value::as_str); + let message = match request.params.get("message") { + Some(Value::String(text)) => text.clone(), + Some(value) => value.to_string(), + None => return fail(&id, "rejected", "The command had no message."), + }; + + let Some(session_id) = session_id else { + return fail(&id, "no-session", "Feather Inspector has no game attached."); + }; + + let sessions = match state.sessions.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let Some(session) = sessions.get(session_id) else { + return fail(&id, "no-session", "Feather Inspector has no game attached."); + }; + + match session.sender.send(Message::Text(message.into())) { + Ok(()) => ok(&id, Value::Null), + Err(error) => fail(&id, "rejected", &error.to_string()), + } + } + + // Answered by the Inspector webview, which holds the legacy payload. + // Null means "nothing to hand over", which is the normal case for a + // fresh install and is not an error. + "preferences.export" => match state.exporter.export().await { + Some(payload) => ok(&id, payload), + None => ok(&id, Value::Null), + }, + + other => fail(&id, "unknown-method", &format!("Unrecognized bridge method \"{other}\".")), + } +} + +/// Start the loopback bridge listener. +/// +/// Bound to 127.0.0.1 on purpose. The game WebSocket binds 0.0.0.0 because +/// devices on the LAN legitimately connect to it; this one must not, because it +/// accepts commands on behalf of another local application. +pub fn start_bridge_server( + sessions: Sessions, + port: u16, + server_version: String, + exporter: PreferencesExporter, +) -> BridgeCapabilities { + let capabilities = BridgeCapabilities::default(); + let state = BridgeState { + sessions, + capabilities: capabilities.clone(), + server_version, + exporter, + }; + + tauri::async_runtime::spawn(async move { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => { + let app = Router::new().route("/bridge", post(handle)).with_state(state); + if let Err(error) = axum::serve(listener, app).await { + eprintln!("[feather] Studio bridge stopped: {error}"); + } + } + Err(error) => { + // Not fatal. Inspector is fully usable with no Studio attached. + eprintln!("[feather] Studio bridge could not bind 127.0.0.1:{port}: {error}"); + } + } + }); + + capabilities +} + +/// Called by the Inspector webview with the legacy payload, or null when there +/// is nothing to hand over. +#[tauri::command] +pub fn resolve_studio_preferences_export( + id: String, + payload: Value, + exporter: tauri::State, +) { + exporter.resolve(&id, payload); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn negotiate_accepts_an_overlapping_range() { + assert_eq!(negotiate(1, 1), Ok(1)); + } + + #[test] + fn negotiate_rejects_a_studio_that_is_too_new_to_accept_us() { + assert!(negotiate(9, 9).is_err()); + } + + #[test] + fn capabilities_verify_only_what_they_issued() { + let caps = BridgeCapabilities::default(); + let (token, _) = caps.issue(); + assert!(caps.verify(&token)); + assert!(!caps.verify("studio-forged")); + } + + #[test] + fn revoking_invalidates_outstanding_capabilities() { + let caps = BridgeCapabilities::default(); + let (token, _) = caps.issue(); + assert!(caps.verify(&token)); + caps.revoke_all(); + assert!(!caps.verify(&token), "a revoked capability must stop working"); + } +} diff --git a/src-tauri/src/ws_server.rs b/apps/inspector/src-tauri/src/ws_server.rs similarity index 100% rename from src-tauri/src/ws_server.rs rename to apps/inspector/src-tauri/src/ws_server.rs diff --git a/src-tauri/tauri.conf.json b/apps/inspector/src-tauri/tauri.conf.json similarity index 85% rename from src-tauri/tauri.conf.json rename to apps/inspector/src-tauri/tauri.conf.json index d05a748d..37396c86 100644 --- a/src-tauri/tauri.conf.json +++ b/apps/inspector/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "version": "4.0.0", "identifier": "com.kyonru.love.feather", "build": { - "beforeDevCommand": "npm run dev:tauri", + "beforeDevCommand": "pnpm -w run dev:tauri", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm run prepare:tauri-cli-sidecar && npm run build", + "beforeBuildCommand": "pnpm -w run prepare:tauri-cli-sidecar && pnpm -w run build", "frontendDist": "../dist" }, "app": { diff --git a/src/App.css b/apps/inspector/src/App.css similarity index 72% rename from src/App.css rename to apps/inspector/src/App.css index 188c9192..1542f3a7 100644 --- a/src/App.css +++ b/apps/inspector/src/App.css @@ -1,4 +1,11 @@ @import 'tailwindcss'; + +/* Explicit rather than implicit. Tailwind currently finds these because the + Inspector's Vite root is the repository root, which stops being true when + src/ moves to apps/inspector. The showcase already hit exactly that: its + Radix select classes silently vanished from the bundle. */ +@source "./"; +@source "../../../packages/ui/src"; @import 'tw-animate-css'; @custom-variant dark (&:is(.dark *)); @@ -54,6 +61,23 @@ --plugin-active-foreground: #563965; --plugin-active-icon: #74498a; --plugin-active-border: #d5bddf; + + /* Semantic state colors. These four are the whole signal channel: color + anywhere else in the UI is chrome, color here means something. The values + match what `assets/theme/registry/semantic.ts` derives for this background, + so an unthemed render and a themed one agree. See V4-ENHANCED.md §4. */ + --ok: #137245; + --ok-surface: #e2f8ee; + --ok-border: #b1e7ce; + --warn: #85580a; + --warn-surface: #f8f0e2; + --warn-border: #e7d3b1; + --danger: #b4271d; + --danger-surface: #f8e4e2; + --danger-border: #e7b5b1; + --info: #2161ab; + --info-surface: #e2ecf8; + --info-border: #b1cae7; } @theme inline { @@ -92,6 +116,23 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-plugin-accent: var(--plugin-accent); + --color-plugin-active: var(--plugin-active); + --color-plugin-active-foreground: var(--plugin-active-foreground); + --color-plugin-active-icon: var(--plugin-active-icon); + --color-plugin-active-border: var(--plugin-active-border); + --color-ok: var(--ok); + --color-ok-surface: var(--ok-surface); + --color-ok-border: var(--ok-border); + --color-warn: var(--warn); + --color-warn-surface: var(--warn-surface); + --color-warn-border: var(--warn-border); + --color-danger: var(--danger); + --color-danger-surface: var(--danger-surface); + --color-danger-border: var(--danger-border); + --color-info: var(--info); + --color-info-surface: var(--info-surface); + --color-info-border: var(--info-border); } .dark { @@ -131,6 +172,20 @@ --plugin-active-foreground: #f1def8; --plugin-active-icon: #dfb7f0; --plugin-active-border: #7f4d94; + + /* Derived for #0d1117 — see the light block above. */ + --ok: #20c578; + --ok-surface: #1d3a2c; + --ok-border: #2f6a4e; + --warn: #e49611; + --warn-surface: #3a2f1d; + --warn-border: #6a542f; + --danger: #e87a73; + --danger-surface: #3a1f1d; + --danger-border: #6a332f; + --info: #659fe2; + --info-surface: #1d2a3a; + --info-border: #2f4b6a; } @layer base { diff --git a/src/assets/react.svg b/apps/inspector/src/assets/react.svg similarity index 100% rename from src/assets/react.svg rename to apps/inspector/src/assets/react.svg diff --git a/src/components/app-sidebar/index.tsx b/apps/inspector/src/components/app-sidebar/index.tsx similarity index 95% rename from src/components/app-sidebar/index.tsx rename to apps/inspector/src/components/app-sidebar/index.tsx index 16b73759..54781f34 100644 --- a/src/components/app-sidebar/index.tsx +++ b/apps/inspector/src/components/app-sidebar/index.tsx @@ -11,10 +11,10 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from '@/components/ui/sidebar'; +} from '@feather/ui/sidebar'; import { BookOpenIcon, FeatherIcon } from 'lucide-react'; import { useLatestVersion } from '@/hooks/use-latest-version'; -import { Button } from '@/components/ui/button'; +import { Button } from '@feather/ui/button'; export function AppSidebar({ ...props }: React.ComponentProps) { useLatestVersion(); diff --git a/src/components/app-sidebar/nav-bottom.tsx b/apps/inspector/src/components/app-sidebar/nav-bottom.tsx similarity index 95% rename from src/components/app-sidebar/nav-bottom.tsx rename to apps/inspector/src/components/app-sidebar/nav-bottom.tsx index a206ba1b..3f016036 100644 --- a/src/components/app-sidebar/nav-bottom.tsx +++ b/apps/inspector/src/components/app-sidebar/nav-bottom.tsx @@ -5,7 +5,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from '@/components/ui/sidebar'; +} from '@feather/ui/sidebar'; import { CloudDownloadIcon, InfoIcon, SettingsIcon } from 'lucide-react'; import { useSettingsStore } from '@/store/settings'; import { useAboutStore } from '@/store/about'; @@ -25,7 +25,7 @@ export function NavBottom({ ...props }: {} & React.ComponentPropsWithoutRef { if (!isLatestVersion) { - return 'bg-cyan-50 dark:bg-cyan-950 animate-pulse'; + return 'bg-info-surface animate-pulse'; } return ''; diff --git a/src/components/app-sidebar/nav-main.tsx b/apps/inspector/src/components/app-sidebar/nav-main.tsx similarity index 90% rename from src/components/app-sidebar/nav-main.tsx rename to apps/inspector/src/components/app-sidebar/nav-main.tsx index fd9eea58..b46ce582 100644 --- a/src/components/app-sidebar/nav-main.tsx +++ b/apps/inspector/src/components/app-sidebar/nav-main.tsx @@ -6,12 +6,11 @@ import { SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, -} from '@/components/ui/sidebar'; +} from '@feather/ui/sidebar'; import type { ElementType } from 'react'; import { cn } from '@/utils/styles'; import { NavLink, useLocation } from 'react-router'; import { - BlendIcon, BugIcon, CableIcon, ClockIcon, @@ -20,9 +19,7 @@ import { ImagesIcon, LogsIcon, RepeatIcon, - SparklesIcon, StarIcon, - WandSparklesIcon, TelescopeIcon, TerminalIcon, } from 'lucide-react'; @@ -37,9 +34,6 @@ const featureIcons: Record = { observability: TelescopeIcon, debugger: BugIcon, console: TerminalIcon, - 'particle-system-playground': SparklesIcon, - 'shader-graph': BlendIcon, - 'texture-lab': WandSparklesIcon, assets: ImagesIcon, 'time-travel': ClockIcon, 'session-replay': RepeatIcon, @@ -58,7 +52,6 @@ type SidebarTool = { const sidebarGroups: Array<{ id: string; label: string; toolIds: SidebarToolId[] }> = [ { id: 'core', label: 'Core', toolIds: ['logs', 'performance', 'session', 'compare'] }, { id: 'inspect', label: 'Inspect', toolIds: ['observability', 'debugger', 'console', 'assets'] }, - { id: 'creative', label: 'Creative', toolIds: ['particle-system-playground', 'shader-graph', 'texture-lab'] }, { id: 'history', label: 'History', toolIds: ['time-travel', 'session-replay'] }, ]; @@ -122,8 +115,7 @@ export function NavMain() { const renderTool = (item: SidebarTool) => { const isDebugger = item.url === '/debugger'; - const worksWithoutSession = item.id === 'shader-graph' || item.id === 'texture-lab'; - const isActive = (hasSession || worksWithoutSession) && item.url === location.pathname; + const isActive = hasSession && item.url === location.pathname; const isPinned = pinnedSidebarTools.includes(item.id); const iconClassName = cn(isPinned && 'text-primary'); const content = ( @@ -131,14 +123,14 @@ export function NavMain() { {item.icon && } {item.title} {isDebugger && isPaused && ( - + )} ); return ( - {hasSession || worksWithoutSession ? ( + {hasSession ? ( state.toggleHiddenPlugin); const [search, setSearch] = useState(''); - const items = useMemo(() => { - const pluginItems = []; - - if (plugins) { - for (const [key, value] of Object.entries(plugins)) { - if (value.tabName) { - pluginItems.push({ - id: key, - name: value.tabName, - url: `/plugins/${key}`, - icon: value.icon, - disabled: value.disabled || false, - }); - } - } - } - - return pluginItems.sort((a, b) => a.name.localeCompare(b.name)); - }, [plugins]); + const items = useMemo(() => pluginNavItems(plugins), [plugins]); const disabledItems = useMemo(() => items.filter((item) => item.disabled), [items]); diff --git a/apps/inspector/src/components/app-sidebar/plugin-nav-items.ts b/apps/inspector/src/components/app-sidebar/plugin-nav-items.ts new file mode 100644 index 00000000..6d3e32e1 --- /dev/null +++ b/apps/inspector/src/components/app-sidebar/plugin-nav-items.ts @@ -0,0 +1,50 @@ +export type PluginNavItem = { + id: string; + name: string; + url: string; + icon?: string; + disabled: boolean; +}; + +/** + * Build the sidebar's plugin entries from the running game's config. + * + * Its own module, with no React in it, because the values here are the least + * trustworthy in the app: `tabName` and `icon` are written in a third-party + * plugin's Lua manifest and reach us through a config payload nothing validates. + * Feather's own runtime is disciplined about its payloads — this is the seam + * where somebody else's code decides what we render. + * + * The old truthiness check (`if (value.tabName)`) admitted any truthy value, so + * a manifest with `tabName = 1234` produced an item whose `name` was a number. + * Sorting called `.localeCompare` on it and the plugin search called + * `.toLowerCase()`, and this list is the sidebar — one odd manifest would have + * taken navigation down for the whole app. + * + * See V4-ENHANCED.md §6.7 (C5). + */ +export function pluginNavItems(plugins: Record | undefined): PluginNavItem[] { + if (!plugins || typeof plugins !== 'object') return []; + + const items: PluginNavItem[] = []; + for (const [id, raw] of Object.entries(plugins)) { + if (!raw || typeof raw !== 'object') continue; + const value = raw as { tabName?: unknown; icon?: unknown; disabled?: unknown }; + if (!value.tabName) continue; + + // Coerced, not trusted. A name that is not a string is still shown — the + // plugin exists and hiding it would be a worse answer than displaying "1234" + // — but it can no longer break the list it appears in. + const name = typeof value.tabName === 'string' ? value.tabName : String(value.tabName); + + items.push({ + id, + name, + url: `/plugins/${id}`, + icon: typeof value.icon === 'string' ? value.icon : undefined, + disabled: value.disabled === true, + }); + } + + return items.sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/components/code.tsx b/apps/inspector/src/components/code.tsx similarity index 73% rename from src/components/code.tsx rename to apps/inspector/src/components/code.tsx index 6c3ac01f..0f4f8011 100644 --- a/src/components/code.tsx +++ b/apps/inspector/src/components/code.tsx @@ -1,5 +1,5 @@ import SyntaxHighlighter from 'react-syntax-highlighter'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { ScrollArea } from '@feather/ui/scroll-area'; import { cn } from '@/utils/styles'; import { useSyntaxTheme } from '@/hooks/use-theme'; @@ -53,10 +53,18 @@ export function TraceViewer({ .replace( filePattern, (_, file, lineNum) => - `${file}:${lineNum}`, + `${file}:${lineNum}`, ) + // These two are syntax highlighting inside a stack trace, not state, so they + // deliberately keep literals rather than borrowing --ok/--info, which would + // make green mean "fine" here and something else everywhere else. They belong + // in the theme's syntax palette — see V4-ENHANCED.md §4 (C0). + /* semantic-exempt:start — syntax highlighting, not state. These live in + template literals, which the no-restricted-syntax rule cannot see into, + so the marker is what verify:enhanced checks instead. */ .replace(inFunctionPattern, `in function`) .replace(quotedPattern, `'$1'`); + /* semantic-exempt:end */ return (
= { observability: FolderSearchIcon, debugger: BugIcon, console: TerminalIcon, - 'particle-system-playground': FileCodeIcon, - 'shader-graph': FileCodeIcon, - 'texture-lab': WandSparklesIcon, assets: FolderSearchIcon, 'time-travel': RotateCcwIcon, 'session-replay': RotateCcwIcon, @@ -221,6 +212,7 @@ export function CommandCenter() { const storeConfig = useConfigStore((state) => state.config); const setDisconnected = useConfigStore((state) => state.setDisconnected); const setLogOverride = useConfigStore((state) => state.setLogOverride); + const openSettings = useSettingsStore((state) => state.setOpen); const hiddenMainFeatures = useSettingsStore((state) => state.hiddenMainFeatures); const showHiddenMainFeaturesInCommandCenter = useSettingsStore( (state) => state.showHiddenMainFeaturesInCommandCenter, @@ -303,7 +295,7 @@ export function CommandCenter() { keywords: [feature.id, feature.url, 'page', 'tool'], icon: featureIcons[feature.id], actionKind: 'navigate' as const, - disabled: !hasSession && feature.id !== 'shader-graph' && feature.id !== 'texture-lab', + disabled: !hasSession, disabledReason: sessionRequired, badges: hiddenMainFeatures.includes(feature.id) ? [{ label: 'Hidden', variant: 'secondary' as const }] @@ -542,6 +534,25 @@ export function CommandCenter() { run: () => closeAndRun(() => openUrl(doc.url)), })); + // Settings were the one thing the palette could not find, in a list that + // already promised pages, plugins, snippets, sessions and docs. 1,789 lines + // of settings across four sections, reachable only by opening the dialog and + // hunting — the longest common path in the app. + // + // Built from `settingsTabs` rather than a copy of it, so a new section shows + // up here without anyone remembering to add it. Section granularity is + // deliberate: enumerating individual toggles would drift the moment one is + // renamed, and lands you in the same place anyway. + const settingsItems: CommandCenterItem[] = settingsTabs.map((tab) => ({ + id: `settings:${tab.value}`, + title: `Settings — ${tab.label}`, + subtitle: tab.description, + keywords: ['settings', 'preferences', 'options', tab.value, tab.label.toLowerCase()], + icon: tab.icon, + actionKind: 'navigate' as const, + run: () => closeAndRun(() => openSettings(true, tab.value)), + })); + return [ { id: 'pages', title: 'Pages', items: pages }, { id: 'plugins', title: 'Plugins', items: plugins }, @@ -549,6 +560,7 @@ export function CommandCenter() { { id: 'debugger', title: 'Debugger', items: debuggerItems }, { id: 'hot-reload', title: 'Hot Reload', items: hotReloadItems }, { id: 'session', title: 'Session', items: sessionItems }, + { id: 'settings', title: 'Settings', items: settingsItems }, { id: 'docs', title: 'Docs', items: docs }, ].filter((group) => group.items.length > 0); }, [ @@ -558,6 +570,7 @@ export function CommandCenter() { hiddenPlugins, hotReload, navigate, + openSettings, queryClient, savedSnippets, sessionId, @@ -607,7 +620,7 @@ export function CommandCenter() { > Command Center - Search pages, plugins, snippets, debugger actions, sessions, and docs. + Search pages, plugins, snippets, debugger actions, sessions, settings, and docs.
@@ -633,7 +646,7 @@ export function CommandCenter() { setOpen(false); } }} - placeholder="Search pages, plugins, snippets, sessions, docs..." + placeholder="Search pages, plugins, snippets, settings, docs..." className="h-11 pl-9 pr-24" aria-label="Command Center search" /> diff --git a/src/components/data-table.tsx b/apps/inspector/src/components/data-table.tsx similarity index 92% rename from src/components/data-table.tsx rename to apps/inspector/src/components/data-table.tsx index 50154cd5..d83ab54e 100644 --- a/src/components/data-table.tsx +++ b/apps/inspector/src/components/data-table.tsx @@ -17,12 +17,12 @@ import { import { open } from '@tauri-apps/plugin-dialog'; import { TableVirtuoso } from 'react-virtuoso'; import { LogTypeBadge } from '@/components/log-type-badge'; -import { TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Tabs, TabsContent } from '@/components/ui/tabs'; +import { TableCell, TableHead, TableHeader, TableRow } from '@feather/ui/table'; +import { Tabs, TabsContent } from '@feather/ui/tabs'; import { cn } from '@/utils/styles'; import { Log } from '@/hooks/use-logs'; import { isWeb } from '@/utils/platform'; -import { Input } from './ui/input'; +import { Input } from '@feather/ui/input'; import { PauseIcon, PlayIcon, @@ -31,8 +31,8 @@ import { Trash2Icon, UploadIcon, } from 'lucide-react'; -import { Button } from './ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; +import { Button } from '@feather/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@feather/ui/tooltip'; import { Dialog, DialogClose, @@ -42,8 +42,8 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from './ui/dialog'; -import { Label } from './ui/label'; +} from '@feather/ui/dialog'; +import { Label } from '@feather/ui/label'; // Original Table is wrapped with a
(see https://ui.shadcn.com/docs/components/table#radix-:r24:-content-manual), // but here we don't want it, so let's use a new component with only tag @@ -224,9 +224,9 @@ export function DataTable({ @@ -239,7 +239,7 @@ export function DataTable({ @@ -252,9 +252,9 @@ export function DataTable({ @@ -270,7 +270,7 @@ export function DataTable({ @@ -293,7 +293,7 @@ export function DataTable({ ) : ( diff --git a/apps/inspector/src/components/freshness.tsx b/apps/inspector/src/components/freshness.tsx new file mode 100644 index 00000000..7665ec03 --- /dev/null +++ b/apps/inspector/src/components/freshness.tsx @@ -0,0 +1,62 @@ +import { CircleAlertIcon, RotateCwIcon } from 'lucide-react'; + +import { cn } from '@/utils/styles'; +import { useFreshness } from '@/hooks/use-freshness'; + +/** + * Says when a panel's data arrived, and says loudly when it is from a previous + * run of the game. + * + * The quiet case is deliberately quiet: muted, small, no icon. Most of the time + * the answer is "seconds ago" and the reader should be able to skip it. The + * stale case is not quiet, because it is the one that sends someone chasing a + * bug in data that predates their fix. + * + * See V4-ENHANCED.md §5 (C2). + */ +export function Freshness({ + queryKey, + onRefresh, + className, + label = 'Updated', +}: { + queryKey: readonly unknown[]; + /** Offered only when the data is stale — a refresh you never needed is noise. */ + onRefresh?: () => void; + className?: string; + label?: string; +}) { + const freshness = useFreshness(queryKey); + + if (freshness.never) { + return Not loaded yet; + } + + if (freshness.fromPreviousRun) { + return ( + + + From a previous run + {onRefresh && ( + + )} + + ); + } + + return ( + + {label} {freshness.label} + + ); +} diff --git a/src/components/log-type-badge.tsx b/apps/inspector/src/components/log-type-badge.tsx similarity index 62% rename from src/components/log-type-badge.tsx rename to apps/inspector/src/components/log-type-badge.tsx index f9657816..bbc2bc27 100644 --- a/src/components/log-type-badge.tsx +++ b/apps/inspector/src/components/log-type-badge.tsx @@ -1,7 +1,7 @@ import { CircleXIcon, FeatherIcon, FileClockIcon, FileQuestionMarkIcon } from 'lucide-react'; import { DynamicIcon, type IconName } from 'lucide-react/dynamic'; import type { ReactNode } from 'react'; -import { Badge } from '@/components/ui/badge'; +import { Badge } from '@feather/ui/badge'; import { LogType } from '@/hooks/use-logs'; import { useConfigStore } from '@/store/config'; import { cn } from '@/utils/styles'; @@ -15,30 +15,45 @@ function isFeatherEvent(type: string) { return type === LogType.FEATHER_FINISH || type === LogType.FEATHER_START; } +/** + * Badges are tinted chips rather than solid fills. + * + * The fills they replaced (`bg-red-700 text-white dark:bg-red-400 + * dark:text-red-950`) hand-picked a pair per mode and had no contrast guarantee + * on any of the themes Feather ships. The chip pairing does: the theme test + * asserts every `--` clears AA on its own `---surface`. + * + * It also reads better where it matters. Dozens of these stack in the log list, + * and a column of solid saturated blocks is the shouting chrome that makes a + * genuine error harder to pick out, not easier. + */ function logTypeTone(type: string): LogTypeTone { if (type === 'output') { return { - badgeClass: 'bg-cyan-700 text-white dark:bg-cyan-400 dark:text-cyan-950', + badgeClass: 'bg-info-surface text-info border-info-border', icon: , }; } if (type === 'error' || type === 'fatal') { return { - badgeClass: 'bg-red-700 text-white dark:bg-red-400 dark:text-red-950', + badgeClass: 'bg-danger-surface text-danger border-danger-border', icon: , }; } + // Runtime lifecycle. A game that started and finished is healthy, so this is + // an ok signal — the amber it used to wear implied a problem that is not there. if (isFeatherEvent(type)) { return { - badgeClass: 'bg-yellow-700 text-white dark:bg-yellow-400 dark:text-yellow-950', + badgeClass: 'bg-ok-surface text-ok border-ok-border', icon: , }; } + // Unrecognized types carry no state, so they stay chrome. return { - badgeClass: 'bg-gray-700 text-white dark:bg-gray-400 dark:text-gray-950', + badgeClass: 'bg-muted text-muted-foreground border-border', icon: , }; } diff --git a/src/components/mobile-connection.tsx b/apps/inspector/src/components/mobile-connection.tsx similarity index 93% rename from src/components/mobile-connection.tsx rename to apps/inspector/src/components/mobile-connection.tsx index 2808ea89..80968162 100644 --- a/src/components/mobile-connection.tsx +++ b/apps/inspector/src/components/mobile-connection.tsx @@ -1,12 +1,12 @@ import { useEffect, useState, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Button } from '@/components/ui/button'; +import { Label } from '@feather/ui/label'; +import { Input } from '@feather/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@feather/ui/select'; +import { Button } from '@feather/ui/button'; import { useSettingsStore } from '@/store/settings'; import { CheckIcon, CopyIcon, SmartphoneIcon } from 'lucide-react'; -import { Separator } from '@/components/ui/separator'; +import { Separator } from '@feather/ui/separator'; type LocalIp = { ip: string; @@ -100,7 +100,7 @@ export function MobileConnection() {
{wsUrl}
diff --git a/src/components/page-layout.tsx b/apps/inspector/src/components/page-layout.tsx similarity index 100% rename from src/components/page-layout.tsx rename to apps/inspector/src/components/page-layout.tsx diff --git a/src/components/session-tabs.tsx b/apps/inspector/src/components/session-tabs.tsx similarity index 62% rename from src/components/session-tabs.tsx rename to apps/inspector/src/components/session-tabs.tsx index 2510a15c..0e30084c 100644 --- a/src/components/session-tabs.tsx +++ b/apps/inspector/src/components/session-tabs.tsx @@ -1,11 +1,4 @@ -import { useState } from 'react'; -import { - createCreativeSessionId, - isCreativeSession, - sessionSupportsRuntime, - useSessionStore, - type SessionInfo, -} from '@/store/session'; +import { sessionSupportsRuntime, useSessionStore, type SessionInfo } from '@/store/session'; import { Config, useConfigStore } from '@/store/config'; import { useQueryClient } from '@tanstack/react-query'; import { sessionQueryKey, type TimeTravelFrame, type TimeTravelStatus } from '@/hooks/use-ws-connection'; @@ -29,35 +22,28 @@ import { ClockIcon, PauseIcon, PlayIcon, - SparklesIcon, } from 'lucide-react'; -import { version } from '../../package.json'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { Button } from '@/components/ui/button'; +import { version } from '../../../../package.json'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@feather/ui/tooltip'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from '@/components/ui/context-menu'; +} from '@feather/ui/context-menu'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; +} from '@feather/ui/dropdown-menu'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { readTextFile } from '@tauri-apps/plugin-fs'; -import { useLocation, useNavigate } from 'react-router'; +import { useNavigate } from 'react-router'; import { toast } from 'sonner'; import { sendCommand } from '@/lib/send-command'; -import { useShaderGraphStore } from '@/store/shader-graph'; -import { useSettingsStore } from '@/store/settings'; -import { deleteLocalParticleWorkspace } from '@/showcase/use-local-particle-playground'; const osIcons: Record = { Windows: , @@ -88,38 +74,6 @@ function createFileConfig(path: string, name: string): Config { }; } -function createCreativeConfig(sessionId: string, name: string): Config { - return { - plugins: {}, - root_path: '', - version, - API: 0, - sampleRate: 1, - outfile: '', - language: 'lua', - captureScreenshot: false, - location: sessionId, - sourceDir: '', - sessionName: name, - sysInfo: { - os: 'Creative', - arch: 'local', - cpuCount: 0, - }, - }; -} - -function normalizeCreativeName(name: string, index: number): string { - const trimmed = name.trim().replace(/\s+/g, ' ').slice(0, 64); - return trimmed || `Creative Workspace ${index}`; -} - -function isCreativeRoute(pathname: string): boolean { - return pathname.startsWith('/shader-graph') || - pathname.startsWith('/particle-system-playground') || - pathname.startsWith('/texture-lab'); -} - function SessionTab({ session, isActive, @@ -137,7 +91,6 @@ function SessionTab({ }) { const osIcon = session.os ? osIcons[session.os] : undefined; const FileIcon = session.kind === 'time-travel-file' ? ClockIcon : session.kind === 'log-file' ? FileTextIcon : null; - const creative = isCreativeSession(session); const tab = ( @@ -154,12 +107,10 @@ function SessionTab({ - {creative ? ( - - ) : FileIcon ? ( + {FileIcon ? ( ) : osIcon ? ( {osIcon} @@ -167,8 +118,8 @@ function SessionTab({ )} {session.name || session.id.slice(0, 8)} - {versionMismatch && } - {session.insecure && } + {versionMismatch && } + {session.insecure && } - setCreateDialogOpen(true)}> - - New creative workspace - - Open log file @@ -456,7 +333,7 @@ export function SessionTabs() { type="button" className={cn( 'flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground', - activeSession.runtimeSuspended && 'bg-amber-500/10 text-amber-700 dark:text-amber-300', + activeSession.runtimeSuspended && 'bg-warn-surface text-warn', )} title={activeSession.runtimeSuspended ? 'Resume Feather runtime' : 'Suspend Feather runtime'} aria-pressed={activeSession.runtimeSuspended === true} @@ -488,10 +365,6 @@ export function SessionTabs() { onClick={() => handleSessionClick(session)} onReload={() => refreshLiveSession(session)} onRemove={() => { - if (isCreativeSession(session)) { - setRemoveCreativeSession(session); - return; - } queryClient.removeQueries({ queryKey: [session.id] }); removeSession(session.id); }} @@ -499,59 +372,6 @@ export function SessionTabs() { /> ); })} - - - - New creative workspace - - Create a local workspace for Shader Graph, Particle Playground, and Texture Lab without connecting a game. - - -
{ - event.preventDefault(); - createCreativeWorkspace(); - }} - > - - - - - - -
-
- !open && setRemoveCreativeSession(null)}> - - - Remove creative workspace? - - This deletes the local Shader Graph, Particle Playground, and Texture Lab state for{' '} - {removeCreativeSession?.name ?? 'this workspace'}. - - - - - - - - ); } diff --git a/src/components/site-header.tsx b/apps/inspector/src/components/site-header.tsx similarity index 87% rename from src/components/site-header.tsx rename to apps/inspector/src/components/site-header.tsx index 38c31b80..9c215a87 100644 --- a/src/components/site-header.tsx +++ b/apps/inspector/src/components/site-header.tsx @@ -1,8 +1,8 @@ -import { Button } from '@/components/ui/button'; +import { Button } from '@feather/ui/button'; import { CommandCenterTrigger } from '@/components/command-center'; -import { Separator } from '@/components/ui/separator'; -import { SidebarTrigger } from '@/components/ui/sidebar'; -import { Badge } from './ui/badge'; +import { Separator } from '@feather/ui/separator'; +import { SidebarTrigger } from '@feather/ui/sidebar'; +import { Badge } from '@feather/ui/badge'; import { FolderOpenIcon } from 'lucide-react'; import { useConfigStore } from '@/store/config'; import { openFolder } from '@/utils/linking'; diff --git a/src/components/theme.tsx b/apps/inspector/src/components/theme.tsx similarity index 93% rename from src/components/theme.tsx rename to apps/inspector/src/components/theme.tsx index 0a45a58f..7d761a59 100644 --- a/src/components/theme.tsx +++ b/apps/inspector/src/components/theme.tsx @@ -1,6 +1,6 @@ import { useSettingsStore } from '@/store/settings'; import { useLayoutEffect } from 'react'; -import { resolveTheme } from '@/assets/theme/registry'; +import { resolveTheme } from '@feather/ui/theme/registry'; import { useSystemThemeMode } from '@/hooks/use-theme'; export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { diff --git a/src/components/triage/index.tsx b/apps/inspector/src/components/triage/index.tsx similarity index 87% rename from src/components/triage/index.tsx rename to apps/inspector/src/components/triage/index.tsx index 28e819af..0ab24d34 100644 --- a/src/components/triage/index.tsx +++ b/apps/inspector/src/components/triage/index.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { CheckIcon, CopyIcon, SearchIcon, XIcon } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Badge } from '@feather/ui/badge'; +import { Button } from '@feather/ui/button'; +import { Input } from '@feather/ui/input'; +import { ScrollArea } from '@feather/ui/scroll-area'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@feather/ui/tooltip'; import { copyToClipboardWithMeta } from '@/utils/strings'; import { cn } from '@/utils/styles'; @@ -26,9 +26,9 @@ export type TriageSummaryItem = { }; function toneClass(tone: TriageTone = 'default') { - if (tone === 'good') return 'border-emerald-500/40 text-emerald-700 dark:text-emerald-300'; - if (tone === 'warning') return 'border-amber-500/40 text-amber-700 dark:text-amber-300'; - if (tone === 'danger') return 'border-destructive/40 text-destructive'; + if (tone === 'good') return 'border-ok-border text-ok'; + if (tone === 'warning') return 'border-warn-border text-warn'; + if (tone === 'danger') return 'border-danger-border text-danger'; if (tone === 'muted') return 'border-muted-foreground/25 text-muted-foreground'; return 'border-border text-foreground'; } @@ -137,15 +137,23 @@ export function TriageSummaryChips({ items }: { items: TriageSummaryItem[] }) { ); } +/** + * `action` exists because filters now outlive the visit that set them (C1). + * "No rows match the current filters" used to be self-explanatory — the filter + * was something you had just typed. It can now be days old, so the state that + * reports it should also be the one that undoes it. + */ export function TriageEmptyState({ title, description, icon, + action, className, }: { title: React.ReactNode; description?: React.ReactNode; icon?: React.ReactNode; + action?: React.ReactNode; className?: string; }) { return ( @@ -154,6 +162,7 @@ export function TriageEmptyState({ {icon ?
{icon}
: null}

{title}

{description ?

{description}

: null} + {action ?
{action}
: null} ); @@ -194,7 +203,7 @@ export function TriageCopyButton({ window.setTimeout(() => setCopied(false), 1400); }} > - {children ?? (copied ? : )} + {children ?? (copied ? : )}
{copied ? `Copied ${label}` : title ?? `Copy ${label}`} diff --git a/src/constants/feather-api.ts b/apps/inspector/src/constants/feather-api.ts similarity index 100% rename from src/constants/feather-api.ts rename to apps/inspector/src/constants/feather-api.ts diff --git a/apps/inspector/src/constants/feather-protocol.ts b/apps/inspector/src/constants/feather-protocol.ts new file mode 100644 index 00000000..5c96d571 --- /dev/null +++ b/apps/inspector/src/constants/feather-protocol.ts @@ -0,0 +1,87 @@ +/** + * Wire protocol compatibility between the desktop app and the Lua runtime. + * + * This is NOT the plugin API version (`FEATHER_PLUGIN_API`). That one gates + * which plugins a runtime can load. This one gates whether the runtime and the + * desktop app can talk to each other at all. + * + * They are separate because the runtime and the desktop app ship on separate + * release trains (see V4.md section 6), so their version numbers are expected + * to differ. Before v4 they were kept equal by lock-step releases and nothing + * needed to be negotiated. + * + * The runtime reports its version as `protocolVersion` in the `feather:hello` + * config payload. Keep this in sync with `FEATHER_PROTOCOL_VERSION` in + * `packages/runtime-lua/feather/init.lua` — `npm run check:protocol` enforces it. + */ + +/** Wire protocol version this desktop build speaks. */ +export const FEATHER_PROTOCOL_VERSION = 1; + +/** + * Oldest runtime protocol version this desktop build can still talk to. + * + * Raise this only when dropping support for an old runtime is deliberate. + */ +export const FEATHER_PROTOCOL_MIN_SUPPORTED = 1; + +export type ProtocolStatus = 'ok' | 'legacy' | 'runtime-too-old' | 'runtime-too-new'; + +export interface ProtocolCompatibility { + status: ProtocolStatus; + /** The runtime's reported version, or null when it reported none. */ + runtime: number | null; + /** True when the desktop app should not trust command/response round-trips. */ + incompatible: boolean; + /** Short, specific description of what is wrong. Empty when status is 'ok'. */ + summary: string; + /** What the user should actually do about it. Empty when status is 'ok'. */ + remediation: string; +} + +/** + * Compare a runtime-reported protocol version against what this build supports. + * + * A runtime that reports nothing is treated as `legacy`, not as broken: every + * runtime released before this negotiation existed omits the field, and those + * sessions still work. It is surfaced as guidance, not as a failure. + */ +export function evaluateProtocol(protocolVersion: unknown): ProtocolCompatibility { + if (typeof protocolVersion !== 'number' || !Number.isFinite(protocolVersion)) { + return { + status: 'legacy', + runtime: null, + incompatible: false, + summary: 'This game is running a Feather runtime from before protocol negotiation.', + remediation: `Update the Feather runtime in your game to report a protocol version. The desktop app speaks protocol v${FEATHER_PROTOCOL_VERSION}.`, + }; + } + + if (protocolVersion < FEATHER_PROTOCOL_MIN_SUPPORTED) { + return { + status: 'runtime-too-old', + runtime: protocolVersion, + incompatible: true, + summary: `The game speaks protocol v${protocolVersion}; this desktop app needs at least v${FEATHER_PROTOCOL_MIN_SUPPORTED}.`, + remediation: 'Update the Feather runtime in your game, or install an older desktop app that still supports it.', + }; + } + + if (protocolVersion > FEATHER_PROTOCOL_VERSION) { + return { + status: 'runtime-too-new', + runtime: protocolVersion, + incompatible: true, + summary: `The game speaks protocol v${protocolVersion}; this desktop app only speaks v${FEATHER_PROTOCOL_VERSION}.`, + remediation: 'Update the Feather desktop app, or pin the game to an older Feather runtime.', + }; + } + + return { + status: 'ok', + runtime: protocolVersion, + incompatible: false, + summary: '', + remediation: '', + }; +} diff --git a/src/constants/main-features.ts b/apps/inspector/src/constants/main-features.ts similarity index 76% rename from src/constants/main-features.ts rename to apps/inspector/src/constants/main-features.ts index 50b85be5..6306bcdf 100644 --- a/src/constants/main-features.ts +++ b/apps/inspector/src/constants/main-features.ts @@ -4,9 +4,6 @@ export const MAIN_FEATURES = [ { id: 'observability', title: 'Observability', url: '/observability' }, { id: 'debugger', title: 'Debugger', url: '/debugger' }, { id: 'console', title: 'Console', url: '/console' }, - { id: 'particle-system-playground', title: 'Particles Playground', url: '/particle-system-playground' }, - { id: 'shader-graph', title: 'Shader Graph', url: '/shader-graph' }, - { id: 'texture-lab', title: 'Texture Lab', url: '/texture-lab' }, { id: 'assets', title: 'Assets', url: '/assets' }, { id: 'time-travel', title: 'Time Travel', url: '/time-travel' }, { id: 'session-replay', title: 'Session Replay', url: '/session-replay' }, @@ -27,9 +24,6 @@ export const SIDEBAR_TOOL_ORDER: SidebarToolId[] = [ 'debugger', 'console', 'assets', - 'particle-system-playground', - 'shader-graph', - 'texture-lab', 'time-travel', 'session-replay', ]; diff --git a/src/constants/server.ts b/apps/inspector/src/constants/server.ts similarity index 100% rename from src/constants/server.ts rename to apps/inspector/src/constants/server.ts diff --git a/src/hooks/use-assets.ts b/apps/inspector/src/hooks/use-assets.ts similarity index 100% rename from src/hooks/use-assets.ts rename to apps/inspector/src/hooks/use-assets.ts diff --git a/src/hooks/use-config.ts b/apps/inspector/src/hooks/use-config.ts similarity index 100% rename from src/hooks/use-config.ts rename to apps/inspector/src/hooks/use-config.ts diff --git a/src/hooks/use-console.ts b/apps/inspector/src/hooks/use-console.ts similarity index 96% rename from src/hooks/use-console.ts rename to apps/inspector/src/hooks/use-console.ts index ac8a07a5..adb2e126 100644 --- a/src/hooks/use-console.ts +++ b/apps/inspector/src/hooks/use-console.ts @@ -1,6 +1,6 @@ import { useCallback, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { sendCommand } from '@/lib/send-command'; +import { sendCommand, sendUserCommand } from '@/lib/send-command'; import { useSessionStore } from '@/store/session'; import { useEffectiveApiKey } from './use-session-api-key'; import { @@ -131,7 +131,7 @@ export const useConsole = () => { const unpinExpression = useCallback( (id: string) => { if (!sessionId) return; - sendCommand(sessionId, { type: 'cmd:console:unpin', data: { id } }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:console:unpin', data: { id } }, 'unpin the value'); }, [sessionId], ); diff --git a/src/hooks/use-debugger.ts b/apps/inspector/src/hooks/use-debugger.ts similarity index 100% rename from src/hooks/use-debugger.ts rename to apps/inspector/src/hooks/use-debugger.ts diff --git a/apps/inspector/src/hooks/use-freshness.ts b/apps/inspector/src/hooks/use-freshness.ts new file mode 100644 index 00000000..baac7966 --- /dev/null +++ b/apps/inspector/src/hooks/use-freshness.ts @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useSessionStore } from '@/store/session'; + +/** + * How old is what I am looking at, and is it from this run? + * + * Inspector has 45 ways to refresh a panel and, until now, no way to tell + * whether you needed to. Panels deliberately go dormant when you leave — that is + * what keeps Feather's runtime overhead low — so a panel you come back to can be + * showing state from ten minutes and three game restarts ago, rendered + * identically to live data. The user was given a button to fix a problem they + * could not see. + * + * The honest signal is not age. It is whether the data predates the session + * currently attached: data that arrived before this game started is from a + * previous run, full stop, however recent the clock says it is. + * + * See V4-ENHANCED.md §5 (C2). + */ + +/** Below this, "just now" is more honest than a number that churns every second. */ +const JUST_NOW_MS = 3_000; + +export type Freshness = { + /** When this data last arrived, or null if it never has. */ + updatedAt: number | null; + /** Age in milliseconds, or null if it never arrived. */ + ageMs: number | null; + /** True when the data predates the attached session — it is from a previous run. */ + fromPreviousRun: boolean; + /** Nothing has ever arrived for this key. */ + never: boolean; + /** "just now", "12s ago", "4m ago" — or null when there is nothing to describe. */ + label: string | null; +}; + +export function describeAge(ageMs: number): string { + if (ageMs < JUST_NOW_MS) return 'just now'; + const seconds = Math.floor(ageMs / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +/** + * Did this data arrive before the attached session started? + * + * The whole judgement, kept as a pure function so it can be tested without a + * React tree. Age is not the signal — a panel showing a value from 40 seconds + * ago is fine if the game has been up for an hour, and wrong if the game + * restarted 5 seconds ago. + */ +export function isFromPreviousRun(updatedAt: number | null, sessionConnectedAt?: number | null): boolean { + if (updatedAt === null || !sessionConnectedAt) return false; + return updatedAt < sessionConnectedAt; +} + +/** + * Track freshness for one query key. + * + * Ticks only while there is something to describe, and only once a second — a + * freshness label that re-renders a panel faster than the data changes would be + * its own kind of noise. + */ +export function useFreshness(queryKey: readonly unknown[]): Freshness { + const queryClient = useQueryClient(); + const activeSession = useSessionStore((state) => (state.sessionId ? state.sessions[state.sessionId] : null)); + const [now, setNow] = useState(() => Date.now()); + + const state = queryClient.getQueryState(queryKey); + const updatedAt = state?.dataUpdatedAt && state.dataUpdatedAt > 0 ? state.dataUpdatedAt : null; + + useEffect(() => { + if (updatedAt === null) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [updatedAt]); + + if (updatedAt === null) { + return { updatedAt: null, ageMs: null, fromPreviousRun: false, never: true, label: null }; + } + + const ageMs = Math.max(0, now - updatedAt); + const fromPreviousRun = isFromPreviousRun(updatedAt, activeSession?.connectedAt); + + return { + updatedAt, + ageMs, + fromPreviousRun, + never: false, + label: describeAge(ageMs), + }; +} diff --git a/src/hooks/use-gif.ts b/apps/inspector/src/hooks/use-gif.ts similarity index 100% rename from src/hooks/use-gif.ts rename to apps/inspector/src/hooks/use-gif.ts diff --git a/src/hooks/use-hot-reload.ts b/apps/inspector/src/hooks/use-hot-reload.ts similarity index 96% rename from src/hooks/use-hot-reload.ts rename to apps/inspector/src/hooks/use-hot-reload.ts index 67341b75..20fd83e1 100644 --- a/src/hooks/use-hot-reload.ts +++ b/apps/inspector/src/hooks/use-hot-reload.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { sendCommand } from '@/lib/send-command'; +import { sendCommand, sendUserCommand } from '@/lib/send-command'; import { useQuery } from '@tanstack/react-query'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; @@ -108,7 +108,7 @@ export const useHotReload = () => { const restoreOriginals = useMemo(() => { return () => { if (!sessionId) return; - sendCommand(sessionId, { type: 'cmd:hot_reload:restore' }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:hot_reload:restore' }, 'restore the module'); }; }, [sessionId]); diff --git a/src/hooks/use-latest-version.ts b/apps/inspector/src/hooks/use-latest-version.ts similarity index 95% rename from src/hooks/use-latest-version.ts rename to apps/inspector/src/hooks/use-latest-version.ts index fbdcb0b8..65352107 100644 --- a/src/hooks/use-latest-version.ts +++ b/apps/inspector/src/hooks/use-latest-version.ts @@ -1,7 +1,7 @@ import { Servers } from '@/constants/server'; import { timeout } from '@/utils/timers'; import { useQuery } from '@tanstack/react-query'; -import { version } from '../../package.json'; +import { version } from '../../../../package.json'; import { useSettingsStore } from '@/store/settings'; import { isGreaterOrEqual } from '@/utils/versions'; diff --git a/src/hooks/use-logs.ts b/apps/inspector/src/hooks/use-logs.ts similarity index 85% rename from src/hooks/use-logs.ts rename to apps/inspector/src/hooks/use-logs.ts index 4b8a6573..b5725a45 100644 --- a/src/hooks/use-logs.ts +++ b/apps/inspector/src/hooks/use-logs.ts @@ -1,6 +1,6 @@ import { readTextFileLines } from '@tauri-apps/plugin-fs'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { sendCommand } from '@/lib/send-command'; +import { sendCommand, sendUserCommand } from '@/lib/send-command'; import { timeout } from '@/utils/timers'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; @@ -19,6 +19,18 @@ export enum LogType { FEATHER_FINISH = 'feather:finish', } +/** + * `str` and `trace` carry defaults rather than being required. + * + * They were declared `z.string()` while nothing ever validated against this + * schema, so the type promised a string and the runtime delivered `undefined` + * whenever a log arrived without them — which is how searching the logs could + * throw on `log.str.toLowerCase()`. + * + * Defaulting rather than rejecting is deliberate: a log line missing its message + * is still evidence that something happened, and dropping it would hide exactly + * the malformed output someone is most likely to be hunting. + */ export const schema = z.object({ id: z.string(), count: z.number(), @@ -26,8 +38,8 @@ export const schema = z.object({ firstTime: z.number().optional(), lastTime: z.number().optional(), type: z.string(), - str: z.string(), - trace: z.string(), + str: z.string().default(''), + trace: z.string().default(''), screenshot: z.string().optional(), }); @@ -37,7 +49,10 @@ function parseLogLine(line: string): Log | null { const jsonStart = line.indexOf('{'); if (jsonStart === -1) return null; try { - return JSON.parse(line.slice(jsonStart)); + // Parsed through the schema, not cast past it. This is the boundary the + // schema existed for and was never wired to. + const parsed = schema.safeParse(JSON.parse(line.slice(jsonStart))); + return parsed.success ? parsed.data : null; } catch { return null; } @@ -179,7 +194,7 @@ export const useLogs = (): { ); const history = useLogHistoryStore.getState(); history.removeLogs(sessionId, visibleIds, history.sessionHistoryKeys[sessionId]); - sendCommand(sessionId, { type: 'cmd:log', action: 'clear' }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:log', action: 'clear' }, 'clear the logs'); } setPausedSnapshot((current) => current && visibleSet ? current.filter((log) => !visibleSet.has(log.id)) : visibleSet ? current : [], diff --git a/src/hooks/use-observability.ts b/apps/inspector/src/hooks/use-observability.ts similarity index 100% rename from src/hooks/use-observability.ts rename to apps/inspector/src/hooks/use-observability.ts diff --git a/src/hooks/use-performance.ts b/apps/inspector/src/hooks/use-performance.ts similarity index 100% rename from src/hooks/use-performance.ts rename to apps/inspector/src/hooks/use-performance.ts diff --git a/src/hooks/use-plugin-control.ts b/apps/inspector/src/hooks/use-plugin-control.ts similarity index 100% rename from src/hooks/use-plugin-control.ts rename to apps/inspector/src/hooks/use-plugin-control.ts diff --git a/src/hooks/use-plugin.ts b/apps/inspector/src/hooks/use-plugin.ts similarity index 100% rename from src/hooks/use-plugin.ts rename to apps/inspector/src/hooks/use-plugin.ts diff --git a/src/hooks/use-profiler.ts b/apps/inspector/src/hooks/use-profiler.ts similarity index 65% rename from src/hooks/use-profiler.ts rename to apps/inspector/src/hooks/use-profiler.ts index 1cd1d964..86756f42 100644 --- a/src/hooks/use-profiler.ts +++ b/apps/inspector/src/hooks/use-profiler.ts @@ -59,6 +59,30 @@ export const EMPTY_PROFILER_STATE: ProfilerState = { data: [], }; +/** + * What a new session inherits from the run before it. + * + * Comparing before and after a change is the entire point of a profiler, and + * making that change means restarting the game. The session migration carried + * logs, metrics, observers and assets across but not this, so the one comparison + * the feature exists for was the one it could not make. + * + * Only the snapshots travel. Live capture state belongs to the process that + * produced it: `recording: true` in a fresh run shows a capture in progress that + * nothing is filling, and a carried-over elapsed time silently attributes the old + * run's duration to the new one. Both would be the tool lying about what it is + * measuring, which is worse than losing the baseline. + * + * Returns null when there is nothing worth carrying, so the caller can skip the + * write rather than replacing real state with an empty shell. + * + * See V4-ENHANCED.md §6.3. + */ +export function profilerStateForNewSession(previous: ProfilerState | undefined): ProfilerState | null { + if (!previous?.snapshots?.length) return null; + return { ...EMPTY_PROFILER_STATE, snapshots: previous.snapshots }; +} + type ProfilerAction = 'start' | 'stop' | 'reset' | 'snapshot' | 'refresh'; export function useProfiler() { diff --git a/apps/inspector/src/hooks/use-server-polling.ts b/apps/inspector/src/hooks/use-server-polling.ts new file mode 100644 index 00000000..f8e4a87c --- /dev/null +++ b/apps/inspector/src/hooks/use-server-polling.ts @@ -0,0 +1,16 @@ +import { sendBackgroundCommand } from '@/lib/send-command'; +/** + * Send a one-shot request to the game for all data. + * Use this for manual reconnect / refresh buttons only. + */ +export const requestAllData = (sessionId: string) => { + sendBackgroundCommand(sessionId, { type: 'req:config' }); + + sendBackgroundCommand(sessionId, { type: 'req:performance' }); + + sendBackgroundCommand(sessionId, { type: 'req:observers' }); + + sendBackgroundCommand(sessionId, { type: 'req:assets' }); + + sendBackgroundCommand(sessionId, { type: 'req:plugins' }); +}; diff --git a/src/hooks/use-server.ts b/apps/inspector/src/hooks/use-server.ts similarity index 100% rename from src/hooks/use-server.ts rename to apps/inspector/src/hooks/use-server.ts diff --git a/src/hooks/use-session-api-key.ts b/apps/inspector/src/hooks/use-session-api-key.ts similarity index 100% rename from src/hooks/use-session-api-key.ts rename to apps/inspector/src/hooks/use-session-api-key.ts diff --git a/src/hooks/use-session-replay.ts b/apps/inspector/src/hooks/use-session-replay.ts similarity index 92% rename from src/hooks/use-session-replay.ts rename to apps/inspector/src/hooks/use-session-replay.ts index 917aa53c..722613aa 100644 --- a/src/hooks/use-session-replay.ts +++ b/apps/inspector/src/hooks/use-session-replay.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { sendCommand } from '@/lib/send-command'; +import { sendCommand, sendUserCommand } from '@/lib/send-command'; import { useSessionStore } from '@/store/session'; import { sessionQueryKey } from './use-ws-connection'; @@ -174,7 +174,7 @@ export function useSessionReplay() { const startRecording = () => { if (!sessionId) return; if (status?.recording) return; - sendCommand(sessionId, { type: 'cmd:session_replay:start' }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:session_replay:start' }, 'start the recording'); }; const stopRecording = () => { @@ -183,7 +183,7 @@ export function useSessionReplay() { ...(prev ?? DEFAULT_STATUS), recording: false, })); - sendCommand(sessionId, { type: 'cmd:session_replay:stop' }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:session_replay:stop' }, 'stop the recording'); }; const requestRecording = (id?: string | null) => { @@ -216,7 +216,7 @@ export function useSessionReplay() { const seekRecording = (target: string | number, play = false) => { if (!sessionId) return; if (status?.recording) return; - sendCommand(sessionId, { type: 'cmd:session_replay:seek', data: { target, play } }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:session_replay:seek', data: { target, play } }, 'seek the replay'); }; const stopReplay = () => { @@ -225,7 +225,7 @@ export function useSessionReplay() { ...(prev ?? DEFAULT_STATUS), replaying: false, })); - sendCommand(sessionId, { type: 'cmd:session_replay:stop_replay' }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:session_replay:stop_replay' }, 'stop the replay'); }; const importRecording = (files: SessionReplayFile[]) => { @@ -240,13 +240,13 @@ export function useSessionReplay() { queryClient.setQueryData(sessionQueryKey.sessionReplayRecording(sessionId), imported); queryClient.setQueryData(sessionQueryKey.sessionReplaySelected(sessionId), id); } - sendCommand(sessionId, { type: 'cmd:session_replay:import', data: { files } }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:session_replay:import', data: { files } }, 'import the replay'); }; const deleteRecording = (id?: string | null) => { if (!sessionId) return; const replayId = id ?? selectedReplayId ?? status?.replayId ?? null; - sendCommand(sessionId, { type: 'cmd:session_replay:delete', data: replayId ? { id: replayId } : {} }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:session_replay:delete', data: replayId ? { id: replayId } : {} }, 'delete the replay'); if (replayId) { queryClient.setQueryData(sessionQueryKey.sessionReplayRecordings(sessionId), (prev) => { const next = { ...(prev ?? {}) }; diff --git a/src/hooks/use-theme.ts b/apps/inspector/src/hooks/use-theme.ts similarity index 95% rename from src/hooks/use-theme.ts rename to apps/inspector/src/hooks/use-theme.ts index 09fc5555..0c9fdd43 100644 --- a/src/hooks/use-theme.ts +++ b/apps/inspector/src/hooks/use-theme.ts @@ -1,5 +1,5 @@ import { useSettingsStore } from '@/store/settings'; -import { resolveTheme, type AppTheme, type SyntaxHighlighterStyle, type ThemeMode } from '@/assets/theme/registry'; +import { resolveTheme, type AppTheme, type SyntaxHighlighterStyle, type ThemeMode } from '@feather/ui/theme/registry'; import { useSyncExternalStore } from 'react'; const prefersDarkQuery = '(prefers-color-scheme: dark)'; diff --git a/src/hooks/use-time-travel.ts b/apps/inspector/src/hooks/use-time-travel.ts similarity index 89% rename from src/hooks/use-time-travel.ts rename to apps/inspector/src/hooks/use-time-travel.ts index 1b93fd6d..4a2ca85a 100644 --- a/src/hooks/use-time-travel.ts +++ b/apps/inspector/src/hooks/use-time-travel.ts @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { sendCommand } from '@/lib/send-command'; +import { sendCommand, sendUserCommand } from '@/lib/send-command'; import { useSessionStore } from '@/store/session'; import { sessionQueryKey, type TimeTravelFrame, type TimeTravelStatus } from './use-ws-connection'; @@ -32,7 +32,7 @@ export const useTimeTravel = () => { if (!sessionId) return; // Optimistically clear stale frames from a previous session queryClient.setQueryData(sessionQueryKey.timeTravelFrames(sessionId), []); - sendCommand(sessionId, { type: 'cmd:time_travel:start' }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:time_travel:start' }, 'start time travel'); }; const stopRecording = () => { @@ -42,7 +42,7 @@ export const useTimeTravel = () => { queryClient.setQueryData(sessionQueryKey.timeTravel(sessionId), (prev) => prev ? { ...prev, recording: false } : DEFAULT_STATUS, ); - sendCommand(sessionId, { type: 'cmd:time_travel:stop' }).catch(() => {}); + sendUserCommand(sessionId, { type: 'cmd:time_travel:stop' }, 'stop time travel'); }; const requestFrames = (fromFrame?: number, toFrame?: number) => { diff --git a/src/hooks/use-ws-connection.ts b/apps/inspector/src/hooks/use-ws-connection.ts similarity index 93% rename from src/hooks/use-ws-connection.ts rename to apps/inspector/src/hooks/use-ws-connection.ts index eb0713d8..ba69c95e 100644 --- a/src/hooks/use-ws-connection.ts +++ b/apps/inspector/src/hooks/use-ws-connection.ts @@ -10,7 +10,7 @@ import { PENDING_SESSION_NAME, useSessionStore } from '@/store/session'; import { useSettingsStore } from '@/store/settings'; import type { Log } from './use-logs'; import type { PerformanceMetrics } from './use-performance'; -import type { ProfilerState } from './use-profiler'; +import { profilerStateForNewSession, type ProfilerState } from './use-profiler'; import type { PluginContentProps, PluginDataType } from './use-plugin'; import type { AssetCatalog } from './use-assets'; import type { HotReloadState } from './use-hot-reload'; @@ -25,7 +25,10 @@ import { toast } from 'sonner'; import { isWeb } from '@/utils/platform'; import { useDebuggerStore, type BreakpointIssue, type DebuggerStatus, type PausedState } from '@/store/debugger'; import { FEATHER_PLUGIN_API } from '@/constants/feather-api'; -import { sendCommand } from '@/lib/send-command'; +import { evaluateProtocol } from '@/constants/feather-protocol'; +import { parseInbound } from '@feather/protocol'; +import { sessionQueryKey } from '@/lib/session-query-keys'; +import { sendCommand, sendBackgroundCommand } from '@/lib/send-command'; import { base64ToUint8Array } from '@/utils/arrays'; import { normalizePerformanceMetric } from '@/utils/performance-metrics'; import { @@ -37,35 +40,8 @@ import { import { shouldRequestSessionConfig } from '@/utils/session-reconnect'; // Cache key helpers — all indexed by the Rust-assigned session ID -export const sessionQueryKey = { - config: (sessionId: string) => [sessionId, 'config'], - logs: (sessionId: string) => [sessionId, 'logs'], - performance: (sessionId: string) => [sessionId, 'performance'], - profiler: (sessionId: string) => [sessionId, 'profiler'], - observers: (sessionId: string) => [sessionId, 'observers'], - assets: (sessionId: string) => [sessionId, 'assets'], - plugin: (sessionId: string, pluginId: string) => [sessionId, 'plugin', pluginId], - pluginAction: (sessionId: string, pluginId: string, action: string) => [sessionId, 'plugin-action', pluginId, action], - console: (sessionId: string) => [sessionId, 'console'], - consoleGlobals: (sessionId: string) => [sessionId, 'console-globals'], - consolePins: (sessionId: string) => [sessionId, 'console-pins'], - consoleInspect: (sessionId: string) => [sessionId, 'console-inspect'], - timeTravel: (sessionId: string) => [sessionId, 'time-travel'], - timeTravelFrames: (sessionId: string) => [sessionId, 'time-travel-frames'], - sessionReplay: (sessionId: string) => [sessionId, 'session-replay'], - sessionReplayRecording: (sessionId: string) => [sessionId, 'session-replay-recording'], - sessionReplayRecordings: (sessionId: string) => [sessionId, 'session-replay-recordings'], - sessionReplayList: (sessionId: string) => [sessionId, 'session-replay-list'], - sessionReplaySelected: (sessionId: string) => [sessionId, 'session-replay-selected'], - hotReload: (sessionId: string) => [sessionId, 'hot-reload'], -}; - -type WsMessage = { - _session: string; - type: string; - data?: unknown; - plugin?: string; -}; +// Re-exported for the many modules that already import it from here. +export { sessionQueryKey }; type BinaryEvent = { _session: string; @@ -244,6 +220,7 @@ export const useWsConnection = () => { const setPausedState = useDebuggerStore((state) => state.setPausedState); const setDebuggerEnabled = useDebuggerStore((state) => state.setEnabled); const lastMessageRef = useRef(Date.now()); + const warnedUnknownTypesRef = useRef>(new Set()); const pendingBinaryRef = useRef>({}); // Keep Rust's app_id in sync with settings so it can validate auth:response. @@ -389,13 +366,19 @@ export const useWsConnection = () => { // Game → desktop messages const unlistenMessage = await listen('feather://message', (event) => { if (cancelled) return; - let msg: WsMessage; - - try { - msg = JSON.parse(event.payload) as WsMessage; - } catch { + const parsed = parseInbound(event.payload); + if (!parsed.ok) { + // Now that the runtime and the desktop ship on separate release + // trains, a newer runtime can legitimately send a message this build + // has never heard of. Report that once per type instead of dropping + // it silently, so version skew is visible rather than mysterious. + if (parsed.reason === 'unknown-type' && parsed.type && !warnedUnknownTypesRef.current.has(parsed.type)) { + warnedUnknownTypesRef.current.add(parsed.type); + console.warn(`[feather] ${parsed.detail}`); + } return; } + const msg = parsed.message; // Any message from the game resets the health timer lastMessageRef.current = Date.now(); @@ -411,7 +394,7 @@ export const useWsConnection = () => { const hasConfig = !!queryClient.getQueryData(sessionQueryKey.config(sessionId)); if (shouldRequestSessionConfig(session, hasConfig)) { markSessionPending(sessionId); - sendCommand(sessionId, { type: 'req:config' }).catch(() => {}); + sendBackgroundCommand(sessionId, { type: 'req:config' }); } } @@ -430,6 +413,17 @@ export const useWsConnection = () => { ); } + // Wire protocol negotiation. Only a genuine incompatibility is + // raised here — a runtime that predates negotiation is reported in + // Session health instead, so it does not nag on every connect. + const protocol = evaluateProtocol(config.protocolVersion); + if (protocol.incompatible) { + toast.error(`Feather protocol mismatch — ${protocol.summary}`, { + description: protocol.remediation, + duration: Infinity, + }); + } + const historyKeys = resolveLogHistoryKeys(config, sessionId); const sessionLabel = config.sessionName || config.root_path?.split('/').pop() || 'Game'; const logHistory = useLogHistoryStore.getState(); @@ -480,6 +474,19 @@ export const useWsConnection = () => { if (oldAssets) { queryClient.setQueryData(sessionQueryKey.assets(sessionId), oldAssets); } + // Profiler snapshots, which this list used to omit. + // + // Comparing before and after a change is the entire point of a + // profiler, and making that change means restarting the game. So + // the one comparison the feature exists for was the one it could + // not do: the baseline was dropped by the cleanup below while + // logs and metrics were carefully carried across. + const carriedProfiler = profilerStateForNewSession( + queryClient.getQueryData(sessionQueryKey.profiler(oldSession.id)), + ); + if (carriedProfiler) { + queryClient.setQueryData(sessionQueryKey.profiler(sessionId), carriedProfiler); + } // Clean up old session cache queryClient.removeQueries({ queryKey: [oldSession.id] }); } @@ -972,7 +979,7 @@ export const useWsConnection = () => { const unlistenStart = await listen('feather://session-start', (event) => { if (cancelled) return; markSessionPending(event.payload); - sendCommand(event.payload, { type: 'req:config' }).catch(() => {}); + sendBackgroundCommand(event.payload, { type: 'req:config' }); }); if (cancelled) { @@ -997,7 +1004,7 @@ export const useWsConnection = () => { const hasConfig = !!queryClient.getQueryData(sessionQueryKey.config(sessionId)); if (!shouldRequestSessionConfig(session, hasConfig)) return; markSessionPending(sessionId); - sendCommand(sessionId, { type: 'req:config' }).catch(() => {}); + sendBackgroundCommand(sessionId, { type: 'req:config' }); }); }) .catch(() => {}); diff --git a/apps/inspector/src/lib/send-command.ts b/apps/inspector/src/lib/send-command.ts new file mode 100644 index 00000000..25a12b4d --- /dev/null +++ b/apps/inspector/src/lib/send-command.ts @@ -0,0 +1,66 @@ +import { toast } from 'sonner'; + +import type { CommandMessage } from '@feather/session-bridge'; +import { sendCommand } from '@feather/session-bridge'; + +/** + * Re-exported from `@feather/session-bridge` so existing call sites keep working + * while the transport itself stays out of this module's import graph. + * + * With no session attached this resolves without doing anything, which is what + * lets a consumer with no game — Feather Studio standalone, or the showcase on + * the web — stay fully usable rather than erroring. + */ +export { sendCommand, hasSession } from '@feather/session-bridge'; +export type { CommandMessage } from '@feather/session-bridge'; + +/** + * Send a command the user asked for, and say so if it does not arrive. + * + * Inspector had 44 `.catch(() => {})` sites, and they were not all the same + * thing. Background sync failing transiently is not worth interrupting anyone — + * it will be retried, and a toast per hiccup is the noise L6 warns about. But a + * command the user *pressed a button for* is different: if "Stop replay" or + * "Clear logs" never reaches the game, silence leaves them believing something + * happened that did not, and the panel simply looks broken. + * + * `action` completes the sentence "Could not ..." — pass "stop the replay", not + * "Stop replay failed". + * + * See V4-ENHANCED.md §5 (C4). + */ +export async function sendUserCommand( + sessionId: string, + message: CommandMessage, + action: string, +): Promise { + try { + await sendCommand(sessionId, message); + return true; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + toast.error(`Could not ${action}`, { description: reason }); + return false; + } +} + +/** + * Send a command the user did not ask for, and stay quiet if it does not arrive. + * + * The counterpart to `sendUserCommand`, and the reason both exist: a bare + * `.catch(() => {})` cannot tell you whether the silence was considered or + * merely convenient. These are background refreshes and connection setup — + * `req:assets` when a panel opens, `req:config` on reconnect. They are retried + * the next time the panel asks, and a toast per transient hiccup is exactly the + * noise that trains people to dismiss toasts without reading them. + * + * What the user *does* get is the panel saying its data is stale, which is the + * honest signal and is C2's job rather than this one's. + * + * See V4-ENHANCED.md §5 (C4). + */ +export function sendBackgroundCommand(sessionId: string, message: CommandMessage): void { + void sendCommand(sessionId, message).catch(() => { + // Intentional: see above. The panel's freshness indicator carries this. + }); +} diff --git a/apps/inspector/src/lib/session-query-keys.ts b/apps/inspector/src/lib/session-query-keys.ts new file mode 100644 index 00000000..5a73e952 --- /dev/null +++ b/apps/inspector/src/lib/session-query-keys.ts @@ -0,0 +1,30 @@ +/** + * React Query keys for per-session data. + * + * Extracted from `use-ws-connection` so modules that only need the key shapes + * do not transitively import the WebSocket hook — and with it `@tauri-apps/*`. + * That chain was what bound the creative tools to Tauri before they moved to + * Feather Studio, and breaking it is what let them move. + */ +export const sessionQueryKey = { + config: (sessionId: string) => [sessionId, 'config'], + logs: (sessionId: string) => [sessionId, 'logs'], + performance: (sessionId: string) => [sessionId, 'performance'], + profiler: (sessionId: string) => [sessionId, 'profiler'], + observers: (sessionId: string) => [sessionId, 'observers'], + assets: (sessionId: string) => [sessionId, 'assets'], + plugin: (sessionId: string, pluginId: string) => [sessionId, 'plugin', pluginId], + pluginAction: (sessionId: string, pluginId: string, action: string) => [sessionId, 'plugin-action', pluginId, action], + console: (sessionId: string) => [sessionId, 'console'], + consoleGlobals: (sessionId: string) => [sessionId, 'console-globals'], + consolePins: (sessionId: string) => [sessionId, 'console-pins'], + consoleInspect: (sessionId: string) => [sessionId, 'console-inspect'], + timeTravel: (sessionId: string) => [sessionId, 'time-travel'], + timeTravelFrames: (sessionId: string) => [sessionId, 'time-travel-frames'], + sessionReplay: (sessionId: string) => [sessionId, 'session-replay'], + sessionReplayRecording: (sessionId: string) => [sessionId, 'session-replay-recording'], + sessionReplayRecordings: (sessionId: string) => [sessionId, 'session-replay-recordings'], + sessionReplayList: (sessionId: string) => [sessionId, 'session-replay-list'], + sessionReplaySelected: (sessionId: string) => [sessionId, 'session-replay-selected'], + hotReload: (sessionId: string) => [sessionId, 'hot-reload'], +}; diff --git a/apps/inspector/src/lib/studio-preferences-export.ts b/apps/inspector/src/lib/studio-preferences-export.ts new file mode 100644 index 00000000..22b3385c --- /dev/null +++ b/apps/inspector/src/lib/studio-preferences-export.ts @@ -0,0 +1,64 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { STUDIO_PREFERENCE_KEYS, type StudioPreferencesPayload } from '@feather/session-bridge'; + +/** + * A read-only legacy export adapter. + * + * Feather Studio is a separately installed application with its own storage + * origin, so it cannot read this app's `settings-storage` key. On first pairing + * it asks over the bridge, and this answers with the Studio-owned slice that the + * in-place migration deliberately never deleted. + * + * Deliberately narrow. It renders nothing, mutates nothing, and imports no + * Studio code — the field list comes from the shared bridge contract, which is + * what lets Inspector produce the payload without the coupling coming back. Once + * users have migrated this can be deleted outright. + */ + +const LEGACY_KEY = 'settings-storage'; +const PAYLOAD_VERSION = 1; + +function readLegacySlice(): StudioPreferencesPayload | null { + let parsed: unknown; + try { + const raw = globalThis.localStorage?.getItem(LEGACY_KEY); + if (!raw) return null; + parsed = JSON.parse(raw); + } catch { + return null; + } + + const state = (parsed as { state?: unknown } | null)?.state; + if (!state || typeof state !== 'object') return null; + + const source = state as Record; + const payload: Record = { version: PAYLOAD_VERSION }; + let found = false; + + for (const key of STUDIO_PREFERENCE_KEYS) { + if (key in source) { + payload[key] = source[key]; + found = true; + } + } + + // Nothing to hand over is a normal outcome, not a failure. + return found ? (payload as unknown as StudioPreferencesPayload) : null; +} + +/** + * Answer export requests for as long as the app is running. + * + * Returns an unsubscribe function. + */ +export async function serveStudioPreferencesExport(): Promise<() => void> { + return listen<{ id: string }>('feather://export-studio-preferences', (event) => { + const id = event.payload?.id; + if (!id) return; + void invoke('resolve_studio_preferences_export', { + id, + payload: readLegacySlice(), + }).catch(() => {}); + }); +} diff --git a/src/lib/send-command.ts b/apps/inspector/src/lib/tauri-command-sender.ts similarity index 50% rename from src/lib/send-command.ts rename to apps/inspector/src/lib/tauri-command-sender.ts index 4d97c9c3..942efee9 100644 --- a/src/lib/send-command.ts +++ b/apps/inspector/src/lib/tauri-command-sender.ts @@ -1,7 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { useSettingsStore } from '@/store/settings'; - -type CommandMessage = string | Record; +import type { CommandMessage, CommandSender } from '@feather/session-bridge'; function withAppId(message: CommandMessage): string { const appId = useSettingsStore.getState().appId; @@ -18,9 +17,11 @@ function withAppId(message: CommandMessage): string { return JSON.stringify({ ...message, appId }); } -export function sendCommand(sessionId: string, message: CommandMessage): Promise { - return invoke('send_command', { - sessionId, - message: withAppId(message), - }); -} +/** + * The desktop transport. Registered by the entry point when Tauri is present. + * + * This is the only module that knows commands travel over Tauri, which is what + * keeps `@tauri-apps/*` out of the shared packages Feather Studio also uses. + */ +export const tauriCommandSender: CommandSender = (sessionId, message) => + invoke('send_command', { sessionId, message: withAppId(message) }); diff --git a/src/lib/utils.ts b/apps/inspector/src/lib/utils.ts similarity index 100% rename from src/lib/utils.ts rename to apps/inspector/src/lib/utils.ts diff --git a/apps/inspector/src/main.tsx b/apps/inspector/src/main.tsx new file mode 100644 index 00000000..b77930d9 --- /dev/null +++ b/apps/inspector/src/main.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { createWebHost } from "@feather/host"; +import { createTauriHost } from "@feather/host/tauri"; +import { setCommandSender } from "@feather/session-bridge"; +import { tauriCommandSender } from "./lib/tauri-command-sender"; +import { isWeb } from "./utils/platform"; +import { serveStudioPreferencesExport } from "./lib/studio-preferences-export"; +import { AppProvider } from "./providers"; +import { Router } from "./router"; +import "./App.css"; + +// This bundle also runs in a plain browser — `pnpm run web`, and the Playwright +// suite — where the Tauri APIs are absent. Pick the host from what is actually +// available rather than from which bundle this is. +// The file dialogs need a real Tauri runtime, so the host is chosen by what is +// actually present — this bundle also runs in a plain browser via `pnpm run web` +// and the Playwright suite. +const host = isWeb() ? createWebHost() : createTauriHost(); + +// The command transport is registered unconditionally for this bundle. It routes +// through Tauri's `invoke`, which the Playwright suite mocks, so gating it on +// isWeb() would silently disable every game command under test. The showcase has +// its own entry point and registers nothing, which is what leaves the creative +// tools it compiles from Feather Studio as local editors on the real web. +setCommandSender(tauriCommandSender); + +// Answer Feather Studio's one-time preferences handoff. Read-only, and a no-op +// when there is nothing to hand over. +if (!isWeb()) { + void serveStudioPreferencesExport(); +} + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + +); diff --git a/src/pages/about/index.tsx b/apps/inspector/src/pages/about/index.tsx similarity index 96% rename from src/pages/about/index.tsx rename to apps/inspector/src/pages/about/index.tsx index 815b87b7..0c3a3789 100644 --- a/src/pages/about/index.tsx +++ b/apps/inspector/src/pages/about/index.tsx @@ -1,10 +1,10 @@ -import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@feather/ui/dialog'; +import { Button } from '@feather/ui/button'; +import { Badge } from '@feather/ui/badge'; +import { ScrollArea } from '@feather/ui/scroll-area'; import { useAboutStore } from '@/store/about'; import { openUrl } from '@/utils/linking'; -import { version } from '../../../package.json'; +import { version } from '../../../../../package.json'; import { useSettingsStore } from '@/store/settings'; import { BadgeCheckIcon, @@ -173,7 +173,7 @@ export function AboutModal() { {!isLatestVersion && ( -
+

New version available

diff --git a/src/pages/assets/index.tsx b/apps/inspector/src/pages/assets/index.tsx similarity index 95% rename from src/pages/assets/index.tsx rename to apps/inspector/src/pages/assets/index.tsx index 11e4d977..4170b0f0 100644 --- a/src/pages/assets/index.tsx +++ b/apps/inspector/src/pages/assets/index.tsx @@ -1,4 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { findMissingPaths } from './missing-paths'; +import { sendBackgroundCommand } from '@/lib/send-command'; +import { Freshness } from '@/components/freshness'; +import { sessionQueryKey } from '@/lib/session-query-keys'; +import { usePanelState } from '@/store/panel-state'; import { open as openFolderDialog } from '@tauri-apps/plugin-dialog'; import { readFile, stat } from '@tauri-apps/plugin-fs'; import { @@ -17,12 +22,12 @@ import { TextIcon, XIcon, } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { Switch } from '@/components/ui/switch'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@feather/ui/button'; +import { Label } from '@feather/ui/label'; +import { Badge } from '@feather/ui/badge'; +import { Switch } from '@feather/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feather/ui/tabs'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@feather/ui/table'; import { TriageEmptyState, TriageFilterBar, @@ -141,7 +146,7 @@ function useMissingAssetPaths(rows: AssetRow[]) { useEffect(() => { let cancelled = false; - const paths = Array.from(new Set(rows.map((asset) => asset.path).filter((path): path is string => !!path))).slice(0, 250); + const paths = Array.from(new Set(rows.map((asset) => asset.path).filter((path): path is string => !!path))); if (paths.length === 0) { setMissingPaths(new Set()); @@ -153,18 +158,11 @@ function useMissingAssetPaths(rows: AssetRow[]) { return; } - Promise.all( - paths.map(async (path) => { - try { - await stat(resolveAssetPath(path, rootPath, manualRootPath)); - return null; - } catch { - return path; - } - }), - ).then((results) => { + void findMissingPaths(paths, (path) => stat(resolveAssetPath(path, rootPath, manualRootPath)), { + isCancelled: () => cancelled, + }).then((missing) => { if (cancelled) return; - setMissingPaths(new Set(results.filter((path): path is string => !!path))); + setMissingPaths(missing); }); return () => { @@ -702,13 +700,14 @@ function PreviewPanel({ export default function AssetsPage() { const { data, previewAsset, setAssetPreviewEnabled } = useAssets(); + const sessionId = useSessionStore((state) => state.sessionId); const { rootPath, manualRootPath, pickRootPath, clearManualRootPath } = useGameRootPath(); const assetPreviewEnabled = useConfigStore((state) => state.config?.assets?.enabled !== false); - const [tab, setTab] = useState('texture'); - const [search, setSearch] = useState(''); - const [filter, setFilter] = useState('all'); - const [sortKey, setSortKey] = useState('name'); - const [sortDirection, setSortDirection] = useState('asc'); + const [tab, setTab] = usePanelState('assets.tab', 'texture'); + const [search, setSearch] = usePanelState('assets.search', ''); + const [filter, setFilter] = usePanelState('assets.filter', 'all'); + const [sortKey, setSortKey] = usePanelState('assets.sortKey', 'name'); + const [sortDirection, setSortDirection] = usePanelState('assets.sortDirection', 'asc'); const [selected, setSelected] = useState<{ kind: AssetKind; id: number } | null>(null); const [previewRequest, setPreviewRequest] = useState<{ kind: AssetKind; id: number } | null>(null); const allRows = useMemo(() => [...data.textures, ...data.fonts, ...data.audio], [data.audio, data.fonts, data.textures]); @@ -873,6 +872,15 @@ export default function AssetsPage() { />
+ {/* Assets are requested when the panel opens and then go dormant, so + what you return to may predate the run you are looking at. */} + {sessionId && ( + sendBackgroundCommand(sessionId, { type: 'req:assets' })} + className="ml-auto" + /> + )} Promise; + +export async function findMissingPaths( + paths: string[], + stat: StatFn, + options: { batchSize?: number; isCancelled?: () => boolean } = {}, +): Promise> { + const batchSize = options.batchSize ?? BATCH_SIZE; + const isCancelled = options.isCancelled ?? (() => false); + const missing = new Set(); + + for (let index = 0; index < paths.length; index += batchSize) { + // Checked between batches rather than only at the end: on a large project + // this outlives the panel that started it, and a user who navigated away + // should not keep the filesystem busy. + if (isCancelled()) return missing; + + const batch = paths.slice(index, index + batchSize); + const results = await Promise.all( + batch.map(async (path) => { + try { + await stat(path); + return null; + } catch { + return path; + } + }), + ); + + for (const path of results) { + if (path) missing.add(path); + } + } + + return missing; +} diff --git a/src/pages/compare/index.tsx b/apps/inspector/src/pages/compare/index.tsx similarity index 87% rename from src/pages/compare/index.tsx rename to apps/inspector/src/pages/compare/index.tsx index b3ce8c78..b37c0d38 100644 --- a/src/pages/compare/index.tsx +++ b/apps/inspector/src/pages/compare/index.tsx @@ -2,10 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useShallow } from 'zustand/react/shallow'; import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@feather/ui/badge'; +import { Button } from '@feather/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@feather/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@feather/ui/table'; import { TriageCopyButton, TriageEmptyState, @@ -14,6 +14,8 @@ import { TriageSummaryChip, TriageToolbar, } from '@/components/triage'; +import { useSettingsStore } from '@/store/settings'; +import { usePanelState } from '@/store/panel-state'; import { sessionQueryKey } from '@/hooks/use-ws-connection'; import { type PerformanceMetrics } from '@/hooks/use-performance'; import { finiteNumber, formatOptionalFixed, formatOptionalMemory, formatSignedMemory } from '@/utils/performance-metrics'; @@ -78,9 +80,9 @@ function statusLabel(status: CompareStatus) { } function statusClass(status: CompareStatus) { - if (status === 'changed') return 'border-amber-500/40 text-amber-700 dark:text-amber-300'; - if (status === 'onlyA') return 'border-blue-500/40 text-blue-700 dark:text-blue-300'; - if (status === 'onlyB') return 'border-cyan-500/40 text-cyan-700 dark:text-cyan-300'; + if (status === 'changed') return 'border-warn-border text-warn'; + if (status === 'onlyA') return 'border-info-border text-info'; + if (status === 'onlyB') return 'border-info-border text-info'; return 'border-muted-foreground/30 text-muted-foreground'; } @@ -140,7 +142,7 @@ function SessionPicker({ {sessions.map((session) => ( - + {sessionLabel(session)} @@ -170,7 +172,7 @@ function SessionCard({ {sessionLabel(session)} - + Connected
@@ -212,7 +214,7 @@ function deltaClass(metric: string, delta: number) { if (delta === 0) return ''; const lowerIsBetter = metric !== 'FPS'; const worse = lowerIsBetter ? delta > 0 : delta < 0; - return worse ? 'border-amber-500/40 text-amber-700 dark:text-amber-300' : 'border-emerald-500/40 text-emerald-700 dark:text-emerald-300'; + return worse ? 'border-warn-border text-warn' : 'border-ok-border text-ok'; } function DeltaBadge({ label, left, right, formatter }: { label: string; left?: number; right?: number; formatter?: (value: number) => string }) { @@ -285,11 +287,15 @@ export default function ComparePage() { const connectedSessions = useMemo(() => sessions.filter((session) => session.connected), [sessions]); const [leftId, setLeftId] = useState(null); const [rightId, setRightId] = useState(null); - const [filter, setFilter] = useState('all'); - const [group, setGroup] = useState('all'); - const [search, setSearch] = useState(''); - const [sortKey, setSortKey] = useState('status'); - const [sortDirection, setSortDirection] = useState('asc'); + // The two session ids stay ephemeral: they name processes, and a remembered id + // would select a session that no longer exists after a restart. + const [filter, setFilter] = usePanelState('compare.filter', 'all'); + const [group, setGroup] = usePanelState('compare.group', 'all'); + const [search, setSearch] = usePanelState('compare.search', ''); + const [sortKey, setSortKey] = usePanelState('compare.sortKey', 'status'); + const [sortDirection, setSortDirection] = usePanelState('compare.sortDirection', 'asc'); + const cliProjectDir = useSettingsStore((state) => state.cliProjectDir); + const runCommand = `feather run ${cliProjectDir || 'path/to/my-game'}`; const canCompare = connectedSessions.length >= 2; useEffect(() => { @@ -384,7 +390,16 @@ export default function ComparePage() { + Start a second game to compare two runs side by side. + {runCommand} + + } /> ) : ( <> @@ -436,7 +451,24 @@ export default function ComparePage() { } /> ) : filteredRows.length === 0 ? ( - + { + setFilter('all'); + setGroup('all'); + setSearch(''); + }} + > + Clear filters + + } + /> ) : (
@@ -465,9 +497,12 @@ export default function ComparePage() { diff --git a/src/pages/console/index.tsx b/apps/inspector/src/pages/console/index.tsx similarity index 97% rename from src/pages/console/index.tsx rename to apps/inspector/src/pages/console/index.tsx index 9bc453f4..54721f79 100644 --- a/src/pages/console/index.tsx +++ b/apps/inspector/src/pages/console/index.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { LuaCodeInput, type LuaCompletionItem } from '@/components/ui/lua-code-input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Switch } from '@/components/ui/switch'; +import { Button } from '@feather/ui/button'; +import { Label } from '@feather/ui/label'; +import { Badge } from '@feather/ui/badge'; +import { LuaCodeInput, type LuaCompletionItem } from '@feather/ui/lua-code-input'; +import { ScrollArea } from '@feather/ui/scroll-area'; +import { Switch } from '@feather/ui/switch'; +import { usePanelState } from '@/store/panel-state'; import { useConsole, type ConsoleEntry } from '@/hooks/use-console'; import { useEffectiveApiKey } from '@/hooks/use-session-api-key'; import { usePluginControl } from '@/hooks/use-plugin-control'; @@ -237,7 +238,7 @@ function ConsoleValueInspector({ key={`${field.key}:${field.path?.join('.')}`} className="grid grid-cols-[minmax(0,0.35fr)_minmax(0,1fr)_auto] items-center gap-2 rounded border px-2 py-1" > - + {field.key} @@ -320,8 +321,8 @@ function ConsoleOutput({ status === 'error' ? 'border-destructive/40 text-destructive' : status === 'success' - ? 'border-emerald-500/40 text-emerald-600 dark:text-emerald-400' - : 'border-amber-500/40 text-amber-600'; + ? 'border-ok-border text-ok' + : 'border-warn-border text-warn'; return (
- > + >
{displayOutput(response.result, expanded)} @@ -495,7 +496,10 @@ export default function ConsolePage() { // Track which response IDs have already been persisted to avoid duplicates const persistedIds = useRef(new Set()); const [historyIndex, setHistoryIndex] = useState(-1); - const [readOnly, setReadOnly] = useState(false); + // A mode the user chose. The draft `input` above stays ephemeral on purpose: + // restoring a half-typed command into a console you can execute from is a + // worse surprise than retyping it. + const [readOnly, setReadOnly] = usePanelState('console.readOnly', false); const [isSearching, setIsSearching] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchIndex, setSearchIndex] = useState(0); @@ -741,7 +745,7 @@ export default function ConsolePage() { variant={consolePlugin.enabled ? 'outline' : 'secondary'} className={cn( 'h-6 shrink-0 font-mono text-xs', - consolePlugin.enabled && 'border-emerald-500/40 text-emerald-600 dark:text-emerald-400', + consolePlugin.enabled && 'border-ok-border text-ok', )} > {consolePlugin.enabled ? 'Plugin enabled' : 'Plugin disabled'} @@ -758,7 +762,7 @@ export default function ConsolePage() { variant="outline" className={cn( 'h-6 shrink-0 font-mono text-xs', - sandboxLabel === 'Unsandboxed' && 'border-amber-500/40 text-amber-600', + sandboxLabel === 'Unsandboxed' && 'border-warn-border text-warn', )} > {sandboxLabel} @@ -766,14 +770,14 @@ export default function ConsolePage() { )} {globalsLabel} {readOnly ? 'Read-only guardrails' : 'Writable eval'} @@ -959,7 +963,7 @@ export default function ConsolePage() { className="flex shrink-0 items-center gap-1 rounded-md border px-2 py-1" title="Best-effort guardrails. Blocks obvious writes but is not a true dry run." > - +
onSetProfilerProbe(lineNum, 'start')}> - + Start profiling here onSetProfilerProbe(lineNum, 'stop')}> - + Stop profiling here onSetProfilerProbe(lineNum, 'snapshot')}> - + Snapshot here setWrapProbe(lineNum, line)}> - + Profile function here @@ -540,14 +664,14 @@ function SourceView({ {lineNum} {/* Code */}
-                  {isCurrent && }
+                  {isCurrent && }
                   {hasConditionError && !}
 
                    = {
   nil: 'text-muted-foreground',
+  /* eslint-disable no-restricted-syntax -- syntax highlighting, not state: a
+     string value is not a warning, so these must not borrow the state ramps. */
   boolean: 'text-purple-400',
-  number: 'text-green-400',
-  string: 'text-orange-400',
-  table: 'text-sky-400',
+  number: 'text-sky-600 dark:text-sky-400',
+  string: 'text-amber-700 dark:text-amber-300',
+  table: 'text-teal-700 dark:text-teal-300',
+  /* eslint-enable no-restricted-syntax */
   function: 'text-muted-foreground italic',
   userdata: 'text-muted-foreground italic',
   thread: 'text-muted-foreground italic',
@@ -692,17 +825,17 @@ function VarNode({ name, value, indent = 0 }: { name: string; value: string; ind
         
           {isExpandable && }
         
-        {name}
+        {name}
         =
         
           {truncated}
         
         
{latestConditionError && ( - - - {dbg.breakpointErrors.length} condition error{dbg.breakpointErrors.length === 1 ? '' : 's'} - + { + if (!issue.file) return; + setSelectedFile(issue.file); + if (issue.line) setScrollToLine(issue.line); + }} + /> )} {!latestConditionError && rejectedBreakpointCount > 0 && ( - - - {rejectedBreakpointCount} rejected - + { + if (!issue.file) return; + setSelectedFile(issue.file); + if (issue.line) setScrollToLine(issue.line); + }} + /> )} + )}
@@ -162,9 +194,9 @@ export function LogTable({ @@ -199,7 +231,7 @@ export function LogTable({ className="size-8" onClick={() => onClear(filteredLogs.map((log) => log.id))} > - + Clear visible logs @@ -212,9 +244,9 @@ export function LogTable({ @@ -230,7 +262,7 @@ export function LogTable({ @@ -253,7 +285,7 @@ export function LogTable({ ) : ( @@ -275,6 +307,7 @@ export function LogTable({
{ diff --git a/src/pages/log/index.tsx b/apps/inspector/src/pages/log/index.tsx similarity index 97% rename from src/pages/log/index.tsx rename to apps/inspector/src/pages/log/index.tsx index 3c5243bb..921e2303 100644 --- a/src/pages/log/index.tsx +++ b/apps/inspector/src/pages/log/index.tsx @@ -1,17 +1,17 @@ import { readFile } from '@tauri-apps/plugin-fs'; import { PageLayout } from '@/components/page-layout'; -import { Separator } from '@/components/ui/separator'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@feather/ui/separator'; +import { ScrollArea } from '@feather/ui/scroll-area'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button, CopyButton } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@feather/ui/card'; +import { Button, CopyButton } from '@feather/ui/button'; import { useConfig } from '@/hooks/use-config'; import { Log, LogType, useLogs } from '@/hooks/use-logs'; import { LuaBlock, TraceViewer } from '@/components/code'; import { isWeb } from '@/utils/platform'; import { invoke } from '@tauri-apps/api/core'; import { useSettingsStore } from '@/store/settings'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@feather/ui/dialog'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; import { useQueryClient } from '@tanstack/react-query'; diff --git a/apps/inspector/src/pages/log/search.ts b/apps/inspector/src/pages/log/search.ts new file mode 100644 index 00000000..3f034e7b --- /dev/null +++ b/apps/inspector/src/pages/log/search.ts @@ -0,0 +1,24 @@ +import type { Log } from '@/hooks/use-logs'; + +/** + * Text matching for the log search. + * + * Its own module, with no React in it, so it can be tested directly. The bug + * that put it here — `log.str.toLowerCase()` throwing on a log whose `str` the + * runtime omitted — was only reachable through the component, which meant the + * cheapest possible test could not reach it either. + * + * Deliberately tolerant. Logs arriving over the WebSocket are not validated + * against the schema, and searching is what someone does when the output already + * looks wrong: it is the last place that should be strict about shape. + */ +export function searchableText(log: Log): string { + const message = typeof log.str === 'string' ? log.str : ''; + const type = typeof log.type === 'string' ? log.type : ''; + return `${message}\n${type}`.toLowerCase(); +} + +export function matchesSearch(log: Log, needle: string): boolean { + if (!needle) return true; + return searchableText(log).includes(needle); +} diff --git a/src/pages/observable/index.tsx b/apps/inspector/src/pages/observable/index.tsx similarity index 85% rename from src/pages/observable/index.tsx rename to apps/inspector/src/pages/observable/index.tsx index 3ccea2b1..3bc27326 100644 --- a/src/pages/observable/index.tsx +++ b/apps/inspector/src/pages/observable/index.tsx @@ -1,17 +1,23 @@ import { useEffect, useMemo, useState } from 'react'; +import { useHost, type FeatherHost } from '@feather/host'; +import { useSessionStore } from '@/store/session'; +import { Freshness } from '@/components/freshness'; +import { sessionQueryKey } from '@/lib/session-query-keys'; +import { sendBackgroundCommand } from '@/lib/send-command'; +import { usePanelState } from '@/store/panel-state'; import { PageLayout } from '@/components/page-layout'; import { SectionCards } from './section-cards'; import { useObservability, ObserverEntry } from '@/hooks/use-observability'; -import { Button } from '@/components/ui/button'; +import { Button } from '@feather/ui/button'; import { LuaBlock } from '@/components/code'; -import { Separator } from '@/components/ui/separator'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@feather/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feather/ui/tabs'; +import { ScrollArea } from '@feather/ui/scroll-area'; import { useConfig } from '@/hooks/use-config'; import { lineDiff, hasDiff } from '@/utils/diff'; import { cn } from '@/utils/styles'; -import { Badge } from '@/components/ui/badge'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@feather/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@feather/ui/select'; import { ActivityIcon, DownloadIcon } from 'lucide-react'; import { downloadFile } from '@/utils/file'; import { @@ -66,14 +72,14 @@ function formatObservedTime(time?: number) { return new Date(time).toLocaleTimeString(); } -function exportObservers(data: ObserverEntry[]) { +function exportObservers(host: FeatherHost, data: ObserverEntry[]) { const payload = { exportedAt: new Date().toISOString(), count: data.length, observers: data, }; const src = `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(payload, null, 2))}`; - void downloadFile(`feather-observers-${Date.now()}.json`, src, 'string'); + void downloadFile(host, `feather-observers-${Date.now()}.json`, src, 'string'); } function DiffView({ oldValue, newValue }: { oldValue: string; newValue: string }) { @@ -84,8 +90,8 @@ function DiffView({ oldValue, newValue }: { oldValue: string; newValue: string }
@@ -132,7 +138,7 @@ export function ObserveSidePanel({ data, onClose }: { onClose: (o: boolean) => v subtitle={{data.value.length.toLocaleString()} chars} badges={ <> - {data.changed && } + {data.changed && } {data.type} @@ -148,7 +154,7 @@ export function ObserveSidePanel({ data, onClose }: { onClose: (o: boolean) => v Current - Diff {showDiff && } + Diff {showDiff && } History{' '} @@ -202,13 +208,15 @@ export function ObserveSidePanel({ data, onClose }: { onClose: (o: boolean) => v } export default function Page() { - const [search, setSearch] = useState(''); - const [typeFilter, setTypeFilter] = useState('all'); - const [groupFilter, setGroupFilter] = useState('all'); - const [sortBy, setSortBy] = useState('changed'); + const host = useHost(); + const sessionId = useSessionStore((state) => state.sessionId); + const [search, setSearch] = usePanelState('observability.search', ''); + const [typeFilter, setTypeFilter] = usePanelState('observability.typeFilter', 'all'); + const [groupFilter, setGroupFilter] = usePanelState('observability.groupFilter', 'all'); + const [sortBy, setSortBy] = usePanelState('observability.sortBy', 'changed'); const [changeWindow, setChangeWindow] = useState(readStoredChangeWindow); const [now, setNow] = useState(() => Date.now()); - const [changedOnly, setChangedOnly] = useState(false); + const [changedOnly, setChangedOnly] = usePanelState('observability.changedOnly', false); const { data: searchedDataRaw, all: allRaw } = useObservability(search); const [selected, setSelected] = useState(null); @@ -299,6 +307,15 @@ end`} 0 ? 'warning' : 'default'} /> 0 ? 'good' : 'muted'} /> + {/* Observed values are the data you must least afford to read stale: + the whole point is watching them change. */} + {sessionId && ( + sendBackgroundCommand(sessionId, { type: 'req:observers' })} + className="ml-auto" + /> + )}
- @@ -388,7 +405,25 @@ end`} />
{data.length === 0 ? ( - + { + setSearch(''); + setTypeFilter('all'); + setGroupFilter('all'); + setChangedOnly(false); + }} + > + Clear filters + + } + /> ) : ( )} diff --git a/src/pages/observable/section-cards.tsx b/apps/inspector/src/pages/observable/section-cards.tsx similarity index 91% rename from src/pages/observable/section-cards.tsx rename to apps/inspector/src/pages/observable/section-cards.tsx index e43f186a..01b1bf5c 100644 --- a/src/pages/observable/section-cards.tsx +++ b/apps/inspector/src/pages/observable/section-cards.tsx @@ -1,5 +1,5 @@ -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@feather/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@feather/ui/card'; import { cn } from '@/utils/styles'; import { ObserverEntry } from '@/hooks/use-observability'; @@ -30,7 +30,7 @@ export function SectionCards({
{item.key} {item.changed && ( - + )} {item.changeCount ? ( diff --git a/src/pages/performance/chart-area-interactive.tsx b/apps/inspector/src/pages/performance/chart-area-interactive.tsx similarity index 97% rename from src/pages/performance/chart-area-interactive.tsx rename to apps/inspector/src/pages/performance/chart-area-interactive.tsx index ff108a35..19083ac0 100644 --- a/src/pages/performance/chart-area-interactive.tsx +++ b/apps/inspector/src/pages/performance/chart-area-interactive.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from 'recharts'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; +import { useIsMobile } from '@feather/ui/hooks/use-mobile'; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@feather/ui/card'; +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@feather/ui/chart'; import { Combobox, ComboboxContent, @@ -11,9 +11,9 @@ import { ComboboxInput, ComboboxItem, ComboboxList, -} from '@/components/ui/combobox'; +} from '@feather/ui/combobox'; import { PerformanceMetrics } from '@/hooks/use-performance'; -import { Button } from '@/components/ui/button'; +import { Button } from '@feather/ui/button'; import { DownloadIcon } from 'lucide-react'; import { formatOptionalMemory, metricNumber, metricStatsNumber } from '@/utils/performance-metrics'; diff --git a/src/pages/performance/index.tsx b/apps/inspector/src/pages/performance/index.tsx similarity index 91% rename from src/pages/performance/index.tsx rename to apps/inspector/src/pages/performance/index.tsx index c91079b8..2c20b832 100644 --- a/src/pages/performance/index.tsx +++ b/apps/inspector/src/pages/performance/index.tsx @@ -1,17 +1,19 @@ import { useEffect, useMemo, useState } from 'react'; +import { useHost, type FeatherHost } from '@feather/host'; +import { usePanelState } from '@/store/panel-state'; import { ChartAreaInteractive, chartMetrics, type ChartMetricKey } from '@/pages/performance/chart-area-interactive'; import { PageLayout } from '@/components/page-layout'; import { PerformanceMetrics, usePerformance, type FeatherOverheadMetric } from '@/hooks/use-performance'; import { SectionCards } from './section-cards'; import { useConfig } from '@/hooks/use-config'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Switch } from '@feather/ui/switch'; +import { Label } from '@feather/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feather/ui/tabs'; +import { Button } from '@feather/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@feather/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@feather/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@feather/ui/collapsible'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@feather/ui/table'; import { TriageEmptyState, TriageToolbar } from '@/components/triage'; import { CheckCircleIcon, @@ -33,14 +35,14 @@ import { type PerformanceVerdict, } from '@/utils/performance-metrics'; -function exportPerformance(samples: PerformanceMetrics[], metric: ChartMetricKey) { +function exportPerformance(host: FeatherHost, samples: PerformanceMetrics[], metric: ChartMetricKey) { const payload = { exportedAt: new Date().toISOString(), metric, samples, }; const src = `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(payload, null, 2))}`; - void downloadFile(`feather-performance-${Date.now()}.json`, src, 'string'); + void downloadFile(host, `feather-performance-${Date.now()}.json`, src, 'string'); } function formatByteCount(bytes: unknown): string { @@ -216,8 +218,8 @@ function SpikesList({ data }: { data: PerformanceMetrics[] }) { function verdictClass(verdict: PerformanceVerdict) { return verdict.severity === 'critical' - ? 'border-red-500/50 bg-red-500/10 text-red-950 dark:text-red-100' - : 'border-amber-500/50 bg-amber-500/10 text-amber-950 dark:text-amber-100'; + ? 'border-danger-border bg-danger-surface text-danger' + : 'border-warn-border bg-warn-surface text-warn'; } function HealthVerdicts({ data, latest }: { data: PerformanceMetrics[]; latest: PerformanceMetrics | null }) { @@ -237,8 +239,8 @@ function HealthVerdicts({ data, latest }: { data: PerformanceMetrics[]; latest: if (verdicts.length === 0) { return ( - - + + Healthy No actionable performance warnings in the visible window. @@ -249,10 +251,10 @@ function HealthVerdicts({ data, latest }: { data: PerformanceMetrics[]; latest: return ( - +
- + Health Warnings {verdicts.length} @@ -306,10 +308,14 @@ function HealthVerdicts({ data, latest }: { data: PerformanceMetrics[]; latest: } export default function Page() { + const host = useHost(); const { data } = usePerformance(); - const [selected, setSelected] = useState('fps'); - const [diskUsageEnabled, setDiskUsageEnabled] = useState(false); - const [followTail, setFollowTail] = useState(true); + const [selected, setSelected] = usePanelState('performance.metric', 'fps'); + const [diskUsageEnabled, setDiskUsageEnabled] = usePanelState('performance.diskUsage', false); + const [followTail, setFollowTail] = usePanelState('performance.followTail', true); + // Deliberately not persisted. Pausing is a momentary act — you stop the chart + // to read a number off it. Coming back later to a chart that is silently + // frozen on an old run is the stale-data trap, so a pause ends with the visit. const [paused, setPaused] = useState(false); const [snapshot, setSnapshot] = useState(null); const [visibleWindow, setVisibleWindow] = useState([]); @@ -367,7 +373,7 @@ export default function Page() { Follow - @@ -399,7 +405,7 @@ export default function Page() { dataKey={selected} data={visibleData} onDataWindowChange={setVisibleWindow} - onExport={(samples) => exportPerformance(samples, selected)} + onExport={(samples) => exportPerformance(host, samples, selected)} /> ) { +function exportProfiler(host: FeatherHost, rows: ProfilerRow[], metadata: Record) { const payload = { exportedAt: new Date().toISOString(), metadata, rows, }; const src = `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(payload, null, 2))}`; - void downloadFile(`feather-profiler-${Date.now()}.json`, src, 'string'); + void downloadFile(host, `feather-profiler-${Date.now()}.json`, src, 'string'); } function ProfilerFilterField({ @@ -246,7 +247,7 @@ function ProfilerRunComparisonDrawer({
Normal - + Slow
@@ -321,7 +322,7 @@ function ProfilerRunComparisonDrawer({ 'grid size-5 place-items-center rounded-full border text-[10px] font-semibold opacity-0 transition-opacity', (isA || isB) && 'opacity-100', isA && 'border-primary bg-primary text-primary-foreground', - isB && 'border-amber-500 bg-amber-500 text-white', + isB && 'border-warn-border bg-warn text-white', )} > {isA ? 'A' : isB ? 'B' : ''} @@ -329,9 +330,9 @@ function ProfilerRunComparisonDrawer({ @@ -417,6 +418,7 @@ function ProfilerRunComparisonDrawer({ } export function ProfilerPanel() { + const host = useHost(); const { data, onAction } = useProfiler(); const [search, setSearch] = useState(''); const [sortBy, setSortBy] = useState('percent'); @@ -571,8 +573,16 @@ export function ProfilerPanel() {
- - + {/* Recording is `warn`, not `ok`, and it matches Time Travel now. + The two panels marked the same concept in opposite colours — + this one green, that one red — and neither reading was right. + A capture in progress is not "fine" (it is costing frame time + and you have to remember to stop it) and it is not a failure. + It is the state that wants your attention, which is what warn + is for. Keeping danger for actual failures is what keeps danger + meaning something. */} + + @@ -607,7 +617,7 @@ export function ProfilerPanel() { size="sm" variant="secondary" onClick={() => - exportProfiler(filteredRows, { + exportProfiler(host, filteredRows, { recording, captureElapsed, totalCapturedTime, diff --git a/src/pages/performance/section-cards.tsx b/apps/inspector/src/pages/performance/section-cards.tsx similarity index 87% rename from src/pages/performance/section-cards.tsx rename to apps/inspector/src/pages/performance/section-cards.tsx index fa8299e4..e86c5b65 100644 --- a/src/pages/performance/section-cards.tsx +++ b/apps/inspector/src/pages/performance/section-cards.tsx @@ -1,5 +1,5 @@ -import { Badge } from '@/components/ui/badge'; -import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@feather/ui/badge'; +import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@feather/ui/card'; import { DEFAULT_METRIC, PerformanceMetrics } from '@/hooks/use-performance'; import { cn } from '@/utils/styles'; import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react'; @@ -27,18 +27,20 @@ const TrendingBadge = ({ value }: { value?: number }) => { ); }; -const cardClass = (active: boolean, tone: 'sky' | 'emerald' | 'amber' | 'violet' | 'cyan' | 'rose', disabled = false) => +/** + * Selection styling for a metric card. + * + * Each card used to carry its own hue — FPS sky, memory emerald, frame time + * rose, disk usage amber — shown only while that card was selected. Six colors + * for one meaning, and two of them said something untrue: a selected frame-time + * card looked like a failure and disk usage like a warning, whatever the numbers + * were. Selection is interaction, so it takes the accent, once. + */ +const cardClass = (active: boolean, disabled = false) => cn( '@container/card justify-between transition-colors', disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer hover:bg-muted/70', - active && { - 'border-sky-500/60 bg-sky-500/10 text-sky-950 dark:text-sky-100': tone === 'sky', - 'border-emerald-500/60 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100': tone === 'emerald', - 'border-amber-500/60 bg-amber-500/10 text-amber-950 dark:text-amber-100': tone === 'amber', - 'border-violet-500/60 bg-violet-500/10 text-violet-950 dark:text-violet-100': tone === 'violet', - 'border-cyan-500/60 bg-cyan-500/10 text-cyan-950 dark:text-cyan-100': tone === 'cyan', - 'border-rose-500/60 bg-rose-500/10 text-rose-950 dark:text-rose-100': tone === 'rose', - }, + active && 'border-primary/60 bg-primary/10', ); function average(data: PerformanceMetrics[], getValue: (metric: PerformanceMetrics) => number) { @@ -78,7 +80,7 @@ export function SectionCards({ return (
- onSelect('fps')} title="Chart FPS"> + onSelect('fps')} title="Chart FPS"> FPS @@ -95,7 +97,7 @@ export function SectionCards({ onSelect('memory')} title="Chart Lua memory" > @@ -117,7 +119,7 @@ export function SectionCards({ diskUsageEnabled && onSelect('diskUsage')} title={diskUsageEnabled ? 'Chart save directory disk usage' : 'Enable disk usage tracking first'} > @@ -134,7 +136,7 @@ export function SectionCards({ onSelect('frameTimeMax')} title="Chart maximum frame time" > @@ -157,7 +159,7 @@ export function SectionCards({ onSelect('drawcalls')} title="Chart draw calls" > @@ -179,7 +181,7 @@ export function SectionCards({
- onSelect('textureMemory')} title="Chart texture memory"> + onSelect('textureMemory')} title="Chart texture memory"> Assets diff --git a/src/pages/plugins/content.tsx b/apps/inspector/src/pages/plugins/content.tsx similarity index 95% rename from src/pages/plugins/content.tsx rename to apps/inspector/src/pages/plugins/content.tsx index d07f2434..6d475787 100644 --- a/src/pages/plugins/content.tsx +++ b/apps/inspector/src/pages/plugins/content.tsx @@ -1,14 +1,15 @@ import { useConfigStore } from '@/store/config'; +import { useHost } from '@feather/host'; import { useSettingsStore } from '@/store/settings'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Textarea } from '@/components/ui/textarea'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@feather/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@feather/ui/card'; +import { Input } from '@feather/ui/input'; +import { Label } from '@feather/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@feather/ui/select'; +import { Switch } from '@feather/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feather/ui/tabs'; +import { Textarea } from '@feather/ui/textarea'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@feather/ui/table'; import { Dialog, DialogContent, @@ -16,7 +17,7 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from '@/components/ui/dialog'; +} from '@feather/ui/dialog'; import { useGif } from '@/hooks/use-gif'; import { GifType, @@ -36,7 +37,7 @@ import { isWeb } from '@/utils/platform'; import { convertFileSrc } from '@tauri-apps/api/core'; import { Bookmark, CheckIcon, ChevronRight, DownloadIcon, ExternalLink } from 'lucide-react'; import { ReactNode, useCallback, useEffect, useState } from 'react'; -import { Badge } from '@/components/ui/badge'; +import { Badge } from '@feather/ui/badge'; import { cn } from '@/utils/styles'; const isDirectImageSrc = (src: string) => src.startsWith('data:') || src.startsWith('blob:'); @@ -47,6 +48,7 @@ const downloadName = (name: string, extension: '.png' | '.gif') => { }; const DownloadButton = ({ url, filename }: { url?: string; filename: string }) => { + const host = useHost(); return ( diff --git a/src/pages/time-travel/index.tsx b/apps/inspector/src/pages/time-travel/index.tsx similarity index 96% rename from src/pages/time-travel/index.tsx rename to apps/inspector/src/pages/time-travel/index.tsx index 58e4fb1a..5df391bd 100644 --- a/src/pages/time-travel/index.tsx +++ b/apps/inspector/src/pages/time-travel/index.tsx @@ -11,10 +11,10 @@ import { SquareIcon, XIcon, } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; +import { Button } from '@feather/ui/button'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@feather/ui/table'; +import { Badge } from '@feather/ui/badge'; +import { Separator } from '@feather/ui/separator'; import { cn } from '@/utils/styles'; import { useTimeTravel } from '@/hooks/use-time-travel'; import type { TimeTravelFrame } from '@/hooks/use-ws-connection'; @@ -132,7 +132,7 @@ function RecordingState({ frameCount, bufferSize }: { frameCount: number; buffer return (
- + Recording…

@@ -140,7 +140,7 @@ function RecordingState({ frameCount, bufferSize }: { frameCount: number; buffer

@@ -179,9 +179,9 @@ function FrameSnapshot({ current, prev }: { current: TimeTravelFrame; prev: Time @@ -189,9 +189,9 @@ function FrameSnapshot({ current, prev }: { current: TimeTravelFrame; prev: Time {kind !== 'same' && ( )} diff --git a/apps/inspector/src/providers.tsx b/apps/inspector/src/providers.tsx new file mode 100644 index 00000000..eb94c106 --- /dev/null +++ b/apps/inspector/src/providers.tsx @@ -0,0 +1,62 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { HostProvider, type FeatherHost } from '@feather/host'; +import { UiProvider, type UiDependencies } from '@feather/ui'; +import { useTheme, useSyntaxTheme } from './hooks/use-theme'; +import { copyToClipboardWithMeta } from './utils/strings'; +import { ThemeProvider } from './components/theme'; + +// Clean up stale react-query offline cache from previous versions +try { + localStorage.removeItem('REACT_QUERY_OFFLINE_CACHE'); +} catch { + // ignore +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60, // 1 hour in-memory cache + }, + }, +}); + +declare global { + interface Window { + __FEATHER_QUERY_CLIENT__?: QueryClient; + } +} + +if (import.meta.env.DEV) { + try { + if (localStorage.getItem('feather-e2e-query-client') === '1') { + window.__FEATHER_QUERY_CLIENT__ = queryClient; + } + } catch { + // ignored + } +} + +/** + * The host is injected by the entry point rather than chosen here: this module + * is shared by the Tauri desktop build and the browser showcase, and importing + * the Tauri host here would pull `@tauri-apps/*` into the web bundle. + */ +// The primitives in @feather/ui need a theme and a clipboard, both of which +// live in app state. Supplying them here keeps that state out of the package. +const uiDependencies: UiDependencies = { + useThemeMode: useTheme, + useSyntaxTheme: useSyntaxTheme as UiDependencies['useSyntaxTheme'], + copyToClipboard: copyToClipboardWithMeta, +}; + +export const AppProvider = ({ host, children }: { host: FeatherHost; children: React.ReactNode }) => { + return ( + + + + {children} + + + + ); +}; diff --git a/src/router.tsx b/apps/inspector/src/router.tsx similarity index 88% rename from src/router.tsx rename to apps/inspector/src/router.tsx index 075e9daf..a81ef2ea 100644 --- a/src/router.tsx +++ b/apps/inspector/src/router.tsx @@ -13,9 +13,9 @@ import { import { AppSidebar } from '@/components/app-sidebar'; import { CommandCenter } from '@/components/command-center'; import { SiteHeader } from '@/components/site-header'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { Toaster } from '@/components/ui/sonner'; -import { Button } from '@/components/ui/button'; +import { SidebarInset, SidebarProvider } from '@feather/ui/sidebar'; +import { Toaster } from '@feather/ui/sonner'; +import { Button } from '@feather/ui/button'; import Logs from './pages/log'; import Performance from './pages/performance'; import Observability from './pages/observable'; @@ -26,16 +26,12 @@ import TimeTravel from './pages/time-travel'; import SessionReplay from './pages/session-replay'; import Compare from './pages/compare'; import Assets from './pages/assets'; -import ParticleSystemPlayground from './pages/particle-system-playground'; -import ShaderGraph from './pages/shader-graph'; -import TextureLab from './pages/texture-lab'; import SessionPage from './pages/session'; import { SettingsModal } from './pages/settings'; import { useConfigStore } from './store/config'; import { AboutModal } from './pages/about'; import { useWsConnection } from './hooks/use-ws-connection'; -import { useMcpCreativeBridge } from './hooks/use-mcp-creative-bridge'; -import { isCreativeSession, sessionCanOpenRuntimePages, sessionSupportsRuntime, useSessionStore } from './store/session'; +import { sessionCanOpenRuntimePages, sessionSupportsRuntime, useSessionStore } from './store/session'; import { useSettingsStore } from './store/settings'; import { copyToClipboardWithMeta } from './utils/strings'; import { openUrl } from './utils/linking'; @@ -47,7 +43,6 @@ const CLI_DOCS_URL = 'https://kyonru.github.io/feather/cli/'; const Modals = () => { const disconnected = useConfigStore((state) => state.disconnected); useWsConnection(); - useMcpCreativeBridge(); useEffect(() => { if (disconnected) { @@ -85,12 +80,8 @@ const runtimeInterestForPath = (pathname: string) => { sessionReplay: pathname.startsWith('/session-replay'), console: pathname.startsWith('/console'), debugger: pathname.startsWith('/debugger'), - shaderGraph: pathname.startsWith('/shader-graph'), - particlePlayground: pathname.startsWith('/particle-system-playground'), pluginIds: [ pluginId, - pathname.startsWith('/shader-graph') ? 'shader-graph' : null, - pathname.startsWith('/particle-system-playground') ? 'particle-system-playground' : null, pathname.startsWith('/time-travel') ? 'time-travel' : null, pathname.startsWith('/session-replay') ? 'session-replay' : null, ].filter(Boolean), @@ -113,7 +104,7 @@ const runtimeRefreshCommandsForPath = (pathname: string): Array; } -function RequireWorkspaceSession({ children }: { children: React.ReactNode }) { - const activeSession = useSessionStore((state) => (state.sessionId ? state.sessions[state.sessionId] : null)); - return activeSession || isCreativeSession(activeSession) ? children : ; -} - export const Router = () => { useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -332,20 +318,10 @@ export const Router = () => { } /> - - - - } - /> } /> - } /> - } /> ; + set: (key: string, value: unknown) => void; + reset: (key: string) => void; + /** Drop everything. Exposed for settings, so the state is not a trap. */ + resetAll: () => void; +}; + +export const usePanelStateStore = create()( + persist( + (set) => ({ + values: {}, + set: (key, value) => set((state) => ({ values: { ...state.values, [key]: value } })), + reset: (key) => + set((state) => { + if (!(key in state.values)) return state; + const values = { ...state.values }; + delete values[key]; + return { values }; + }), + resetAll: () => set({ values: {} }), + }), + { + name: 'feather-panel-state', + version: 1, + }, + ), +); + +/** + * `useState`, but the value survives navigation and restarts. + * + * Drop-in for the common case: same tuple, same functional-update support, so + * migrating a panel is a one-line change per piece of state. + * + * `key` is a stable string namespaced by panel — `logs.search`, not `search` — + * because the store is flat and two panels wanting "search" must not collide. + */ +export function usePanelState(key: string, fallback: T): [T, (next: T | ((previous: T) => T)) => void] { + const stored = usePanelStateStore((state) => state.values[key]); + const write = usePanelStateStore((state) => state.set); + + // `undefined` means never set. A stored `null` or `false` is a real value and + // must not be replaced by the fallback. + const value = (stored === undefined ? fallback : stored) as T; + + const setValue = useCallback( + (next: T | ((previous: T) => T)) => { + const current = (usePanelStateStore.getState().values[key] ?? fallback) as T; + const resolved = typeof next === 'function' ? (next as (previous: T) => T)(current) : next; + write(key, resolved); + }, + [key, fallback, write], + ); + + return [value, setValue]; +} diff --git a/src/store/session.ts b/apps/inspector/src/store/session.ts similarity index 77% rename from src/store/session.ts rename to apps/inspector/src/store/session.ts index 2529d5f9..2ab1e796 100644 --- a/src/store/session.ts +++ b/apps/inspector/src/store/session.ts @@ -2,9 +2,16 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; export const PENDING_SESSION_NAME = 'Connecting game'; -export const CREATIVE_SESSION_PREFIX = 'creative:'; -export type SessionKind = 'live' | 'log-file' | 'time-travel-file' | 'creative'; +/** + * Feather Studio owns the creative tools as of v4, and with them the gameless + * "creative workspace" session. Inspector still recognizes the old marker so a + * workspace persisted by an earlier version is dropped on load rather than + * restored as a tab that opens nothing. + */ +const LEGACY_CREATIVE_SESSION_PREFIX = 'creative:'; + +export type SessionKind = 'live' | 'log-file' | 'time-travel-file'; export type SessionInfo = { id: string; @@ -20,23 +27,16 @@ export type SessionInfo = { runtimeSuspended?: boolean; }; -export function createCreativeSessionId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `${CREATIVE_SESSION_PREFIX}${crypto.randomUUID()}`; - } - return `${CREATIVE_SESSION_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; -} - -export function isCreativeSession(session?: SessionInfo | null): boolean { - return session?.kind === 'creative' || session?.id.startsWith(CREATIVE_SESSION_PREFIX) === true; +function isLegacyCreativeSession(id: string, session: SessionInfo): boolean { + return (session.kind as string) === 'creative' || id.startsWith(LEGACY_CREATIVE_SESSION_PREFIX); } export function sessionSupportsRuntime(session?: SessionInfo | null): session is SessionInfo & { connected: true; kind?: undefined } { - return !!session && !isCreativeSession(session) && !session.kind && session.connected; + return !!session && !session.kind && session.connected; } export function sessionCanOpenRuntimePages(session?: SessionInfo | null): boolean { - return !!session && !isCreativeSession(session); + return !!session; } type SessionStore = { @@ -53,13 +53,14 @@ type SessionStore = { export function prepareSessionsForPersistence(sessions: Record): Record { return Object.fromEntries( Object.entries(sessions) - .filter(([id, session]) => !id.startsWith('file:') && !session.pendingConfig && session.name !== PENDING_SESSION_NAME) - .map(([id, session]) => [ - id, - isCreativeSession(session) - ? { ...session, kind: 'creative' as const, connected: false, pendingConfig: false, runtimeSuspended: false } - : { ...session, connected: false, pendingConfig: false, runtimeSuspended: false }, - ]), + .filter( + ([id, session]) => + !id.startsWith('file:') && + !session.pendingConfig && + session.name !== PENDING_SESSION_NAME && + !isLegacyCreativeSession(id, session), + ) + .map(([id, session]) => [id, { ...session, connected: false, pendingConfig: false, runtimeSuspended: false }]), ); } diff --git a/apps/inspector/src/store/settings.ts b/apps/inspector/src/store/settings.ts new file mode 100644 index 00000000..c66350bc --- /dev/null +++ b/apps/inspector/src/store/settings.ts @@ -0,0 +1,215 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware'; +import { + DEFAULT_PINNED_SIDEBAR_TOOLS, + SIDEBAR_TOOL_ORDER, + type MainFeatureId, + type SidebarToolId, +} from '@/constants/main-features'; +import { compactStoredLogHistory, isStorageQuotaError } from './log-history'; +import { normalizeThemePreference, type ThemePreference } from '@feather/ui/theme/registry'; + +type SettingsStoreState = { + open: boolean; + /** + * Which settings section to show when the dialog opens. + * + * Exists so something else can point at a section — the command palette can + * now find settings, and "find" is only useful if it lands you on the right + * one rather than the first one. + */ + settingsTab: string; + theme: ThemePreference; + // Port the Feather desktop WS server listens on (games connect to this) + port: number; + textEditorPath: string; + cliPath: string; + cliProjectDir: string; + isLatestVersion: boolean; + apiKey: string; + appId: string; + sessionApiKeys: Record; + pausedLogs: boolean; + // Seconds without a message before considering a session disconnected (default 15) + connectionTimeout: number; + hiddenPlugins: string[]; + hiddenMainFeatures: MainFeatureId[]; + pinnedSidebarTools: SidebarToolId[]; + showHiddenMainFeaturesInCommandCenter: boolean; + assetSourceDir: string; +}; + +type SettingsStoreActions = { + setIsLatestVersion: (isLatestVersion: boolean) => void; + setOpen: (open: boolean, tab?: string) => void; + setTheme: (theme: ThemePreference) => void; + setPort: (port: number) => void; + setTextEditorPath: (textEditorPath: string) => void; + setCliPath: (cliPath: string) => void; + setCliProjectDir: (cliProjectDir: string) => void; + setPausedLogs: (pausedLogs: boolean) => void; + setApiKey: (apiKey: string) => void; + setAppId: (appId: string) => void; + regenerateAppId: () => void; + setSessionApiKey: (sessionId: string, apiKey: string) => void; + setConnectionTimeout: (timeout: number) => void; + toggleHiddenPlugin: (pluginId: string) => void; + toggleHiddenMainFeature: (featureId: MainFeatureId) => void; + setHiddenMainFeatures: (featureIds: MainFeatureId[]) => void; + togglePinnedSidebarTool: (toolId: SidebarToolId) => void; + setPinnedSidebarTools: (toolIds: SidebarToolId[]) => void; + setShowHiddenMainFeaturesInCommandCenter: (show: boolean) => void; + setAssetSourceDir: (dir: string) => void; + reset: () => void; +}; + +type SettingsStore = SettingsStoreState & SettingsStoreActions; + +function createAppId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `feather-app-${crypto.randomUUID()}`; + } + return `feather-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +function normalizePinnedSidebarTools(toolIds?: unknown): SidebarToolId[] { + if (!Array.isArray(toolIds)) return [...DEFAULT_PINNED_SIDEBAR_TOOLS]; + const valid = new Set(SIDEBAR_TOOL_ORDER); + const seen = new Set(); + const normalized: SidebarToolId[] = []; + + for (const toolId of toolIds) { + if (typeof toolId !== 'string' || !valid.has(toolId as SidebarToolId)) continue; + const id = toolId as SidebarToolId; + if (seen.has(id)) continue; + seen.add(id); + normalized.push(id); + } + + return SIDEBAR_TOOL_ORDER.filter((toolId) => normalized.includes(toolId)); +} + +const defaultSettings: SettingsStoreState = { + isLatestVersion: true, + open: false, + settingsTab: 'connection', + theme: 'system', + apiKey: '', + appId: createAppId(), + sessionApiKeys: {}, + port: 4004, + textEditorPath: '/usr/local/bin/code', + cliPath: '', + cliProjectDir: '', + pausedLogs: false, + connectionTimeout: 15, + hiddenPlugins: [], + hiddenMainFeatures: [], + pinnedSidebarTools: [...DEFAULT_PINNED_SIDEBAR_TOOLS], + showHiddenMainFeaturesInCommandCenter: false, + assetSourceDir: '', +}; + +function createSettingsStorage(): StateStorage { + const storage = globalThis.localStorage; + return { + getItem: (name) => storage.getItem(name), + removeItem: (name) => storage.removeItem(name), + setItem: (name, value) => { + try { + storage.setItem(name, value); + } catch (error) { + if (isStorageQuotaError(error) && compactStoredLogHistory(storage)) { + try { + storage.setItem(name, value); + return; + } catch (retryError) { + console.warn('[Feather] Could not persist settings after compacting log history:', retryError); + return; + } + } + console.warn('[Feather] Could not persist settings:', error); + } + }, + }; +} + +export const useSettingsStore = create()( + persist( + (set) => ({ + ...defaultSettings, + setIsLatestVersion: (isLatestVersion: boolean) => set({ isLatestVersion }), + setOpen: (open: boolean, tab?: string) => + set((state) => ({ open, settingsTab: tab ?? state.settingsTab ?? 'connection' })), + setTheme: (theme: ThemePreference) => set({ theme: normalizeThemePreference(theme) }), + setPort: (port: number) => set({ port }), + setTextEditorPath: (textEditorPath: string) => set({ textEditorPath }), + setCliPath: (cliPath: string) => set({ cliPath }), + setCliProjectDir: (cliProjectDir: string) => set({ cliProjectDir }), + reset: () => set((state) => ({ ...state, ...defaultSettings, open: state.open })), + setPausedLogs: (pausedLogs: boolean) => set({ pausedLogs }), + setApiKey: (apiKey: string) => set({ apiKey }), + setAppId: (appId: string) => set({ appId }), + regenerateAppId: () => set({ appId: createAppId() }), + setSessionApiKey: (sessionId: string, apiKey: string) => + set((state) => { + const sessionApiKeys = { ...state.sessionApiKeys }; + if (apiKey.trim() === '') { + delete sessionApiKeys[sessionId]; + } else { + sessionApiKeys[sessionId] = apiKey; + } + return { sessionApiKeys }; + }), + setConnectionTimeout: (connectionTimeout: number) => set({ connectionTimeout }), + setAssetSourceDir: (assetSourceDir: string) => set({ assetSourceDir }), + setHiddenMainFeatures: (hiddenMainFeatures: MainFeatureId[]) => set({ hiddenMainFeatures }), + setPinnedSidebarTools: (pinnedSidebarTools: SidebarToolId[]) => + set({ pinnedSidebarTools: normalizePinnedSidebarTools(pinnedSidebarTools) }), + setShowHiddenMainFeaturesInCommandCenter: (showHiddenMainFeaturesInCommandCenter: boolean) => + set({ showHiddenMainFeaturesInCommandCenter }), + togglePinnedSidebarTool: (toolId: SidebarToolId) => + set((state) => { + const pinnedSidebarTools = state.pinnedSidebarTools.includes(toolId) + ? state.pinnedSidebarTools.filter((id) => id !== toolId) + : [...state.pinnedSidebarTools, toolId]; + + return { pinnedSidebarTools: normalizePinnedSidebarTools(pinnedSidebarTools) }; + }), + toggleHiddenMainFeature: (featureId: MainFeatureId) => + set((state) => ({ + hiddenMainFeatures: state.hiddenMainFeatures.includes(featureId) + ? state.hiddenMainFeatures.filter((id) => id !== featureId) + : [...state.hiddenMainFeatures, featureId], + })), + toggleHiddenPlugin: (pluginId: string) => + set((state) => ({ + hiddenPlugins: state.hiddenPlugins.includes(pluginId) + ? state.hiddenPlugins.filter((id) => id !== pluginId) + : [...state.hiddenPlugins, pluginId], + })), + }), + { + name: 'settings-storage', + storage: createJSONStorage(createSettingsStorage), + partialize: ({ settingsTab: _settingsTab, open: _open, ...rest }) => rest, + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial | undefined; + + return { + ...currentState, + ...persisted, + theme: normalizeThemePreference(persisted?.theme), + pinnedSidebarTools: normalizePinnedSidebarTools(persisted?.pinnedSidebarTools), + // Which section you were last on is navigation, not a preference. + // Persisting it means opening Settings and landing wherever you + // happened to finish last time — and by C1's own rule, this is not + // something anyone is annoyed to choose again, because you open + // Settings *for* a reason each time. It stays for the life of the app + // run so the palette can point at a section, and resets after that. + settingsTab: currentState.settingsTab, + }; + }, + }, + ), +); diff --git a/src/types/external.d.ts b/apps/inspector/src/types/external.d.ts similarity index 100% rename from src/types/external.d.ts rename to apps/inspector/src/types/external.d.ts diff --git a/src/utils/arrays.ts b/apps/inspector/src/utils/arrays.ts similarity index 100% rename from src/utils/arrays.ts rename to apps/inspector/src/utils/arrays.ts diff --git a/src/utils/assets.ts b/apps/inspector/src/utils/assets.ts similarity index 100% rename from src/utils/assets.ts rename to apps/inspector/src/utils/assets.ts diff --git a/src/utils/cache.ts b/apps/inspector/src/utils/cache.ts similarity index 100% rename from src/utils/cache.ts rename to apps/inspector/src/utils/cache.ts diff --git a/src/utils/diff.ts b/apps/inspector/src/utils/diff.ts similarity index 100% rename from src/utils/diff.ts rename to apps/inspector/src/utils/diff.ts diff --git a/apps/inspector/src/utils/file.ts b/apps/inspector/src/utils/file.ts new file mode 100644 index 00000000..03a0dab0 --- /dev/null +++ b/apps/inspector/src/utils/file.ts @@ -0,0 +1,34 @@ +import { toast } from 'sonner'; +import type { FeatherHost } from '@feather/host'; + +import { base64ToUint8Array } from './arrays'; +import { fetchBlobAsUint8Array } from './assets'; + +/** + * Save a file for the user. + * + * Previously branched on `isWeb()` and carried two implementations. The host + * owns that difference now, which is also what keeps `@tauri-apps/plugin-fs` + * out of the shared packages Feather Studio and the showcase build against. + */ +export async function downloadFile( + host: FeatherHost, + name: string, + src: string, + type: 'string' | 'base64', +) { + try { + if (!src) return; + + const bytes = type === 'base64' ? base64ToUint8Array(src) : await fetchBlobAsUint8Array(src); + if (!bytes) return; + + await host.saveBinaryFile(name, bytes); + + if (host.kind === 'tauri') { + toast.success('File added to the downloads folder', { position: 'bottom-center' }); + } + } catch { + toast.error('Failed to download file', { position: 'bottom-center' }); + } +} diff --git a/src/utils/linking.ts b/apps/inspector/src/utils/linking.ts similarity index 100% rename from src/utils/linking.ts rename to apps/inspector/src/utils/linking.ts diff --git a/src/utils/performance-metrics.ts b/apps/inspector/src/utils/performance-metrics.ts similarity index 100% rename from src/utils/performance-metrics.ts rename to apps/inspector/src/utils/performance-metrics.ts diff --git a/src/utils/platform.ts b/apps/inspector/src/utils/platform.ts similarity index 100% rename from src/utils/platform.ts rename to apps/inspector/src/utils/platform.ts diff --git a/src/utils/session-reconnect.ts b/apps/inspector/src/utils/session-reconnect.ts similarity index 100% rename from src/utils/session-reconnect.ts rename to apps/inspector/src/utils/session-reconnect.ts diff --git a/src/utils/strings.ts b/apps/inspector/src/utils/strings.ts similarity index 100% rename from src/utils/strings.ts rename to apps/inspector/src/utils/strings.ts diff --git a/src/utils/styles.ts b/apps/inspector/src/utils/styles.ts similarity index 100% rename from src/utils/styles.ts rename to apps/inspector/src/utils/styles.ts diff --git a/src/utils/timers.ts b/apps/inspector/src/utils/timers.ts similarity index 100% rename from src/utils/timers.ts rename to apps/inspector/src/utils/timers.ts diff --git a/src/utils/versions.ts b/apps/inspector/src/utils/versions.ts similarity index 100% rename from src/utils/versions.ts rename to apps/inspector/src/utils/versions.ts diff --git a/src/vite-env.d.ts b/apps/inspector/src/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to apps/inspector/src/vite-env.d.ts diff --git a/apps/showcase/e2e/helpers/shader-preview-fixture.ts b/apps/showcase/e2e/helpers/shader-preview-fixture.ts new file mode 100644 index 00000000..c385ccc6 --- /dev/null +++ b/apps/showcase/e2e/helpers/shader-preview-fixture.ts @@ -0,0 +1,125 @@ +import { deflateSync } from 'node:zlib'; + +function crc32(buffer: Buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data = Buffer.alloc(0)) { + const typeBuffer = Buffer.from(type, 'ascii'); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0); + return Buffer.concat([length, typeBuffer, data, crc]); +} + +function pngRgba(width: number, height: number, pixels: Array<[number, number, number, number]>) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 6; + header[10] = 0; + header[11] = 0; + header[12] = 0; + + const stride = width * 4 + 1; + const raw = Buffer.alloc(stride * height); + for (let y = 0; y < height; y += 1) { + raw[y * stride] = 0; + for (let x = 0; x < width; x += 1) { + const pixel = pixels[y * width + x] ?? [0, 0, 0, 255]; + const offset = y * stride + 1 + x * 4; + raw[offset] = pixel[0]; + raw[offset + 1] = pixel[1]; + raw[offset + 2] = pixel[2]; + raw[offset + 3] = pixel[3]; + } + } + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(raw)), + pngChunk('IEND'), + ]); +} + +export function shaderPreviewTextureFiles() { + return { + water: { + name: 'water.png', + mimeType: 'image/png', + buffer: pngRgba(4, 4, [ + [42, 98, 230, 255], [45, 128, 235, 255], [38, 165, 214, 255], [42, 98, 230, 255], + [32, 175, 170, 255], [34, 132, 224, 255], [32, 175, 170, 255], [38, 165, 214, 255], + [42, 98, 230, 255], [31, 180, 155, 255], [44, 122, 235, 255], [31, 180, 155, 255], + [38, 165, 214, 255], [42, 98, 230, 255], [32, 175, 170, 255], [44, 122, 235, 255], + ]), + }, + noise: { + name: 'simplex-noise-64.png', + mimeType: 'image/png', + buffer: pngRgba(4, 4, [ + [238, 72, 97, 255], [69, 214, 162, 255], [78, 102, 235, 255], [242, 205, 85, 255], + [152, 86, 226, 255], [244, 116, 62, 255], [58, 186, 232, 255], [142, 220, 88, 255], + [238, 182, 70, 255], [72, 218, 118, 255], [232, 76, 177, 255], [85, 134, 236, 255], + [62, 205, 212, 255], [226, 90, 92, 255], [118, 226, 74, 255], [212, 86, 236, 255], + ]), + }, + mask: { + name: '3-mask.png', + mimeType: 'image/png', + buffer: pngRgba(4, 4, [ + [255, 0, 0, 255], [255, 0, 0, 255], [0, 0, 255, 255], [255, 0, 0, 255], + [255, 0, 0, 255], [0, 0, 255, 255], [255, 0, 0, 255], [255, 0, 0, 255], + [0, 0, 255, 255], [255, 0, 0, 255], [255, 0, 0, 255], [0, 0, 255, 255], + [255, 0, 0, 255], [255, 0, 0, 255], [0, 0, 255, 255], [255, 0, 0, 255], + ]), + }, + }; +} + +export function textureHeavyPreviewGraph() { + return { + type: 'feather.shader-graph', + version: 3, + exportedAt: new Date('2026-05-29T00:00:00.000Z').toISOString(), + shaderName: 'texture-heavy-preview', + playgroundTarget: null, + nodes: [ + { id: 'base', type: 'shaderNode', position: { x: 0, y: 0 }, data: { label: 'Base Sprite', nodeType: 'TextureColor' } }, + { id: 'uv', type: 'shaderNode', position: { x: 0, y: 140 }, data: { label: 'Source UV', nodeType: 'TextureCoords' } }, + { id: 'noiseTex', type: 'shaderNode', position: { x: 0, y: 300 }, data: { label: 'Noise Texture', nodeType: 'TextureInput', uniformName: 'noiseTexture' } }, + { id: 'noiseSample', type: 'shaderNode', position: { x: 260, y: 260 }, data: { label: 'Sample Noise', nodeType: 'SampleTexture' } }, + { id: 'maskTex', type: 'shaderNode', position: { x: 0, y: 470 }, data: { label: 'Mask Texture', nodeType: 'TextureInput', uniformName: 'maskTexture' } }, + { id: 'maskSample', type: 'shaderNode', position: { x: 260, y: 450 }, data: { label: 'Sample Mask', nodeType: 'SampleTexture' } }, + { id: 'key', type: 'shaderNode', position: { x: 520, y: 430 }, data: { label: 'Red Mask', nodeType: 'ColorKeyMask' } }, + { id: 'opacity', type: 'shaderNode', position: { x: 520, y: 170 }, data: { label: 'Opacity', nodeType: 'FloatConstant', values: { val: 0.85 } } }, + { id: 'mix', type: 'shaderNode', position: { x: 790, y: 170 }, data: { label: 'Masked Texture Mix', nodeType: 'EffectMix' } }, + { id: 'preview', type: 'shaderNode', position: { x: 1060, y: 170 }, data: { label: 'Texture Probe', nodeType: 'Preview' } }, + { id: 'out', type: 'shaderNode', position: { x: 1320, y: 170 }, data: { label: 'Fragment Output', nodeType: 'FragmentOutput' } }, + ], + edges: [ + { id: 'base:out->mix:base', source: 'base', sourceHandle: 'out', target: 'mix', targetHandle: 'base' }, + { id: 'noiseTex:texture->noiseSample:texture', source: 'noiseTex', sourceHandle: 'texture', target: 'noiseSample', targetHandle: 'texture' }, + { id: 'uv:out->noiseSample:uv', source: 'uv', sourceHandle: 'out', target: 'noiseSample', targetHandle: 'uv' }, + { id: 'noiseSample:out->mix:effect', source: 'noiseSample', sourceHandle: 'out', target: 'mix', targetHandle: 'effect' }, + { id: 'maskTex:texture->maskSample:texture', source: 'maskTex', sourceHandle: 'texture', target: 'maskSample', targetHandle: 'texture' }, + { id: 'uv:out->maskSample:uv', source: 'uv', sourceHandle: 'out', target: 'maskSample', targetHandle: 'uv' }, + { id: 'maskSample:out->key:source', source: 'maskSample', sourceHandle: 'out', target: 'key', targetHandle: 'source' }, + { id: 'key:mask->mix:mask', source: 'key', sourceHandle: 'mask', target: 'mix', targetHandle: 'mask' }, + { id: 'opacity:out->mix:opacity', source: 'opacity', sourceHandle: 'out', target: 'mix', targetHandle: 'opacity' }, + { id: 'mix:out->preview:color', source: 'mix', sourceHandle: 'out', target: 'preview', targetHandle: 'color' }, + { id: 'preview:out->out:color', source: 'preview', sourceHandle: 'out', target: 'out', targetHandle: 'color' }, + ], + subgraphs: [], + }; +} diff --git a/e2e/showcase.spec.ts b/apps/showcase/e2e/showcase.spec.ts similarity index 92% rename from e2e/showcase.spec.ts rename to apps/showcase/e2e/showcase.spec.ts index d0d8a3bd..5dd0c059 100644 --- a/e2e/showcase.spec.ts +++ b/apps/showcase/e2e/showcase.spec.ts @@ -369,7 +369,14 @@ test('texture lab generates textures and feeds creative tools in the showcase', await expect(textureHeader.getByRole('button', { name: /reset values/i })).toBeVisible(); await expect(textureHeader.getByRole('button', { name: /regenerate/i })).toBeVisible(); await expect(textureHeader.getByRole('button', { name: /export png/i })).toBeVisible(); - await expect(textureHeader.getByRole('button', { name: /use as shader preview/i })).toBeVisible(); + // The apply button names where the texture will go, and that target depends on + // what is available: an active particle emitter, then a selected shader node, + // then the shader preview as a fallback. This asserted the *fallback* label, + // which was only reachable because Texture Lab was wired to the live particle + // controller — unavailable with no game — instead of the local one. With that + // fixed it can feed the emitter, so the label is now "Use in ". The + // property worth pinning is that the button exists and says where it applies. + await expect(textureHeader.getByRole('button', { name: /^use (in|as) /i })).toBeVisible(); await expect(page.getByLabel('Texture background color')).toHaveValue('#000000'); await expect(page.getByLabel('Texture background alpha')).toHaveValue('0'); const preview = page.getByTestId('texture-lab-preview'); @@ -1616,3 +1623,135 @@ test('shader graph preview probes render texture-heavy uploads in the showcase', await probe.click(); await expectTextureProbePayload(page); }); + +// --- Studio preferences migration (V4.md Phase 05a) -------------------------- +// +// Studio-owned preferences were extracted out of the Inspector settings store so +// the two applications can be split. That store is persisted user data — +// texture recipes and saved workspaces people built by hand — so these cover the +// cases where a careless migration would silently destroy it. + +const LEGACY_STUDIO_SETTINGS = { + state: { + theme: 'dark', + port: 4004, + assetSourceDir: '/legacy/assets', + particleTimelineZoom: 2.5, + particleTimelineSnap: false, + collapsedShaderGraphNodeCategories: ['math'], + textureLabWorkspaceId: 'my-game', + textureLabSavedRecipes: [ + { id: 'r1', name: 'Legacy Noise', recipe: {}, createdAt: 1, updatedAt: 2 }, + ], + textureLabWorkspaces: { + 'my-game': { recipe: {}, savedRecipes: [] }, + }, + }, + version: 0, +}; + +async function readStudioStore(page: Page) { + return page.evaluate(() => { + const raw = localStorage.getItem('studio-preferences'); + return raw ? (JSON.parse(raw).state as Record) : null; + }); +} + +test('studio preferences adopt legacy settings once, without destroying them', async ({ page }) => { + await page.addInitScript((legacy) => { + localStorage.setItem('feather-e2e-query-client', '1'); + localStorage.setItem('settings-storage', JSON.stringify(legacy)); + }, LEGACY_STUDIO_SETTINGS); + await page.goto('/texture-lab'); + + await expect.poll(async () => (await readStudioStore(page))?.migratedFrom).toBe(1); + const studio = await readStudioStore(page); + expect(studio?.particleTimelineZoom).toBe(2.5); + expect(studio?.particleTimelineSnap).toBe(false); + // /texture-lab activates a per-session workspace on mount, so the *active* id + // moves to 'default'. What matters is that activation folded the migrated + // workspace into the snapshot map rather than dropping it. + expect(Object.keys(studio?.textureLabWorkspaces as object)).toContain('my-game'); + expect(studio?.assetSourceDir).toBe('/legacy/assets'); + // The saved recipes fold into the workspace they belonged to when the page + // activates a different one. Losing them here is the failure this guards. + const workspaces = studio?.textureLabWorkspaces as Record; + expect(workspaces['my-game'].savedRecipes).toHaveLength(1); + + // Rollback path: the Inspector copy is read, never cleared. + const legacyStillThere = await page.evaluate(() => localStorage.getItem('settings-storage')); + expect(legacyStillThere).toBeTruthy(); + expect(JSON.parse(legacyStillThere!).state.textureLabSavedRecipes).toHaveLength(1); +}); + +test('studio preferences survive a malformed legacy payload', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('feather-e2e-query-client', '1'); + // Truncated JSON, plus a field of entirely the wrong shape. + localStorage.setItem('settings-storage', '{"state":{"textureLabWorkspaces":'); + }); + await page.goto('/texture-lab'); + + // The app must load and the store must fall back to defaults rather than throw. + await expect.poll(async () => (await readStudioStore(page))?.particleTimelineZoom).toBe(1); + expect((await readStudioStore(page))?.migratedFrom).toBe(0); +}); + +test('studio preferences migration is idempotent across reloads', async ({ page }) => { + await page.addInitScript((legacy) => { + localStorage.setItem('feather-e2e-query-client', '1'); + localStorage.setItem('settings-storage', JSON.stringify(legacy)); + }, LEGACY_STUDIO_SETTINGS); + await page.goto('/texture-lab'); + await expect.poll(async () => (await readStudioStore(page))?.migratedFrom).toBe(1); + + // Diverge from the legacy value, then reload. A second adoption would clobber it. + await page.evaluate(() => { + const raw = JSON.parse(localStorage.getItem('studio-preferences')!); + raw.state.particleTimelineZoom = 1; + localStorage.setItem('studio-preferences', JSON.stringify(raw)); + }); + await page.reload(); + + await expect.poll(async () => (await readStudioStore(page))?.particleTimelineZoom).toBe(1); +}); + +test('studio preferences start clean with no legacy data', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('feather-e2e-query-client', '1'); + }); + await page.goto('/texture-lab'); + + await expect.poll(async () => (await readStudioStore(page)) !== null).toBe(true); + const studio = await readStudioStore(page); + expect(studio?.migratedFrom).toBe(0); + expect(studio?.particleTimelineZoom).toBe(1); +}); + +test('the floating preview never covers the diagnostics it explains', async ({ page }) => { + await page.goto('/shader-graph'); + await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible(); + await page.getByRole('tab', { name: 'Output' }).click(); + await expect(page.getByTestId('love-js-preview-floating')).toBeVisible(); + + const covered = await page.evaluate(() => { + const preview = document.querySelector('[data-testid="love-js-preview-floating"]'); + const panel = document.querySelector('[data-testid="shader-right-panel"]'); + if (!preview || !panel) return ['missing preview or panel']; + const p = preview.getBoundingClientRect(); + const hidden: string[] = []; + for (const control of panel.querySelectorAll('button, input, [role="tab"]')) { + const c = control.getBoundingClientRect(); + if (c.width === 0 || c.height === 0) continue; + if (c.right > p.left && c.left < p.right && c.bottom > p.top && c.top < p.bottom) { + hidden.push((control.textContent ?? control.tagName).trim().slice(0, 50)); + } + } + return hidden; + }); + + // A diagnostic's "fix this" button under the floating preview cannot be + // clicked at all — the preview swallows the pointer. The preview publishes + // its footprint as --floating-preview-gutter and this panel reserves it. + expect(covered, 'controls sit underneath the floating preview').toEqual([]); +}); diff --git a/apps/showcase/index.html b/apps/showcase/index.html new file mode 100644 index 00000000..4c52dfb6 --- /dev/null +++ b/apps/showcase/index.html @@ -0,0 +1,12 @@ + + + + + + Feather Studio — Showcase + + +
+ + + diff --git a/apps/showcase/playwright.config.ts b/apps/showcase/playwright.config.ts new file mode 100644 index 00000000..52d43cea --- /dev/null +++ b/apps/showcase/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * The Showcase suite: Feather Studio running in a browser. + * + * Runs against the built output rather than dev, because the showcase ships as + * a static site and that is what users get. + */ +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + use: { baseURL: 'http://127.0.0.1:4174', trace: 'on-first-retry', screenshot: 'only-on-failure' }, + webServer: { + command: + 'pnpm run showcase:build && pnpm exec vite preview --config apps/showcase/vite.config.ts --host 127.0.0.1', + url: 'http://127.0.0.1:4174', + cwd: repoRoot, + reuseExistingServer: process.env.PLAYWRIGHT_REUSE_SERVER === '1', + timeout: 180_000, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/apps/showcase/src/main.tsx b/apps/showcase/src/main.tsx new file mode 100644 index 00000000..d9516089 --- /dev/null +++ b/apps/showcase/src/main.tsx @@ -0,0 +1,9 @@ +/** + * The showcase entry. + * + * Deliberately three lines of its own: everything it renders belongs to Feather + * Studio, and the showcase exists only to host it in a browser with the web host + * and no session bridge. + */ +import '@studio/showcase/showcase-app.css'; +import '@studio/showcase/mount'; diff --git a/apps/showcase/vite.config.ts b/apps/showcase/vite.config.ts new file mode 100644 index 00000000..35161302 --- /dev/null +++ b/apps/showcase/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import path from 'path'; +// @ts-expect-error -- plain .mjs helper shared with Studio's own config +import { loveJsPreviewPlugin, loveJsDevHeaders, loveJsPreviewHeaders } from '../../scripts/vite-lovejs-plugin.mjs'; + +const root = __dirname; +const repoRoot = path.resolve(root, '../..'); + +/** + * The showcase: Feather Studio running in a browser. + * + * A thin shell. It owns no page code — it mounts Studio's web entry with the web + * host and no session bridge, which is the standalone case Studio already + * supports. This replaces the root `vite.showcase.config.ts`, which duplicated + * the Inspector's love.js middleware and CSP block and had begun to drift from it. + */ +export default defineConfig({ + root, + base: './', + cacheDir: path.join(repoRoot, 'node_modules/.vite/feather-showcase'), + plugins: [loveJsPreviewPlugin({ repoRoot, required: true }), react(), tailwindcss()], + resolve: { + alias: [ + { find: /^@studio\/(.*)$/, replacement: path.resolve(repoRoot, 'apps/studio/src/$1') }, + { find: /^@feather\/ui\/hooks\/(.*)$/, replacement: path.resolve(repoRoot, 'packages/ui/src/hooks/$1') }, + { find: /^@feather\/ui\/theme\/registry$/, replacement: path.resolve(repoRoot, 'packages/ui/src/theme/registry/index.ts') }, + { find: /^@feather\/ui\/theme\/(.*)$/, replacement: path.resolve(repoRoot, 'packages/ui/src/theme/$1') }, + { find: /^@feather\/ui$/, replacement: path.resolve(repoRoot, 'packages/ui/src/index.ts') }, + { find: /^@feather\/ui\/(.*)$/, replacement: path.resolve(repoRoot, 'packages/ui/src/components/$1') }, + { find: /^@feather\/host$/, replacement: path.resolve(repoRoot, 'packages/host/src/index.ts') }, + { find: /^@feather\/session-bridge$/, replacement: path.resolve(repoRoot, 'packages/session-bridge/src/index.ts') }, + ], + }, + build: { + outDir: path.join(repoRoot, 'dist-showcase'), + emptyOutDir: true, + }, + // Studio's source lives outside this root, so Vite must be allowed to serve it. + server: { port: 5174, strictPort: true, headers: loveJsDevHeaders, fs: { allow: [repoRoot] } }, + preview: { port: 4174, strictPort: true, headers: loveJsPreviewHeaders }, +}); diff --git a/apps/studio/e2e/studio.spec.ts b/apps/studio/e2e/studio.spec.ts new file mode 100644 index 00000000..c802d58e --- /dev/null +++ b/apps/studio/e2e/studio.spec.ts @@ -0,0 +1,210 @@ +import { expect, test } from '@playwright/test'; + +/** + * Feather Studio's shell. + * + * The tools themselves are covered by the Showcase suite, which renders the same + * components. What is unique to Studio — and what these assert — is that it is a + * *separate application*: it boots without an Inspector, treats the bridge as + * optional, and never assumes a game is attached. + * + * These replaced four tests that asserted the Phase 05b placeholder — its + * heading, its subtitle, its connect button. They passed for as long as Studio + * rendered none of the tools it ships, which is exactly the failure mode V4.md + * D43 describes: a test that asserts current behaviour locks in the thing you + * are trying to change. They now assert the property instead — that each tool is + * reachable and usable with nothing else running. + */ + +const TOOLS = [ + { id: 'shader-graph', label: 'Shader Graph' }, + { id: 'particle-playground', label: 'Particles' }, + { id: 'texture-lab', label: 'Texture Lab' }, +] as const; + +/** Navigation overlays rather than occupying, so reaching a tool goes through it. */ +async function chooseTool(page: import('@playwright/test').Page, id: string) { + await page.getByTestId('studio-menu-trigger').click(); + await page.getByTestId(`studio-tool-${id}`).click(); + await expect(page.getByTestId('studio-menu')).toBeHidden(); +} + +test('Studio boots as a standalone application', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('studio-strip')).toBeVisible(); + await expect(page.getByTestId('studio-current-tool')).toBeVisible(); + + await page.getByTestId('studio-menu-trigger').click(); + for (const tool of TOOLS) { + await expect(page.getByTestId(`studio-tool-${tool.id}`)).toBeVisible(); + } +}); + +test('the menu overlays the work and closes after choosing', async ({ page }) => { + // It is a menu rather than a rail because three tools do not justify taking + // width from a node graph permanently. The cost of that is one extra click, + // so it must not also cost a dismissal. + await page.goto('/'); + await expect(page.getByTestId('studio-menu')).toHaveCount(0); + + await page.getByTestId('studio-menu-trigger').click(); + await expect(page.getByTestId('studio-menu')).toBeVisible(); + + await page.getByTestId('studio-tool-texture-lab').click(); + await expect(page.getByTestId('studio-menu')).toBeHidden(); + await expect(page.getByTestId('studio-current-tool')).toHaveText('Texture Lab'); +}); + +test('every tool is usable with no Inspector and no game', async ({ page }) => { + // The whole point of Studio: local authoring is the default, not a degraded + // mode. Particle Playground in particular used to report itself "not + // available in this session" here, because it asked whether the session was + // creative and Studio never creates one. + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto('/'); + const surface = page.getByTestId('studio-tool-surface'); + + for (const tool of TOOLS) { + await chooseTool(page, tool.id); + await expect(surface).toBeVisible(); + await expect(surface).not.toContainText('not available in this session'); + await expect(surface).not.toContainText('No session'); + // Something substantial rendered, not just a frame. + const text = await surface.innerText(); + expect(text.trim().length, `${tool.label} rendered almost nothing`).toBeGreaterThan(120); + } + + expect(errors, `a tool threw:\n${errors.join('\n')}`).toEqual([]); +}); + +test('the tools are styled, not unstyled markup', async ({ page }) => { + // The tools reference design tokens 394 times. Studio mounted no ThemeProvider + // and registered no Tailwind colour utilities, so every one of them resolved + // to nothing — the tools would have rendered as bare text had the shell ever + // mounted them. + await page.goto('/'); + + // Two separate things, and it is worth keeping them apart: the `:root` block + // in theme-tokens.css is a fallback so the first frame is not unstyled, while + // ThemeProvider writes the *resolved* theme as an inline style on the root. + // Only the inline value proves the provider is mounted — a computed value + // would be satisfied by the fallback alone, which is how the first version of + // this test passed with the provider removed. + const inlineBackground = await page.evaluate(() => + document.documentElement.style.getPropertyValue('--background').trim(), + ); + expect(inlineBackground, 'ThemeProvider is not mounted').toMatch(/^#|rgb/); + + const cardColor = await page.evaluate(() => { + const probe = document.createElement('div'); + probe.className = 'bg-card'; + document.body.appendChild(probe); + const value = getComputedStyle(probe).backgroundColor; + probe.remove(); + return value; + }); + // An unregistered utility leaves the background transparent. + expect(cardColor, 'bg-card is not a real utility').not.toBe('rgba(0, 0, 0, 0)'); +}); + +test('the chosen tool survives a reload', async ({ page }) => { + await page.goto('/'); + await chooseTool(page, 'texture-lab'); + await expect(page.getByTestId('studio-current-tool')).toHaveText('Texture Lab'); + + await page.reload(); + await expect(page.getByTestId('studio-current-tool')).toHaveText('Texture Lab'); +}); + +test('no Inspector is a neutral fact, not an error', async ({ page }) => { + await page.goto('/'); + + // The strip says so without asking for anything: "Local only" is a state, not + // a prompt. Connecting is offered in the menu, where a deliberate act belongs. + await expect(page.getByTestId('studio-inspector-status')).toContainText('Local only'); + + await page.getByTestId('studio-menu-trigger').click(); + const detail = page.getByTestId('studio-inspector-detail'); + await expect(detail).toContainText('Local only'); + await expect(detail.getByRole('button', { name: /Connect Inspector/ })).toBeVisible(); + await expect(detail).toContainText('Studio works as a local editor without it'); +}); + +test('a failed Inspector connection leaves Studio working', async ({ page }) => { + await page.goto('/'); + await page.getByTestId('studio-menu-trigger').click(); + // Nothing is listening on the bridge port in this suite, so connecting fails. + await page.getByRole('button', { name: /Connect Inspector/ }).click(); + await page.keyboard.press('Escape'); + + // The tool keeps working, which is the property that matters — a failed + // optional connection must not take the application with it. + await expect(page.getByTestId('studio-tool-surface')).toBeVisible(); + await expect(page.getByTestId('studio-inspector-status')).toContainText('Local only'); +}); + +test('the three tools share one visual language', async ({ page }) => { + await page.goto('/'); + + for (const tool of ['shader-graph', 'particle-playground', 'texture-lab'] as const) { + await page.getByTestId('studio-menu-trigger').click(); + await page.getByTestId(`studio-tool-${tool}`).click(); + await expect(page.getByTestId('studio-menu')).toBeHidden(); + await expect(page.getByTestId('studio-tool-surface')).toBeVisible(); + + const harsh = await page.evaluate(() => { + const offenders: string[] = []; + for (const el of document.querySelectorAll('*')) { + const style = getComputedStyle(el); + if (style.borderTopWidth === '0px' || style.borderTopStyle === 'none') continue; + // sRGB forms only: an oklch() severity stripe has small numeric + // components that would otherwise read as "near black". + const color = style.borderTopColor; + if (!color.startsWith('rgb')) continue; + const parts = color.match(/[\d.]+/g); + if (!parts) continue; + const [r, g, b, a] = parts.map(Number); + if (r < 90 && g < 90 && b < 90 && (a === undefined || a > 0.5)) { + offenders.push(`${el.tagName.toLowerCase()}.${Array.from(el.classList).join(' ')}`); + } + } + return offenders; + }); + + // Tailwind v4 defaults `border` to `currentColor`, so a missing + // `border-border` base rule draws every container in the *text* colour. + // Studio shipped that way once: 172 near-black outlines in Shader Graph + // alone, which is what "strong border on every container" looked like. + // See V4-STUDIO.md §5 (W2b). + expect(harsh, `${tool} draws borders in the text colour`).toEqual([]); + } +}); + +test('the love.js preview target is served, not the app itself', async ({ page }) => { + await page.goto('/'); + + // Both preview entry points Studio embeds: the node probe inside the shader + // graph (webgl.html) and the floating preview (index.html). + for (const entry of ['showcase-lovejs/webgl.html', 'showcase-lovejs/index.html']) { + const response = await page.request.get(entry); + expect(response.status(), `${entry} is not served`).toBe(200); + + const body = await response.text(); + + // The failure this guards is silent: Vite has no such file, the SPA + // fallback answers with index.html, and the iframe renders Studio inside + // itself. It looks like a broken preview, not a missing asset. + expect(body, `${entry} fell through to the SPA shell`).not.toContain('
'); + expect(body.toLowerCase()).toContain('preview'); + } + + // The player and the game payload it loads must be there too — an entry page + // that resolves while its assets 404 is the same defect one level down. + for (const asset of ['showcase-lovejs/showcase.love', 'showcase-lovejs/webgl-player.js']) { + const response = await page.request.get(asset); + expect(response.status(), `${asset} is not served`).toBe(200); + expect((await response.body()).byteLength, `${asset} is empty`).toBeGreaterThan(0); + } +}); diff --git a/apps/studio/index.html b/apps/studio/index.html new file mode 100644 index 00000000..bf927429 --- /dev/null +++ b/apps/studio/index.html @@ -0,0 +1,12 @@ + + + + + + Feather Studio + + +
+ + + diff --git a/apps/studio/package.json b/apps/studio/package.json new file mode 100644 index 00000000..238ab897 --- /dev/null +++ b/apps/studio/package.json @@ -0,0 +1,21 @@ +{ + "name": "@feather/studio", + "version": "0.1.0", + "private": true, + "description": "Feather Studio \u2014 standalone creative tooling for L\u00d6VE: shader graph, texture lab, particle playground", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.json && vite build && node ../../scripts/prepare-lovejs-dist.mjs apps/studio/dist/showcase-lovejs", + "preview": "vite preview", + "typecheck": "tsc --noEmit -p tsconfig.json", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build" + }, + "dependencies": { + "@feather/host": "workspace:*", + "@feather/session-bridge": "workspace:*", + "@feather/ui": "workspace:*" + } +} diff --git a/apps/studio/playwright.config.ts b/apps/studio/playwright.config.ts new file mode 100644 index 00000000..bb3698bb --- /dev/null +++ b/apps/studio/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Feather Studio's own suite. + * + * Runs against Studio's dev server, which is a different application from the + * Showcase even though they render the same tools: Studio has its own shell, + * its own Tauri host detection, and the optional Inspector bridge. + */ +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + use: { baseURL: 'http://127.0.0.1:1430', trace: 'on-first-retry', screenshot: 'only-on-failure' }, + webServer: { + command: 'pnpm --filter @feather/studio run dev', + url: 'http://127.0.0.1:1430', + reuseExistingServer: process.env.PLAYWRIGHT_REUSE_SERVER === '1', + timeout: 120_000, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/apps/studio/src-tauri/.gitignore b/apps/studio/src-tauri/.gitignore new file mode 100644 index 00000000..212c846c --- /dev/null +++ b/apps/studio/src-tauri/.gitignore @@ -0,0 +1,3 @@ +# Generated by cargo/tauri. Matches the Inspector crate's own ignore. +/target/ +/gen/schemas diff --git a/apps/studio/src-tauri/Cargo.lock b/apps/studio/src-tauri/Cargo.lock new file mode 100644 index 00000000..dccda176 --- /dev/null +++ b/apps/studio/src-tauri/Cargo.lock @@ -0,0 +1,5289 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[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", + "tracing", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +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-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[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 = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[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 = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.5+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "feather-studio" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-opener", + "tokio", + "uuid", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +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_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +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_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.5+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.5+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.5+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/apps/studio/src-tauri/Cargo.toml b/apps/studio/src-tauri/Cargo.toml new file mode 100644 index 00000000..e558ae7a --- /dev/null +++ b/apps/studio/src-tauri/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "feather-studio" +version = "0.1.0" +description = "Feather Studio is a standalone creative tool for LÖVE: shader graphs, texture generation, and particle authoring. It edits assets locally and can optionally push them to a game through Feather Inspector." +authors = ["kyonru"] +edition = "2021" + +[lib] +name = "feather_studio_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri-plugin-opener = "2" +tauri-plugin-fs = "2" +tauri-plugin-dialog = "2" +axum = "0.8" +tokio = { version = "1", features = ["full"] } +uuid = { version = "1", features = ["v4"] } + +# Deliberately absent, compared with the Inspector crate: +# tauri-plugin-shell — Studio spawns no CLI sidecar. +# +# axum and tokio ARE here, for the loopback creative MCP endpoint in +# creative_mcp.rs. The boundary that matters is not "no HTTP server" but "no +# second path to a running game": that endpoint serves Studio's own authoring +# state, and pushing work into a game still goes through the authenticated +# Inspector bridge. Studio still runs no WebSocket server and owns no sessions. diff --git a/apps/studio/src-tauri/build.rs b/apps/studio/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/apps/studio/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/studio/src-tauri/capabilities/default.json b/apps/studio/src-tauri/capabilities/default.json new file mode 100644 index 00000000..a6f41c10 --- /dev/null +++ b/apps/studio/src-tauri/capabilities/default.json @@ -0,0 +1,18 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Feather Studio's main window. Deliberately narrower than Inspector's: Studio edits shaders, textures and particles, so it needs file dialogs and nothing that reaches a running game.", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + { + "identifier": "opener:allow-open-url", + "allow": [{ "url": "https://kyonru.github.io/feather/*" }] + }, + "dialog:default", + "fs:default", + "fs:allow-read-file", + "fs:allow-write-file" + ] +} diff --git a/apps/studio/src-tauri/icons/128x128.png b/apps/studio/src-tauri/icons/128x128.png new file mode 100644 index 00000000..5b6a1591 Binary files /dev/null and b/apps/studio/src-tauri/icons/128x128.png differ diff --git a/apps/studio/src-tauri/icons/128x128@2x.png b/apps/studio/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..8fb65d9c Binary files /dev/null and b/apps/studio/src-tauri/icons/128x128@2x.png differ diff --git a/apps/studio/src-tauri/icons/32x32.png b/apps/studio/src-tauri/icons/32x32.png new file mode 100644 index 00000000..aa75dcb9 Binary files /dev/null and b/apps/studio/src-tauri/icons/32x32.png differ diff --git a/apps/studio/src-tauri/icons/64x64.png b/apps/studio/src-tauri/icons/64x64.png new file mode 100644 index 00000000..54001bfa Binary files /dev/null and b/apps/studio/src-tauri/icons/64x64.png differ diff --git a/apps/studio/src-tauri/icons/Square107x107Logo.png b/apps/studio/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 00000000..ca0653b7 Binary files /dev/null and b/apps/studio/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square142x142Logo.png b/apps/studio/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 00000000..390fb6e8 Binary files /dev/null and b/apps/studio/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square150x150Logo.png b/apps/studio/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 00000000..cb33f084 Binary files /dev/null and b/apps/studio/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square284x284Logo.png b/apps/studio/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 00000000..83ad94a2 Binary files /dev/null and b/apps/studio/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square30x30Logo.png b/apps/studio/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 00000000..47740773 Binary files /dev/null and b/apps/studio/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square310x310Logo.png b/apps/studio/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 00000000..24833d8c Binary files /dev/null and b/apps/studio/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square44x44Logo.png b/apps/studio/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 00000000..c1853549 Binary files /dev/null and b/apps/studio/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square71x71Logo.png b/apps/studio/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 00000000..b32cf576 Binary files /dev/null and b/apps/studio/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/studio/src-tauri/icons/Square89x89Logo.png b/apps/studio/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 00000000..70ab6d3f Binary files /dev/null and b/apps/studio/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/studio/src-tauri/icons/StoreLogo.png b/apps/studio/src-tauri/icons/StoreLogo.png new file mode 100644 index 00000000..9feaa179 Binary files /dev/null and b/apps/studio/src-tauri/icons/StoreLogo.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..8ccc5965 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..7be0c7ca Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..8ccc5965 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..584f9381 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..37aaf056 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..584f9381 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..de06f1ed Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ae6e1591 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..de06f1ed Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..be8d16a4 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..26e37c12 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..be8d16a4 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..f3bb07a9 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ba83181e Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..f3bb07a9 Binary files /dev/null and b/apps/studio/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/studio/src-tauri/icons/base.png b/apps/studio/src-tauri/icons/base.png new file mode 100644 index 00000000..66b3c9f3 Binary files /dev/null and b/apps/studio/src-tauri/icons/base.png differ diff --git a/apps/studio/src-tauri/icons/icon.icns b/apps/studio/src-tauri/icons/icon.icns new file mode 100644 index 00000000..df50d3e5 Binary files /dev/null and b/apps/studio/src-tauri/icons/icon.icns differ diff --git a/apps/studio/src-tauri/icons/icon.ico b/apps/studio/src-tauri/icons/icon.ico new file mode 100644 index 00000000..df7b1cec Binary files /dev/null and b/apps/studio/src-tauri/icons/icon.ico differ diff --git a/apps/studio/src-tauri/icons/icon.png b/apps/studio/src-tauri/icons/icon.png new file mode 100644 index 00000000..9118f701 Binary files /dev/null and b/apps/studio/src-tauri/icons/icon.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 00000000..056664c2 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 00000000..a3a49e45 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 00000000..a3a49e45 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 00000000..b9e976b6 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 00000000..26ed4a6b Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 00000000..0ebcfb92 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 00000000..0ebcfb92 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 00000000..0f06dbe2 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 00000000..a3a49e45 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 00000000..63deef16 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 00000000..63deef16 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 00000000..deb5bbcb Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/studio/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 00000000..51c54669 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/studio/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 00000000..deb5bbcb Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/studio/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 00000000..bf236b11 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/studio/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 00000000..86c6a41a Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/studio/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 00000000..dd0e1bf8 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/apps/studio/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/studio/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 00000000..de083078 Binary files /dev/null and b/apps/studio/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/studio/src-tauri/src/creative_mcp.rs b/apps/studio/src-tauri/src/creative_mcp.rs new file mode 100644 index 00000000..a66d9ec1 --- /dev/null +++ b/apps/studio/src-tauri/src/creative_mcp.rs @@ -0,0 +1,335 @@ +//! Feather Studio's creative MCP endpoint. +//! +//! There is one Feather MCP server, exposed by the CLI as `feather mcp`. Agents +//! configure one thing. But the state behind it now lives in two processes: +//! Inspector owns sessions and debugging, Studio owns shaders, textures and +//! particles. So the single server routes — session traffic to Inspector, and +//! `/creative/*` here. +//! +//! This does **not** make Studio a second way to reach a running game. It serves +//! Studio's own authoring state and nothing else; pushing work into a game still +//! goes through the authenticated Inspector bridge in `session-bridge`. That +//! distinction is why Studio may have an HTTP listener at all while still having +//! no WebSocket server and no CLI sidecar. +//! +//! Creative state lives in the webview, so this relays: a request arrives, is +//! emitted to the frontend, and the frontend answers through a Tauri command +//! that resolves the waiting channel. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::oneshot; + +/// How long a creative action may take before the caller gets a timeout rather +/// than hanging. Generation can be slow, so this is generous. +const ACTION_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug, Serialize)] +pub struct CreativeActionResponse { + pub ok: bool, + pub response: Option, + pub error: Option, +} + +#[derive(Serialize, Clone)] +struct CreativeRequestEvent { + id: String, + tool: String, + action: String, + params: Value, +} + +#[derive(Deserialize)] +struct CreativeActionBody { + action: String, + #[serde(default)] + params: Value, +} + +struct Inner { + app_handle: Mutex>, + token: Mutex, + snapshots: Mutex>, + waiters: Mutex>>, +} + +#[derive(Clone)] +pub struct CreativeMcpState { + inner: Arc, +} + +impl CreativeMcpState { + pub fn new() -> Self { + Self { + inner: Arc::new(Inner { + app_handle: Mutex::new(None), + token: Mutex::new(format!("studio-mcp-{}", uuid::Uuid::new_v4())), + snapshots: Mutex::new(HashMap::new()), + waiters: Mutex::new(HashMap::new()), + }), + } + } + + pub fn set_app_handle(&self, handle: AppHandle) { + *self.inner.app_handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle); + } + + pub fn token(&self) -> String { + self.inner.token.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + + fn set_snapshot(&self, tool: &str, snapshot: Value) { + self.inner + .snapshots + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(tool.to_string(), snapshot); + } + + fn snapshot(&self, tool: &str) -> Option { + self.inner + .snapshots + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(tool) + .cloned() + } + + fn resolve(&self, id: &str, response: CreativeActionResponse) { + if let Some(sender) = self + .inner + .waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(id) + { + let _ = sender.send(response); + } + } +} + +impl Default for CreativeMcpState { + fn default() -> Self { + Self::new() + } +} + +/// Bearer token, compared in full. The endpoint is loopback-only, but a token +/// still matters: anything running as the user on this machine could otherwise +/// read and mutate their work. +fn authorize(headers: &HeaderMap, state: &CreativeMcpState) -> Result<(), Response> { + let expected = state.token(); + let presented = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or_default(); + + if presented == expected && !expected.is_empty() { + return Ok(()); + } + Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid or missing Studio MCP token" })), + ) + .into_response()) +} + +async fn health(State(state): State) -> Response { + let tools: Vec = state + .inner + .snapshots + .lock() + .unwrap_or_else(|e| e.into_inner()) + .keys() + .cloned() + .collect(); + Json(json!({ "app": "studio", "ok": true, "tools": tools })).into_response() +} + +async fn get_snapshot( + headers: HeaderMap, + State(state): State, + Path(tool): Path, +) -> Response { + if let Err(response) = authorize(&headers, &state) { + return response; + } + match state.snapshot(&tool) { + Some(snapshot) => Json(snapshot).into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": format!("Feather Studio has no snapshot for \"{tool}\" yet") })), + ) + .into_response(), + } +} + +async fn send_action( + headers: HeaderMap, + State(state): State, + Path(tool): Path, + Json(body): Json, +) -> Response { + if let Err(response) = authorize(&headers, &state) { + return response; + } + + let handle = state + .inner + .app_handle + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let Some(handle) = handle else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Feather Studio is still starting" })), + ) + .into_response(); + }; + + let id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + state + .inner + .waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id.clone(), tx); + + let event = CreativeRequestEvent { + id: id.clone(), + tool, + action: body.action, + params: body.params, + }; + if handle.emit("feather://mcp-creative-request", event).is_err() { + state + .inner + .waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "could not reach the Studio window" })), + ) + .into_response(); + } + + match tokio::time::timeout(ACTION_TIMEOUT, rx).await { + Ok(Ok(result)) if result.ok => Json(json!({ "ok": true, "response": result.response })).into_response(), + Ok(Ok(result)) => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": result.error.unwrap_or_else(|| "the action failed".into()) })), + ) + .into_response(), + Ok(Err(_)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "the Studio window closed before answering" })), + ) + .into_response(), + Err(_) => { + state + .inner + .waiters + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({ "error": "Feather Studio did not answer in time" })), + ) + .into_response() + } + } +} + +pub fn router(state: CreativeMcpState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/creative/{tool}", get(get_snapshot)) + .route("/creative/{tool}/action", post(send_action)) + .with_state(state) +} + +/// Loopback only. See the module comment for why this listener does not make +/// Studio a second path to a running game. +pub fn start(state: CreativeMcpState, port: u16) { + tauri::async_runtime::spawn(async move { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => { + if let Err(error) = axum::serve(listener, router(state)).await { + eprintln!("[feather-studio] creative MCP endpoint stopped: {error}"); + } + } + Err(error) => { + // Not fatal. Studio is fully usable without MCP. + eprintln!("[feather-studio] creative MCP could not bind 127.0.0.1:{port}: {error}"); + } + } + }); +} + +#[tauri::command] +pub fn set_mcp_creative_snapshot(tool: String, snapshot: Value, state: tauri::State) { + state.set_snapshot(&tool, snapshot); +} + +#[tauri::command] +pub fn resolve_mcp_creative_request( + id: String, + ok: bool, + response: Option, + error: Option, + state: tauri::State, +) { + state.resolve(&id, CreativeActionResponse { ok, response, error }); +} + +#[tauri::command] +pub fn get_studio_mcp_token(state: tauri::State) -> String { + state.token() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshots_round_trip() { + let state = CreativeMcpState::new(); + assert!(state.snapshot("shader-graph").is_none()); + state.set_snapshot("shader-graph", json!({ "nodes": 3 })); + assert_eq!(state.snapshot("shader-graph"), Some(json!({ "nodes": 3 }))); + } + + #[test] + fn each_launch_gets_its_own_token() { + assert_ne!(CreativeMcpState::new().token(), CreativeMcpState::new().token()); + } + + #[test] + fn resolving_an_unknown_request_is_harmless() { + // A late answer after a timeout must not panic. + CreativeMcpState::new().resolve( + "gone", + CreativeActionResponse { ok: true, response: None, error: None }, + ); + } +} diff --git a/apps/studio/src-tauri/src/lib.rs b/apps/studio/src-tauri/src/lib.rs new file mode 100644 index 00000000..1f432b40 --- /dev/null +++ b/apps/studio/src-tauri/src/lib.rs @@ -0,0 +1,41 @@ +//! Feather Studio's native shell. +//! +//! Intentionally thin. Studio is a local editor: its Rust side exists to host a +//! webview and provide file dialogs. It runs no WebSocket server and spawns no +//! CLI, because Inspector owns live game sessions. Reaching a game happens over +//! the authenticated bridge described in `@feather/session-bridge`, from the +//! frontend, not from here. + +mod creative_mcp; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let creative = creative_mcp::CreativeMcpState::new(); + + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .manage(creative.clone()) + .setup(move |app| { + creative.set_app_handle(app.handle().clone()); + + // There is one Feather MCP server, in the CLI. It routes session + // traffic to Inspector and creative traffic here, so an agent + // configures one thing while the state stays with whichever + // application owns it. + let port: u16 = std::env::var("FEATHER_STUDIO_MCP_PORT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(4007); + creative_mcp::start(creative.clone(), port); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + creative_mcp::set_mcp_creative_snapshot, + creative_mcp::resolve_mcp_creative_request, + creative_mcp::get_studio_mcp_token, + ]) + .run(tauri::generate_context!()) + .expect("error while running Feather Studio"); +} diff --git a/apps/studio/src-tauri/src/main.rs b/apps/studio/src-tauri/src/main.rs new file mode 100644 index 00000000..2b845f2b --- /dev/null +++ b/apps/studio/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents an additional console window on Windows in release. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + feather_studio_lib::run() +} diff --git a/apps/studio/src-tauri/tauri.conf.json b/apps/studio/src-tauri/tauri.conf.json new file mode 100644 index 00000000..73752c1e --- /dev/null +++ b/apps/studio/src-tauri/tauri.conf.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Feather Studio", + "version": "0.1.0", + "identifier": "com.kyonru.love.feather.studio", + "build": { + "beforeDevCommand": "pnpm --filter @feather/studio run dev", + "devUrl": "http://localhost:1430", + "beforeBuildCommand": "pnpm --filter @feather/studio run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Feather Studio", + "minWidth": 900, + "minHeight": 640, + "width": 1280, + "height": 820, + "resizable": true, + "dragDropEnabled": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "macOS": {} + } +} diff --git a/apps/studio/src/StudioApp.tsx b/apps/studio/src/StudioApp.tsx new file mode 100644 index 00000000..77a935ad --- /dev/null +++ b/apps/studio/src/StudioApp.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { BlendIcon, MenuIcon, PlugZapIcon, SparklesIcon, WandSparklesIcon } from 'lucide-react'; +import type { ElementType } from 'react'; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@feather/ui/sheet'; +import { cn } from '@feather/ui'; + +import ParticleSystemPlayground from './tools/particle-system-playground'; +import ShaderGraph from './tools/shader-graph'; +import TextureLab from './tools/texture-lab'; +import { useInspectorBridge } from './useInspectorBridge'; +import { STUDIO_TOOLS, useStudioUiStore, type StudioToolId } from './store/studio-ui'; + +/** + * Feather Studio's shell. + * + * A frame, not a page. The tool being used owns everything below a thin strip, + * and the shell contributes no color of its own, because the artifact someone is + * authoring is the only thing here that should be colorful. See V4-STUDIO.md §5. + * + * **Regions are separated by background value, not borders.** A border is a line + * you have to look at; a half-step of value does the same job and stops + * registering once you no longer need it. This is the VS Code approach and it + * suits a workbench better than drawn edges — the eye should spend its contrast + * budget on the work. + * + * **Navigation overlays rather than occupying.** Three tools is not enough to + * justify a permanent rail eating width that a node graph or a texture preview + * would rather have. The strip keeps you oriented — which tool, is a game + * attached — and the menu appears over the work only when asked for. + */ + +const TOOLS: Record React.ReactNode }> = { + 'shader-graph': { label: 'Shader Graph', icon: BlendIcon, render: () => }, + 'particle-playground': { label: 'Particles', icon: SparklesIcon, render: () => }, + 'texture-lab': { label: 'Texture Lab', icon: WandSparklesIcon, render: () => }, +}; + +/** + * The Inspector connection. + * + * Not connected is a neutral fact, never an error: Studio is a local authoring + * tool and a game is an addition to it (V4-STUDIO.md **P2**). The dot is + * achromatic for the same reason the rest of the shell is — connection is + * chrome, not state, and color near a preview competes with the artifact. + */ +function InspectorStatus({ compact = false }: { compact?: boolean }) { + const inspector = useInspectorBridge(); + + const detail = inspector.connected + ? inspector.session.connected + ? (inspector.session.name ?? 'Game attached') + : 'No game attached' + : 'Local only'; + + if (compact) { + return ( +
+ + {detail} +
+ ); + } + + return ( +
+

Game

+

{detail}

+ + {!inspector.connected && ( + <> + +

+ Optional. Studio works as a local editor without it; connecting lets you push work straight into a running + game. +

+ + )} + + {inspector.error && ( +

+ {inspector.error} +

+ )} +
+ ); +} + +function ToolMenu({ activeTool, onSelect }: { activeTool: StudioToolId; onSelect: (tool: StudioToolId) => void }) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + Feather Studio + + + + +
+ +
+
+
+ ); +} + +export function StudioApp() { + const activeTool = useStudioUiStore((state) => state.activeTool); + const setActiveTool = useStudioUiStore((state) => state.setActiveTool); + + return ( +
+ {/* One value step above the work, and no rule between them. Thin, because + the canvas wants the height as much as the width. */} +
+ + + {TOOLS[activeTool].label} + +
+ +
+
+ +
+ {TOOLS[activeTool].render()} +
+
+ ); +} diff --git a/src/components/love-js-preview.tsx b/apps/studio/src/components/love-js-preview.tsx similarity index 85% rename from src/components/love-js-preview.tsx rename to apps/studio/src/components/love-js-preview.tsx index 5ea1e9e8..a517182a 100644 --- a/src/components/love-js-preview.tsx +++ b/apps/studio/src/components/love-js-preview.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { ChevronDownIcon, ChevronUpIcon, RefreshCwIcon } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { stripLovePreviewUploads } from '@/utils/love-preview-upload-bridge'; -import { cn } from '@/utils/styles'; +import { Badge } from '@feather/ui/badge'; +import { Button } from '@feather/ui/button'; +import { stripLovePreviewUploads } from '@studio/utils/love-preview-upload-bridge'; +import { cn } from '@feather/ui'; const DEFAULT_W = 360; const DEFAULT_ASPECT_RATIO = 16 / 9; @@ -42,6 +42,7 @@ export function LoveJsPreview({ const dragRef = useRef<{ startMouseX: number; startMouseY: number; startTx: number; startTy: number } | null>(null); const previewAspectRatio = aspectRatio > 0 ? aspectRatio : DEFAULT_ASPECT_RATIO; + const floatingRef = useRef(null); const [previewWidth, setPreviewWidth] = useState(DEFAULT_W); const previewHeight = Math.round(previewWidth / previewAspectRatio); const resizeRef = useRef<{ startX: number; startW: number } | null>(null); @@ -126,9 +127,33 @@ export function LoveJsPreview({ document.addEventListener('mouseup', onUp); } + // The floating preview is `fixed`, so it sits over whatever occupies the + // bottom-right — which in Shader Graph is the panel holding the diagnostics + // list. A "fix this" button under the preview cannot be clicked at all, so + // the preview publishes its own footprint and the panel behind it reserves + // that much room. Measured rather than assumed, because the preview is + // resizable and a hardcoded gutter goes stale the moment someone drags it. + useEffect(() => { + const node = floatingRef.current; + if (!floating || !node) return; + + const root = document.documentElement; + const observer = new ResizeObserver(() => { + // + the `bottom-4` offset the shell already holds off the edge. + root.style.setProperty('--floating-preview-gutter', `${Math.round(node.getBoundingClientRect().height) + 16}px`); + }); + observer.observe(node); + + return () => { + observer.disconnect(); + root.style.removeProperty('--floating-preview-gutter'); + }; + }, [floating]); + if (floating) { return (
{ + invoke('set_mcp_creative_snapshot', { tool, snapshot }).catch(() => {}); + }, + + onRequest: async (handler) => { + const unlisten = await listen('feather://mcp-creative-request', (event) => { + handler(event.payload); + }); + return unlisten; + }, + + resolve: async (payload) => { + await invoke('resolve_mcp_creative_request', payload); + }, +}; diff --git a/src/hooks/use-mcp-creative-bridge.ts b/apps/studio/src/hooks/use-mcp-creative-bridge.ts similarity index 90% rename from src/hooks/use-mcp-creative-bridge.ts rename to apps/studio/src/hooks/use-mcp-creative-bridge.ts index 133155f2..5aab09c2 100644 --- a/src/hooks/use-mcp-creative-bridge.ts +++ b/apps/studio/src/hooks/use-mcp-creative-bridge.ts @@ -1,13 +1,12 @@ import { useEffect } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; +import { creativeMcpTransport } from '@studio/mcp/transport'; import { useQueryClient } from '@tanstack/react-query'; -import { useShaderGraphStore } from '@/store/shader-graph'; -import { useSettingsStore } from '@/store/settings'; -import { useSessionStore } from '@/store/session'; -import { sessionQueryKey } from './use-ws-connection'; -import { codegen } from '@/pages/shader-graph/codegen'; -import { diagnoseShaderGraph, hasBlockingDiagnostics } from '@/pages/shader-graph/diagnostics'; +import { useShaderGraphStore } from '@studio/store/shader-graph'; +import { useStudioPreferencesStore } from '@studio/store/studio-preferences'; +import { useSessionStore } from '@studio/session'; +import { sessionQueryKey } from '@studio/lib/session-query-keys'; +import { codegen } from '@studio/tools/shader-graph/codegen'; +import { diagnoseShaderGraph, hasBlockingDiagnostics } from '@studio/tools/shader-graph/diagnostics'; import { DEFAULT_TEXTURE_LAB_RECIPE, DEFAULT_TEXTURE_LAB_ATLAS_SETTINGS, @@ -15,7 +14,7 @@ import { generateTextureLabTextureAsync, normalizeTextureLabRecipe, TEXTURE_LAB_GENERATORS, -} from '@/pages/texture-lab/generator'; +} from '@studio/tools/texture-lab/generator'; import type { GeneratedGlsl, PlaygroundTarget, @@ -23,8 +22,8 @@ import type { ShaderNodeInstance, ShaderPreviewShape, ShaderSubgraph, -} from '@/types/shader-graph'; -import type { GeneratedTextureResult, TextureLabAtlasBundle } from '@/types/texture-lab'; +} from '@studio/types/shader-graph'; +import type { GeneratedTextureResult, TextureLabAtlasBundle } from '@studio/types/texture-lab'; const SHADER_GRAPH_TOOL = 'shader-graph'; const PARTICLE_TOOL = 'particle-system-playground'; @@ -104,7 +103,7 @@ function shaderGraphSnapshot() { } function textureLabSnapshot() { - const state = useSettingsStore.getState(); + const state = useStudioPreferencesStore.getState(); return { type: TEXTURE_LAB_TOOL, updatedAt: Date.now(), @@ -308,7 +307,6 @@ function shaderPreviewParams(params: Record) { } function texturePayload(texture: GeneratedTextureResult): TexturePayload { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { dataUrl: _dataUrl, ...rest } = texture; return rest; } @@ -322,7 +320,7 @@ function atlasPayload(bundle: TextureLabAtlasBundle) { } async function handleTextureLabAction(action: string, params: Record) { - const state = useSettingsStore.getState(); + const state = useStudioPreferencesStore.getState(); if (action === 'generators') return { generators: TEXTURE_LAB_GENERATORS }; if (action === 'snapshot') return textureLabSnapshot(); if (action === 'set-recipe') { @@ -382,7 +380,7 @@ async function handleCreativeRequest(request: McpCreativeRequest, queryClient: R } function publishCreativeSnapshot(tool: string, snapshot: unknown) { - invoke('set_mcp_creative_snapshot', { tool, snapshot }).catch(() => {}); + creativeMcpTransport().publishSnapshot(tool, snapshot); } export function useMcpCreativeBridge() { @@ -397,7 +395,7 @@ export function useMcpCreativeBridge() { useEffect(() => { publishCreativeSnapshot(TEXTURE_LAB_TOOL, textureLabSnapshot()); - return useSettingsStore.subscribe(() => { + return useStudioPreferencesStore.subscribe(() => { publishCreativeSnapshot(TEXTURE_LAB_TOOL, textureLabSnapshot()); }); }, []); @@ -418,26 +416,24 @@ export function useMcpCreativeBridge() { useEffect(() => { let cancelled = false; let unlisten: (() => void) | null = null; - listen('feather://mcp-creative-request', async (event) => { - const request = event.payload; - if (!request?.id) return; - try { - const response = await handleCreativeRequest(request, queryClient); - await invoke('resolve_mcp_creative_request', { - id: request.id, - ok: true, - response, - error: null, - }); - } catch (error) { - await invoke('resolve_mcp_creative_request', { - id: request.id, - ok: false, - response: null, - error: error instanceof Error ? error.message : String(error), - }); - } - }) + const transport = creativeMcpTransport(); + transport + .onRequest((request) => { + if (!request?.id) return; + void (async () => { + try { + const response = await handleCreativeRequest(request as McpCreativeRequest, queryClient); + await transport.resolve({ id: request.id, ok: true, response, error: null }); + } catch (error) { + await transport.resolve({ + id: request.id, + ok: false, + response: null, + error: error instanceof Error ? error.message : String(error), + }); + } + })(); + }) .then((next) => { if (cancelled) next(); else unlisten = next; diff --git a/src/hooks/use-particle-system-playground.ts b/apps/studio/src/hooks/use-particle-system-playground.ts similarity index 98% rename from src/hooks/use-particle-system-playground.ts rename to apps/studio/src/hooks/use-particle-system-playground.ts index edb3d725..29112d1b 100644 --- a/src/hooks/use-particle-system-playground.ts +++ b/apps/studio/src/hooks/use-particle-system-playground.ts @@ -1,12 +1,11 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { sendCommand } from '@/lib/send-command'; -import { useConfigStore } from '@/store/config'; -import { useSessionStore } from '@/store/session'; -import { debounce } from '@/utils/timers'; -import { sessionQueryKey } from './use-ws-connection'; +import { sendCommand } from '@feather/session-bridge'; +import { useConfigStore } from '@studio/session/game-config'; +import { useSessionStore } from '@studio/session'; +import { debounce } from '@studio/utils/timers'; +import { sessionQueryKey } from '@studio/lib/session-query-keys'; import type { ParticleTimeline, ParticleTimelineState, @@ -14,7 +13,7 @@ import type { ParticleSystemPlaygroundProjectFile, ParticleSystemPlaygroundSystem, ParticleSystemPlaygroundTemplate, -} from '@/types/particle-system-playground'; +} from '@studio/types/particle-system-playground'; import { advanceParticleTimelineState, migrateParticleProject, @@ -25,7 +24,7 @@ import { removeParticleTimelineTrack, reorderParticleTimeline, withNormalizedTimeline, -} from '@/pages/particle-system-playground/timeline'; +} from '@studio/tools/particle-system-playground/timeline'; import { createParticleHistoryState, recordParticleHistory, @@ -35,8 +34,8 @@ import { undoParticleHistory, type ParticleAuthoringSnapshot, type ParticleHistoryState, -} from '@/pages/particle-system-playground/history'; -import type { TextureLabAtlasMetadata } from '@/types/texture-lab'; +} from '@studio/tools/particle-system-playground/history'; +import type { TextureLabAtlasMetadata } from '@studio/types/texture-lab'; const PLUGIN_ID = 'particle-system-playground'; diff --git a/src/hooks/use-shader-graph.ts b/apps/studio/src/hooks/use-shader-graph.ts similarity index 91% rename from src/hooks/use-shader-graph.ts rename to apps/studio/src/hooks/use-shader-graph.ts index bb7397a2..1edd2e88 100644 --- a/src/hooks/use-shader-graph.ts +++ b/apps/studio/src/hooks/use-shader-graph.ts @@ -1,13 +1,13 @@ import { useCallback, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { sendCommand } from '@/lib/send-command'; -import { sessionSupportsRuntime, useSessionStore } from '@/store/session'; -import { useShaderGraphStore } from '@/store/shader-graph'; -import { sessionQueryKey } from './use-ws-connection'; -import { codegen } from '@/pages/shader-graph/codegen'; -import { shaderGraphGamePreviewController } from '@/pages/shader-graph/gamePreviewController'; -import { diagnoseShaderGraph, hasBlockingDiagnostics } from '@/pages/shader-graph/diagnostics'; -import type { GeneratedGlsl, ShaderParameter, ShaderTextureUpload } from '@/types/shader-graph'; +import { sendCommand } from '@feather/session-bridge'; +import { sessionSupportsRuntime, useSessionStore } from '@studio/session'; +import { useShaderGraphStore } from '@studio/store/shader-graph'; +import { sessionQueryKey } from '@studio/lib/session-query-keys'; +import { codegen } from '@studio/tools/shader-graph/codegen'; +import { shaderGraphGamePreviewController } from '@studio/tools/shader-graph/gamePreviewController'; +import { diagnoseShaderGraph, hasBlockingDiagnostics } from '@studio/tools/shader-graph/diagnostics'; +import type { GeneratedGlsl, ShaderParameter, ShaderTextureUpload } from '@studio/types/shader-graph'; const PLUGIN_ID = 'particle-system-playground'; const SHADER_GRAPH_PLUGIN = 'shader-graph'; diff --git a/apps/studio/src/lib/session-query-keys.ts b/apps/studio/src/lib/session-query-keys.ts new file mode 100644 index 00000000..b6bb78ae --- /dev/null +++ b/apps/studio/src/lib/session-query-keys.ts @@ -0,0 +1,29 @@ +/** + * React Query keys for per-session data. + * + * Extracted from `use-ws-connection` so modules that only need the key shapes + * do not transitively import the WebSocket hook — and with it `@tauri-apps/*`. + * That chain was what kept the creative tools bound to Tauri. + */ +export const sessionQueryKey = { + config: (sessionId: string) => [sessionId, 'config'], + logs: (sessionId: string) => [sessionId, 'logs'], + performance: (sessionId: string) => [sessionId, 'performance'], + profiler: (sessionId: string) => [sessionId, 'profiler'], + observers: (sessionId: string) => [sessionId, 'observers'], + assets: (sessionId: string) => [sessionId, 'assets'], + plugin: (sessionId: string, pluginId: string) => [sessionId, 'plugin', pluginId], + pluginAction: (sessionId: string, pluginId: string, action: string) => [sessionId, 'plugin-action', pluginId, action], + console: (sessionId: string) => [sessionId, 'console'], + consoleGlobals: (sessionId: string) => [sessionId, 'console-globals'], + consolePins: (sessionId: string) => [sessionId, 'console-pins'], + consoleInspect: (sessionId: string) => [sessionId, 'console-inspect'], + timeTravel: (sessionId: string) => [sessionId, 'time-travel'], + timeTravelFrames: (sessionId: string) => [sessionId, 'time-travel-frames'], + sessionReplay: (sessionId: string) => [sessionId, 'session-replay'], + sessionReplayRecording: (sessionId: string) => [sessionId, 'session-replay-recording'], + sessionReplayRecordings: (sessionId: string) => [sessionId, 'session-replay-recordings'], + sessionReplayList: (sessionId: string) => [sessionId, 'session-replay-list'], + sessionReplaySelected: (sessionId: string) => [sessionId, 'session-replay-selected'], + hotReload: (sessionId: string) => [sessionId, 'hot-reload'], +}; diff --git a/apps/studio/src/main.tsx b/apps/studio/src/main.tsx new file mode 100644 index 00000000..33aa0982 --- /dev/null +++ b/apps/studio/src/main.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createWebHost, HostProvider, type FeatherHost } from '@feather/host'; +import { UiProvider, type UiDependencies } from '@feather/ui'; +import { ThemeProvider, useResolvedTheme } from './theme'; +import { StudioApp } from './StudioApp'; +import './studio.css'; + +/** + * Feather Studio's entry point. + * + * Studio runs in two places: inside its own Tauri shell, and as a plain web + * page. The host is chosen from what is actually present rather than from which + * build this is, the same way the Inspector entry does it. + * + * `createTauriHost` is imported lazily so a web build never pulls `@tauri-apps/*` + * into its bundle. + */ +async function resolveHost(): Promise { + const hasTauri = '__TAURI_INTERNALS__' in globalThis; + if (!hasTauri) return createWebHost(); + + // Desktop-only wiring, imported lazily for the same reason as the host: a web + // build must not pull `@tauri-apps/*` into its bundle. + const [{ createTauriHost }, { tauriCreativeMcpTransport }, { setCreativeMcpTransport }] = await Promise.all([ + import('@feather/host/tauri'), + import('./desktop/mcp-transport.ts'), + import('./mcp/transport.ts'), + ]); + setCreativeMcpTransport(tauriCreativeMcpTransport); + return createTauriHost(); +} + +const host = await resolveHost(); + +/** + * Studio's provider stack. + * + * This mounted `HostProvider` alone until now, which is why none of the tools + * could be rendered: they reference design tokens 394 times and every one + * resolved to nothing without `ThemeProvider`, their hooks need a query client, + * and `@feather/ui` components need the UI dependencies. The showcase has run + * the same tools successfully with these four all along — see + * `showcase/providers.tsx`, which this mirrors. + * + * The query client keeps a long `gcTime` because creative state is expensive to + * rebuild and cheap to hold. + */ +const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: 1000 * 60 * 60 } }, +}); + +// Studio follows the system theme. Whether it should instead open dark by +// default — the argument being that you cannot judge a color against a surround +// that has its own — is deferred; see V4-STUDIO.md S1. +const uiDependencies: UiDependencies = { + useThemeMode: () => useResolvedTheme().mode, + useSyntaxTheme: () => useResolvedTheme().syntax as Record, + copyToClipboard: (value: string) => { + void navigator.clipboard?.writeText(value); + }, +}; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + , +); diff --git a/apps/studio/src/mcp/transport.ts b/apps/studio/src/mcp/transport.ts new file mode 100644 index 00000000..896c58e4 --- /dev/null +++ b/apps/studio/src/mcp/transport.ts @@ -0,0 +1,50 @@ +/** + * How Studio's creative MCP server reaches the outside world. + * + * Abstracted for the same reason the command sender is: the creative tools run + * both in Studio's Tauri shell and as a plain web page, and only the former has + * a native MCP host. Keeping the transport behind an interface is what lets the + * web build stay Tauri-free while the desktop build wires the real thing. + * + * With nothing registered every call is a no-op, which is the correct browser + * behaviour — there is no MCP client to talk to. + */ + +export type CreativeMcpRequest = { + id: string; + tool: string; + action: string; + params?: Record; +}; + +export type CreativeMcpResolution = { + id: string; + ok: boolean; + response: unknown; + error: string | null; +}; + +export interface CreativeMcpTransport { + /** Publish the current state of a tool for MCP resources to read. */ + publishSnapshot: (tool: string, snapshot: unknown) => void; + /** Subscribe to incoming tool invocations. Returns an unsubscribe function. */ + onRequest: (handler: (request: CreativeMcpRequest) => void) => Promise<() => void>; + /** Answer one invocation. */ + resolve: (payload: CreativeMcpResolution) => Promise; +} + +const noTransport: CreativeMcpTransport = { + publishSnapshot: () => {}, + onRequest: async () => () => {}, + resolve: async () => {}, +}; + +let current: CreativeMcpTransport = noTransport; + +export function setCreativeMcpTransport(transport: CreativeMcpTransport): void { + current = transport; +} + +export function creativeMcpTransport(): CreativeMcpTransport { + return current; +} diff --git a/apps/studio/src/session/game-config.ts b/apps/studio/src/session/game-config.ts new file mode 100644 index 00000000..546b10db --- /dev/null +++ b/apps/studio/src/session/game-config.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand'; + +export type GamePluginInfo = { + disabled?: boolean; + incompatible?: boolean; + tabName?: string; + icon?: string; + [key: string]: unknown; +}; + +/** + * The slice of the attached game's config Studio actually uses: the source + * directory for resolving preview asset paths, and per-plugin availability for + * the three tools' own plugins. + * + * Inspector holds the full config. Mirroring one slice here keeps Studio from + * importing a store it has no business reading, and keeps it working when there + * is no game at all. + */ +type GameConfigStore = { + config: { sourceDir?: string; plugins?: Record } | null; + setConfig: (config: { sourceDir?: string; plugins?: Record } | null) => void; +}; + +export const useConfigStore = create()((set) => ({ + config: null, + setConfig: (config) => set({ config }), +})); diff --git a/apps/studio/src/session/index.ts b/apps/studio/src/session/index.ts new file mode 100644 index 00000000..244f6b3a --- /dev/null +++ b/apps/studio/src/session/index.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand'; +import type { SessionDescription } from '@feather/session-bridge'; + +/** + * Studio's view of a game session. + * + * Studio does not own sessions — Feather Inspector does. What Studio needs is + * narrower: *is a game reachable right now, and may I push to it?* That answer + * arrives over the authenticated bridge and is mirrored here. + * + * The shape deliberately matches the Inspector session store's, because the + * creative tools were written against it. Keeping the shape means the tools + * moved across the application boundary without being rewritten — but they are + * now reading a mirror fed by the bridge, not Inspector's own state, and there + * is no import path from here back into Inspector. + * + * With no Inspector attached this store is simply empty, which is the same thing + * the tools already understood as "no session". + */ + +export type SessionKind = 'live'; + +export type SessionInfo = { + id: string; + name?: string; + kind?: SessionKind; + connected: boolean; + connectedAt: number; + runtimeSuspended?: boolean; +}; + +/** + * Is a game attached and accepting commands? + * + * The honest question, and the only one Studio needs to ask about sessions. + * `false` is the ordinary answer: most of the time nobody has a game running, + * and Studio is fully useful anyway. + */ +export function sessionSupportsRuntime( + session?: SessionInfo | null, +): session is SessionInfo & { connected: true; kind?: undefined } { + return !!session && !session.kind && session.connected; +} + +/** + * Studio's two modes. + * + * `local` — authoring against Studio's own state. **This is the default**, and + * it is fully capable: every tool works, nothing is disabled, no empty state + * asks for a session. + * + * `attached` — a game is reachable through Inspector, which *adds* live preview + * and pushing work into the running game. Nothing else changes. + * + * This replaced a predicate called `isCreativeSession`, inherited from Inspector + * where a gameless workspace was the exception you deliberately created. In + * Studio the same words name the default, which is why the code read backwards: + * `creativeSession ? localPlayground : livePlayground` selected the *live* + * controller whenever no game was attached, because Studio never creates a + * creative session. Asking the question the right way round removes the bug and + * the vocabulary together. + */ +export type StudioMode = 'local' | 'attached'; + +export function studioModeFor(session?: SessionInfo | null): StudioMode { + return sessionSupportsRuntime(session) ? 'attached' : 'local'; +} + +type StudioSessionStore = { + sessionId: string | null; + sessions: Record; + /** Replace the mirror with what the bridge just reported. */ + syncFromBridge: (description: SessionDescription) => void; + /** Drop the mirror. Called when the bridge disconnects. */ + clear: () => void; +}; + +export const useSessionStore = create()((set) => ({ + sessionId: null, + sessions: {}, + + syncFromBridge: (description) => + set(() => { + if (!description.sessionId) return { sessionId: null, sessions: {} }; + return { + sessionId: description.sessionId, + sessions: { + [description.sessionId]: { + id: description.sessionId, + name: description.name ?? undefined, + connected: description.connected, + connectedAt: Date.now(), + }, + }, + }; + }), + + clear: () => set({ sessionId: null, sessions: {} }), +})); + +/** The active session, or null when no game is attached. */ +export function useActiveSession(): SessionInfo | null { + return useSessionStore((state) => (state.sessionId ? (state.sessions[state.sessionId] ?? null) : null)); +} + +/** Studio's current mode. See `StudioMode`. */ +export function useStudioMode(): StudioMode { + return studioModeFor(useActiveSession()); +} + +/** Convenience for the common branch: local is the default. */ +export function useIsLocalMode(): boolean { + return useStudioMode() === 'local'; +} + + diff --git a/apps/studio/src/session/plugin-control.ts b/apps/studio/src/session/plugin-control.ts new file mode 100644 index 00000000..c423cf1f --- /dev/null +++ b/apps/studio/src/session/plugin-control.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { toast } from 'sonner'; +import { sendCommand } from '@feather/session-bridge'; +import { useConfigStore } from './game-config.ts'; +import { sessionSupportsRuntime, useSessionStore } from './index.ts'; + +/** + * Control a plugin in the attached game. + * + * The same shape Inspector exposes, so the tools moved without being rewritten, + * but backed by Studio's bridge-fed mirrors rather than Inspector's stores. + * With no game attached `available` is false and `setEnabled` is a no-op, which + * is the standalone-editor case rather than an error. + */ +export const usePluginControl = (pluginId: string) => { + const sessionId = useSessionStore((state) => state.sessionId); + const activeSession = useSessionStore((state) => (state.sessionId ? state.sessions[state.sessionId] : null)); + const plugin = useConfigStore((state) => state.config?.plugins?.[pluginId]); + const available = !!plugin; + const enabled = !!plugin && !plugin.disabled && !plugin.incompatible; + + const setEnabled = useCallback( + (nextEnabled: boolean, extra?: Record) => { + if (!sessionId || !sessionSupportsRuntime(activeSession)) return; + + sendCommand(sessionId, { + type: 'cmd:plugin:set_enabled', + plugin: pluginId, + enabled: nextEnabled, + ...(extra ?? {}), + }).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : `Failed to ${nextEnabled ? 'enable' : 'disable'} plugin`); + }); + }, + [activeSession, pluginId, sessionId], + ); + + return { available, enabled, plugin, setEnabled }; +}; diff --git a/apps/studio/src/showcase/LoveJsPreview.tsx b/apps/studio/src/showcase/LoveJsPreview.tsx new file mode 100644 index 00000000..32cbcee0 --- /dev/null +++ b/apps/studio/src/showcase/LoveJsPreview.tsx @@ -0,0 +1 @@ +export { LoveJsPreview } from '@studio/components/love-js-preview'; diff --git a/src/showcase/ShowcaseApp.tsx b/apps/studio/src/showcase/ShowcaseApp.tsx similarity index 97% rename from src/showcase/ShowcaseApp.tsx rename to apps/studio/src/showcase/ShowcaseApp.tsx index 3562a434..bb02eb93 100644 --- a/src/showcase/ShowcaseApp.tsx +++ b/apps/studio/src/showcase/ShowcaseApp.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; -import { Toaster } from '@/components/ui/sonner'; -import { Button } from '@/components/ui/button'; +import { Toaster } from '@feather/ui/sonner'; +import { Button } from '@feather/ui/button'; import { ShowcaseShaderGraph } from './ShowcaseShaderGraph'; import { ShowcaseParticlePlayground } from './ShowcaseParticlePlayground'; import { ShowcaseGallery } from './ShowcaseGallery'; import { BookOpenIcon } from 'lucide-react'; -import TextureLab from '@/pages/texture-lab'; +import TextureLab from '@studio/tools/texture-lab'; type ShowcaseRoute = 'home' | 'shader-graph' | 'particle-system-playground' | 'texture-lab'; diff --git a/src/showcase/ShowcaseGallery.tsx b/apps/studio/src/showcase/ShowcaseGallery.tsx similarity index 99% rename from src/showcase/ShowcaseGallery.tsx rename to apps/studio/src/showcase/ShowcaseGallery.tsx index a934a3c5..f07823e4 100644 --- a/src/showcase/ShowcaseGallery.tsx +++ b/apps/studio/src/showcase/ShowcaseGallery.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; -import { cn } from '@/utils/styles'; +import { cn } from '@feather/ui'; type GalleryItem = { src: string; diff --git a/apps/studio/src/showcase/ShowcaseParticlePlayground.tsx b/apps/studio/src/showcase/ShowcaseParticlePlayground.tsx new file mode 100644 index 00000000..55dd6300 --- /dev/null +++ b/apps/studio/src/showcase/ShowcaseParticlePlayground.tsx @@ -0,0 +1,17 @@ +import ParticleSystemPlaygroundPage from '@studio/tools/particle-system-playground'; + +/** + * The showcase used to hand this component its own local controller and a + * `standalone` prop, because with no game attached the tool would otherwise pick + * the live controller and report itself unavailable. Both were workarounds for + * the session model being inverted; the tool defaults to local now, so the + * showcase mounts it the same way Studio does — which is the point of the + * showcase being a faithful preview. + */ +export function ShowcaseParticlePlayground() { + return ( +
+ +
+ ); +} diff --git a/src/showcase/ShowcaseShaderGraph.tsx b/apps/studio/src/showcase/ShowcaseShaderGraph.tsx similarity index 95% rename from src/showcase/ShowcaseShaderGraph.tsx rename to apps/studio/src/showcase/ShowcaseShaderGraph.tsx index cfbe554a..88402471 100644 --- a/src/showcase/ShowcaseShaderGraph.tsx +++ b/apps/studio/src/showcase/ShowcaseShaderGraph.tsx @@ -2,17 +2,17 @@ import { ReactFlowProvider } from '@xyflow/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { DownloadIcon, FolderOpenIcon, Trash2Icon } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { useShaderGraphStore } from '@/store/shader-graph'; -import type { GeneratedGlsl, PlaygroundTarget, ShaderEdge, ShaderNodeInstance, ShaderParameter, ShaderSubgraph } from '@/types/shader-graph'; -import { NodePalette } from '@/pages/shader-graph/NodePalette'; -import { ShaderCanvas } from '@/pages/shader-graph/ShaderCanvas'; -import { ShaderRightPanel } from '@/pages/shader-graph/ShaderRightPanel'; -import { codegen } from '@/pages/shader-graph/codegen'; -import { diagnoseShaderGraph } from '@/pages/shader-graph/diagnostics'; -import { instantiateShaderGraphPreset, SHADER_GRAPH_PRESETS } from '@/pages/shader-graph/presets'; +import { Button } from '@feather/ui/button'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@feather/ui/resizable'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@feather/ui/select'; +import { useShaderGraphStore } from '@studio/store/shader-graph'; +import type { GeneratedGlsl, PlaygroundTarget, ShaderEdge, ShaderNodeInstance, ShaderParameter, ShaderSubgraph } from '@studio/types/shader-graph'; +import { NodePalette } from '@studio/tools/shader-graph/NodePalette'; +import { ShaderCanvas } from '@studio/tools/shader-graph/ShaderCanvas'; +import { ShaderRightPanel } from '@studio/tools/shader-graph/ShaderRightPanel'; +import { codegen } from '@studio/tools/shader-graph/codegen'; +import { diagnoseShaderGraph } from '@studio/tools/shader-graph/diagnostics'; +import { instantiateShaderGraphPreset, SHADER_GRAPH_PRESETS } from '@studio/tools/shader-graph/presets'; import { LoveJsPreview } from './LoveJsPreview'; const FILE_VERSION = 3; diff --git a/src/showcase/main.tsx b/apps/studio/src/showcase/mount.tsx similarity index 51% rename from src/showcase/main.tsx rename to apps/studio/src/showcase/mount.tsx index 64d3dde4..07ed373e 100644 --- a/src/showcase/main.tsx +++ b/apps/studio/src/showcase/mount.tsx @@ -1,9 +1,15 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { AppProvider } from '@/providers'; +import { AppProvider } from './providers'; import { ShowcaseApp } from './ShowcaseApp'; -import '@/App.css'; +/** + * Studio's public web entry. + * + * The showcase shell imports this; Studio owns what it renders. Keeping the + * mount here rather than in the shell is what makes the shell genuinely thin — + * it carries no page code of its own. + */ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/apps/studio/src/showcase/providers.tsx b/apps/studio/src/showcase/providers.tsx new file mode 100644 index 00000000..70f53ab6 --- /dev/null +++ b/apps/studio/src/showcase/providers.tsx @@ -0,0 +1,54 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UiProvider, type UiDependencies } from '@feather/ui'; +import { createWebHost, HostProvider } from '@feather/host'; +import { ThemeProvider, useResolvedTheme } from '@studio/theme'; + +/** + * The showcase is Feather Studio running in a browser. + * + * It gets the web host and no session bridge, which is the standalone case the + * tools already handle. Previously it shared the Inspector's provider tree, + * which is how a *desktop* page ended up importing browser code out of the + * showcase folder — the inversion Phase 04 removed. + */ +const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: 1000 * 60 * 60 } }, +}); + +declare global { + interface Window { + __FEATHER_QUERY_CLIENT__?: QueryClient; + } +} + +if (import.meta.env.DEV) { + try { + if (localStorage.getItem('feather-e2e-query-client') === '1') { + window.__FEATHER_QUERY_CLIENT__ = queryClient; + } + } catch { + // ignored + } +} + +const host = createWebHost(); + +// The showcase has no Inspector settings store to read a theme from, so it +// follows the system preference. +const uiDependencies: UiDependencies = { + useThemeMode: () => useResolvedTheme().mode, + useSyntaxTheme: () => useResolvedTheme().syntax as Record, + copyToClipboard: (value: string) => { + void navigator.clipboard?.writeText(value); + }, +}; + +export const AppProvider = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + +); diff --git a/apps/studio/src/showcase/showcase-app.css b/apps/studio/src/showcase/showcase-app.css new file mode 100644 index 00000000..9331f9ae --- /dev/null +++ b/apps/studio/src/showcase/showcase-app.css @@ -0,0 +1,123 @@ +@import 'tailwindcss'; +@import '../theme-tokens.css'; + +/* Tailwind v4 scans relative to the Vite root, which for the showcase is + apps/showcase/. Point it at Studio's source explicitly so the tools' classes + are found regardless of which config is building. */ +@source "../"; +/* The shared primitives live outside Studio, and their Tailwind classes must + be generated too — Radix select/dialog positioning comes from there. */ +@source "../../../../packages/ui/src"; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +.dark { + --background: #0d1117; + --foreground: #c9d1d9; + --card: #161b22; + --card-foreground: #c9d1d9; + --popover: #161b22; + --popover-foreground: #c9d1d9; + --primary: #8b949e; + --primary-foreground: #0d1117; + --secondary: #21262d; + --secondary-foreground: #c9d1d9; + --muted: #21262d; + --muted-foreground: #8b949e; + --accent: #30363d; + --accent-foreground: #c9d1d9; + --destructive: #f85149; + --border: #30363d; + --input: #30363d; + --ring: #8b949e; + --chart-1: #8b949e; + --chart-2: #3fb950; + --chart-3: #bc8cff; + --chart-4: #d29922; + --chart-5: #f85149; + --sidebar: #161b22; + --sidebar-foreground: #c9d1d9; + --sidebar-primary: #8b949e; + --sidebar-primary-foreground: #0d1117; + --sidebar-accent: #21262d; + --sidebar-accent-foreground: #c9d1d9; + --sidebar-border: #30363d; + --sidebar-ring: #ff9aa2; + --plugin-accent: #c9a0dc; + --plugin-active: #31263a; + --plugin-active-foreground: #f1def8; + --plugin-active-icon: #dfb7f0; + --plugin-active-border: #7f4d94; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@keyframes shader-node-drop-enter { + 0% { + opacity: 0.72; + box-shadow: + 0 0 0 0 color-mix(in oklab, var(--primary) 55%, transparent), + 0 12px 28px color-mix(in oklab, var(--primary) 22%, transparent); + filter: saturate(1.18); + } + 52% { + opacity: 1; + box-shadow: + 0 0 0 6px color-mix(in oklab, var(--primary) 0%, transparent), + 0 10px 24px color-mix(in oklab, var(--primary) 15%, transparent); + filter: saturate(1.08); + } + 100% { + opacity: 1; + box-shadow: 0 1px 2px color-mix(in oklab, var(--foreground) 10%, transparent); + filter: saturate(1); + } +} + +.shader-node-drop-enter { + animation: shader-node-drop-enter 520ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .shader-node-drop-enter { + animation: none; + } +} + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + ::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 9999px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + } +} diff --git a/src/store/shader-graph.ts b/apps/studio/src/store/shader-graph.ts similarity index 99% rename from src/store/shader-graph.ts rename to apps/studio/src/store/shader-graph.ts index b54b3673..8321c828 100644 --- a/src/store/shader-graph.ts +++ b/apps/studio/src/store/shader-graph.ts @@ -1,8 +1,8 @@ import type { Node, Edge } from '@xyflow/react'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import type { ShaderNodeData, PlaygroundTarget, GeneratedGlsl, ShaderPreviewShape, ShaderTextureUpload, ShaderSubgraph } from '@/types/shader-graph'; -import { clonePortDef, syncSubgraphBoundary, syncSubgraphInstances } from '@/pages/shader-graph/subgraphBoundary'; +import type { ShaderNodeData, PlaygroundTarget, GeneratedGlsl, ShaderPreviewShape, ShaderTextureUpload, ShaderSubgraph } from '@studio/types/shader-graph'; +import { clonePortDef, syncSubgraphBoundary, syncSubgraphInstances } from '@studio/tools/shader-graph/subgraphBoundary'; type ValidationStatus = 'idle' | 'validating' | 'ok' | 'error'; type ShaderRightPanelTab = 'controls' | 'selection' | 'output'; diff --git a/apps/studio/src/store/studio-preferences.ts b/apps/studio/src/store/studio-preferences.ts new file mode 100644 index 00000000..71611741 --- /dev/null +++ b/apps/studio/src/store/studio-preferences.ts @@ -0,0 +1,433 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware'; +import { + DEFAULT_COLLAPSED_SHADER_GRAPH_NODE_CATEGORIES, + normalizeShaderGraphNodeCategories, +} from '@studio/constants/shader-graph'; +import type { NodeCategory } from '@studio/types/shader-graph'; +import type { TextureLabRecipe, TextureLabSavedRecipe } from '@studio/types/texture-lab'; +import { + DEFAULT_TEXTURE_LAB_RECIPE, + normalizeTextureLabRecipe, + normalizeTextureLabSavedRecipes, + TEXTURE_LAB_SAVED_RECIPE_LIMIT, +} from '@studio/tools/texture-lab/generator'; + +/** + * Studio-owned preferences. + * + * These lived in the Inspector settings store, which is why that store imports + * texture-lab and shader-graph code — the coupling that makes a shared app-state + * package circular and blocks the application split (V4.md Phase 05). + * + * This stage extracts them **in place**: the tools still run inside the current + * application, but their state now has its own store, its own storage key, and a + * versioned payload that can be handed to a separately installed Feather Studio + * later. Studio will have its own data directory and storage origin, so it can + * never read `settings-storage` directly — the payload has to travel. + * + * The legacy copy is read but never deleted. Rollback has to stay possible. + */ + +export const STUDIO_PREFERENCES_VERSION = 1; + +/** The storage key for the extracted store. */ +export const STUDIO_PREFERENCES_STORAGE_KEY = 'studio-preferences'; + +/** The Inspector store this state came from. Read-only from here, forever. */ +export const LEGACY_SETTINGS_STORAGE_KEY = 'settings-storage'; + +export type TextureLabWorkspaceSnapshot = { + recipe: TextureLabRecipe; + savedRecipes: TextureLabSavedRecipe[]; +}; + +/** + * The transfer payload. + * + * Versioned deliberately: a separately installed Studio may be older or newer + * than the Inspector handing this over, so the receiver must be able to tell. + */ +export type StudioPreferencesV1 = { + version: typeof STUDIO_PREFERENCES_VERSION; + textureLabRecipe: TextureLabRecipe; + textureLabSavedRecipes: TextureLabSavedRecipe[]; + textureLabWorkspaceId: string; + textureLabWorkspaces: Record; + collapsedShaderGraphNodeCategories: NodeCategory[]; + particleTimelineZoom: number; + particleTimelineSnap: boolean; + /** + * Studio's own copy. Seeded from the Inspector value at migration and then + * owned independently — the two applications do not share a source directory + * setting after the split. + */ + assetSourceDir: string; +}; + +type StudioPreferencesState = Omit & { + /** Which migration produced this state, or 0 when it started clean. */ + migratedFrom: number; +}; + +type StudioPreferencesActions = { + setTextureLabRecipe: (recipe: Partial) => void; + activateTextureLabWorkspace: (workspaceId: string) => void; + deleteTextureLabWorkspace: (workspaceId: string) => void; + saveTextureLabRecipe: (name: string, recipe?: Partial) => void; + deleteTextureLabSavedRecipe: (id: string) => void; + toggleShaderGraphNodeCategory: (category: NodeCategory) => void; + setCollapsedShaderGraphNodeCategories: (categories: NodeCategory[]) => void; + setParticleTimelineZoom: (zoom: number) => void; + setParticleTimelineSnap: (snap: boolean) => void; + setAssetSourceDir: (dir: string) => void; + /** Idempotent. Returns false when the payload was rejected or already applied. */ + importPreferences: (payload: unknown, options?: { force?: boolean }) => boolean; +}; + +export type StudioPreferencesStore = StudioPreferencesState & StudioPreferencesActions; + +// --- normalizers ----------------------------------------------------------- +// Behaviour preserved from the Inspector settings store. Changing any of these +// would silently reshape data users already have. + +function createSavedTextureRecipeId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `texture-recipe-${crypto.randomUUID()}`; + } + return `texture-recipe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +function normalizeTextureLabSavedName(name: string): string { + return name.trim().replace(/\s+/g, ' ').slice(0, 64); +} + +export function normalizeTextureLabWorkspace( + snapshot?: Partial, +): TextureLabWorkspaceSnapshot { + return { + recipe: normalizeTextureLabRecipe(snapshot?.recipe), + savedRecipes: normalizeTextureLabSavedRecipes(snapshot?.savedRecipes), + }; +} + +export function normalizeParticleTimelineZoom(zoom?: unknown): number { + const value = Number(zoom); + if (!Number.isFinite(value)) return 1; + return Math.min(4, Math.max(1, Math.round(value * 4) / 4)); +} + +function normalizeWorkspaces(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record).map(([id, workspace]) => [ + id, + normalizeTextureLabWorkspace(workspace as Partial), + ]), + ); +} + +const defaultPreferences: StudioPreferencesState = { + textureLabRecipe: DEFAULT_TEXTURE_LAB_RECIPE, + textureLabSavedRecipes: [], + textureLabWorkspaceId: 'default', + textureLabWorkspaces: {}, + collapsedShaderGraphNodeCategories: [...DEFAULT_COLLAPSED_SHADER_GRAPH_NODE_CATEGORIES], + particleTimelineZoom: 1, + particleTimelineSnap: true, + assetSourceDir: '', + migratedFrom: 0, +}; + +/** + * Coerce anything into a valid payload. + * + * Accepts partial and malformed input on purpose: this parses data that may have + * come from an older application over a process bridge, so it must never throw. + * Unrecognised fields are dropped rather than carried through. + */ +export function normalizeStudioPreferences(raw: unknown): StudioPreferencesV1 { + const source = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}) as Record; + + return { + version: STUDIO_PREFERENCES_VERSION, + textureLabRecipe: normalizeTextureLabRecipe(source.textureLabRecipe as Partial), + textureLabSavedRecipes: normalizeTextureLabSavedRecipes(source.textureLabSavedRecipes), + textureLabWorkspaceId: + typeof source.textureLabWorkspaceId === 'string' && source.textureLabWorkspaceId.trim() + ? source.textureLabWorkspaceId + : 'default', + textureLabWorkspaces: normalizeWorkspaces(source.textureLabWorkspaces), + collapsedShaderGraphNodeCategories: normalizeShaderGraphNodeCategories( + source.collapsedShaderGraphNodeCategories, + ), + particleTimelineZoom: normalizeParticleTimelineZoom(source.particleTimelineZoom), + particleTimelineSnap: typeof source.particleTimelineSnap === 'boolean' ? source.particleTimelineSnap : true, + assetSourceDir: typeof source.assetSourceDir === 'string' ? source.assetSourceDir : '', + }; +} + +/** + * Read the Studio-owned slice out of the Inspector's persisted settings. + * + * Returns null when there is nothing to adopt, which is the case for a fresh + * install and for any environment without local storage. Never writes, never + * clears: the Inspector copy is the rollback path. + */ +export function readLegacyStudioPreferences( + storage: Pick | undefined = globalThis.localStorage, +): StudioPreferencesV1 | null { + if (!storage) return null; + let parsed: unknown; + try { + const raw = storage.getItem(LEGACY_SETTINGS_STORAGE_KEY); + if (!raw) return null; + parsed = JSON.parse(raw); + } catch { + return null; + } + + // zustand/persist wraps state as { state, version }. + const state = (parsed as { state?: unknown } | null)?.state; + if (!state || typeof state !== 'object') return null; + + const source = state as Record; + const hasStudioFields = [ + 'textureLabRecipe', + 'textureLabSavedRecipes', + 'textureLabWorkspaces', + 'collapsedShaderGraphNodeCategories', + 'particleTimelineZoom', + 'particleTimelineSnap', + ].some((key) => key in source); + + if (!hasStudioFields) return null; + return normalizeStudioPreferences(source); +} + +function createStudioStorage(): StateStorage { + const storage = globalThis.localStorage; + return { + getItem: (name) => storage.getItem(name), + removeItem: (name) => storage.removeItem(name), + setItem: (name, value) => { + try { + storage.setItem(name, value); + } catch (error) { + console.warn('[Feather] Could not persist studio preferences:', error); + } + }, + }; +} + +export const useStudioPreferencesStore = create()( + persist( + (set, get) => ({ + ...defaultPreferences, + + setTextureLabRecipe: (textureLabRecipe: Partial) => + set((state) => ({ + textureLabRecipe: normalizeTextureLabRecipe({ ...state.textureLabRecipe, ...textureLabRecipe }), + })), + + activateTextureLabWorkspace: (workspaceId: string) => + set((state) => { + const id = workspaceId.trim() || 'default'; + if (state.textureLabWorkspaceId === id) return {}; + const textureLabWorkspaces = { + ...state.textureLabWorkspaces, + [state.textureLabWorkspaceId]: normalizeTextureLabWorkspace({ + recipe: state.textureLabRecipe, + savedRecipes: state.textureLabSavedRecipes, + }), + }; + const next = textureLabWorkspaces[id] ?? normalizeTextureLabWorkspace(); + return { + textureLabWorkspaceId: id, + textureLabWorkspaces, + textureLabRecipe: next.recipe, + textureLabSavedRecipes: next.savedRecipes, + }; + }), + + deleteTextureLabWorkspace: (workspaceId: string) => + set((state) => { + const textureLabWorkspaces = { ...state.textureLabWorkspaces }; + delete textureLabWorkspaces[workspaceId]; + if (state.textureLabWorkspaceId !== workspaceId) { + return { textureLabWorkspaces }; + } + const fallback = textureLabWorkspaces.default ?? normalizeTextureLabWorkspace(); + return { + textureLabWorkspaceId: 'default', + textureLabWorkspaces, + textureLabRecipe: fallback.recipe, + textureLabSavedRecipes: fallback.savedRecipes, + }; + }), + + saveTextureLabRecipe: (name: string, textureLabRecipe?: Partial) => + set((state) => { + const savedName = normalizeTextureLabSavedName(name); + if (!savedName) return {}; + const now = Date.now(); + const recipe = normalizeTextureLabRecipe(textureLabRecipe ?? state.textureLabRecipe); + const existing = state.textureLabSavedRecipes.find( + (item) => item.name.toLowerCase() === savedName.toLowerCase(), + ); + const nextSavedRecipe: TextureLabSavedRecipe = { + id: existing?.id ?? createSavedTextureRecipeId(), + name: savedName, + recipe, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + const rest = state.textureLabSavedRecipes.filter((item) => item.id !== nextSavedRecipe.id); + return { + textureLabSavedRecipes: normalizeTextureLabSavedRecipes([nextSavedRecipe, ...rest]).slice( + 0, + TEXTURE_LAB_SAVED_RECIPE_LIMIT, + ), + }; + }), + + deleteTextureLabSavedRecipe: (id: string) => + set((state) => ({ + textureLabSavedRecipes: state.textureLabSavedRecipes.filter((item) => item.id !== id), + })), + + toggleShaderGraphNodeCategory: (category: NodeCategory) => + set((state) => ({ + collapsedShaderGraphNodeCategories: state.collapsedShaderGraphNodeCategories.includes(category) + ? state.collapsedShaderGraphNodeCategories.filter((id) => id !== category) + : [...state.collapsedShaderGraphNodeCategories, category], + })), + + setCollapsedShaderGraphNodeCategories: (collapsedShaderGraphNodeCategories: NodeCategory[]) => + set({ + collapsedShaderGraphNodeCategories: normalizeShaderGraphNodeCategories( + collapsedShaderGraphNodeCategories, + [], + ), + }), + + setParticleTimelineZoom: (particleTimelineZoom: number) => + set({ particleTimelineZoom: normalizeParticleTimelineZoom(particleTimelineZoom) }), + + setParticleTimelineSnap: (particleTimelineSnap: boolean) => set({ particleTimelineSnap }), + + setAssetSourceDir: (assetSourceDir: string) => set({ assetSourceDir }), + + importPreferences: (payload: unknown, options?: { force?: boolean }) => { + const candidate = payload as { version?: unknown } | null; + const version = Number(candidate?.version); + + // A payload from a newer Studio than this build understands. Refuse + // rather than silently dropping the fields we cannot read. + if (Number.isFinite(version) && version > STUDIO_PREFERENCES_VERSION) return false; + + // Idempotent: a repeated import of the same migration is a no-op unless + // the caller explicitly asks to overwrite. + const current = get(); + if (!options?.force && current.migratedFrom >= STUDIO_PREFERENCES_VERSION) return false; + + const normalized = normalizeStudioPreferences(payload); + set({ ...normalized, migratedFrom: STUDIO_PREFERENCES_VERSION }); + return true; + }, + }), + { + name: STUDIO_PREFERENCES_STORAGE_KEY, + storage: createJSONStorage(createStudioStorage), + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial | undefined; + + // Nothing persisted yet: adopt the Inspector's copy once. This is the + // in-place half of the migration. After the application split, a + // separately installed Studio has no access to that key and instead + // receives the payload over the bridge (Phase 05b). + if (!persisted) { + const legacy = readLegacyStudioPreferences(); + if (legacy) { + const fields: Omit = { + textureLabRecipe: legacy.textureLabRecipe, + textureLabSavedRecipes: legacy.textureLabSavedRecipes, + textureLabWorkspaceId: legacy.textureLabWorkspaceId, + textureLabWorkspaces: legacy.textureLabWorkspaces, + collapsedShaderGraphNodeCategories: legacy.collapsedShaderGraphNodeCategories, + particleTimelineZoom: legacy.particleTimelineZoom, + particleTimelineSnap: legacy.particleTimelineSnap, + assetSourceDir: legacy.assetSourceDir, + }; + return { ...currentState, ...fields, migratedFrom: STUDIO_PREFERENCES_VERSION }; + } + return currentState; + } + + return { + ...currentState, + ...persisted, + collapsedShaderGraphNodeCategories: normalizeShaderGraphNodeCategories( + persisted.collapsedShaderGraphNodeCategories, + ), + particleTimelineZoom: normalizeParticleTimelineZoom(persisted.particleTimelineZoom), + particleTimelineSnap: + typeof persisted.particleTimelineSnap === 'boolean' + ? persisted.particleTimelineSnap + : currentState.particleTimelineSnap, + textureLabRecipe: normalizeTextureLabRecipe(persisted.textureLabRecipe), + textureLabSavedRecipes: normalizeTextureLabSavedRecipes(persisted.textureLabSavedRecipes), + textureLabWorkspaceId: persisted.textureLabWorkspaceId ?? currentState.textureLabWorkspaceId, + textureLabWorkspaces: normalizeWorkspaces(persisted.textureLabWorkspaces), + }; + }, + /** + * Materialize the store on first run. + * + * Zustand only writes on a state change, so without this an adoption from + * legacy settings would sit in memory until the user happened to touch a + * Studio control — and be redone on every launch until then. Persisting + * eagerly also records the difference the two-step migration depends on: + * `migratedFrom: 0` means "this install started clean", which is not the + * same as "this install has never run". + */ + onRehydrateStorage: () => (state) => { + if (!state) return; + useStudioPreferencesStore.setState({ migratedFrom: state.migratedFrom }); + }, + // Mirrors the Inspector store: the active recipe and saved list are folded + // back into the current workspace snapshot on write, so switching + // workspaces never loses the one you were editing. + partialize: (state) => ({ + ...state, + textureLabWorkspaces: { + ...state.textureLabWorkspaces, + [state.textureLabWorkspaceId]: normalizeTextureLabWorkspace({ + recipe: state.textureLabRecipe, + savedRecipes: state.textureLabSavedRecipes, + }), + }, + }), + }, + ), +); + +/** + * The payload to hand a separately installed Studio on first pairing. + * + * Reads through `partialize`'s workspace folding so the in-flight recipe is + * included, not just what was last written. + */ +export function exportStudioPreferencesV1(): StudioPreferencesV1 { + const state = useStudioPreferencesStore.getState(); + return normalizeStudioPreferences({ + ...state, + textureLabWorkspaces: { + ...state.textureLabWorkspaces, + [state.textureLabWorkspaceId]: normalizeTextureLabWorkspace({ + recipe: state.textureLabRecipe, + savedRecipes: state.textureLabSavedRecipes, + }), + }, + }); +} diff --git a/apps/studio/src/store/studio-ui.ts b/apps/studio/src/store/studio-ui.ts new file mode 100644 index 00000000..5cca9528 --- /dev/null +++ b/apps/studio/src/store/studio-ui.ts @@ -0,0 +1,52 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +/** + * Studio's shell state. + * + * Which tool you had open is something you chose and would be mildly annoyed to + * choose again, which is the test V4-ENHANCED **C1** settled on for what belongs + * in storage. Global rather than per session, for the same reason Inspector's + * panel state is: what you are working on outlives any particular game, and in + * Studio there is usually no game at all. + * + * Deliberately not here: anything transient. A dialog being open, a drag in + * progress. Restoring those reopens the app into a state nobody left it in. + */ + +export const STUDIO_TOOLS = ['shader-graph', 'particle-playground', 'texture-lab'] as const; + +export type StudioToolId = (typeof STUDIO_TOOLS)[number]; + +function isStudioTool(value: unknown): value is StudioToolId { + return typeof value === 'string' && (STUDIO_TOOLS as readonly string[]).includes(value); +} + +type StudioUiStore = { + activeTool: StudioToolId; + setActiveTool: (tool: StudioToolId) => void; +}; + +export const useStudioUiStore = create()( + persist( + (set) => ({ + activeTool: 'shader-graph', + setActiveTool: (activeTool) => set({ activeTool }), + }), + { + name: 'feather-studio-ui', + version: 1, + merge: (persisted, current) => { + const saved = persisted as Partial | undefined; + return { + ...current, + ...saved, + // A tool id written by an older build — or a renamed one — must not + // leave the shell with nothing to render. Adding a field to a + // persisted store is a migration whether or not anyone calls it one. + activeTool: isStudioTool(saved?.activeTool) ? saved.activeTool : current.activeTool, + }; + }, + }, + ), +); diff --git a/apps/studio/src/studio.css b/apps/studio/src/studio.css new file mode 100644 index 00000000..2b6bf34d --- /dev/null +++ b/apps/studio/src/studio.css @@ -0,0 +1,25 @@ +@import 'tailwindcss'; +@import './theme-tokens.css'; + +/* Tailwind v4 scans relative to the Vite root, which for the showcase is + apps/showcase/. Point it at Studio's source explicitly so the tools' classes + are found regardless of which config is building. */ +@source "./"; +@source "../../../packages/ui/src"; + +/* Tailwind v4 changed the default `border` color from a light grey to + `currentColor`. Without this rule every `border` utility in Studio drew in the + *text* colour — a near-black line around every container, which is exactly the + "strong borders" that made the tools look boxed-in. shadcn assumes this rule + exists; the showcase had it, Studio's own entry never did. See V4-STUDIO.md + §5 (W2b). */ +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + } +} diff --git a/apps/studio/src/theme-tokens.css b/apps/studio/src/theme-tokens.css new file mode 100644 index 00000000..181aa717 --- /dev/null +++ b/apps/studio/src/theme-tokens.css @@ -0,0 +1,182 @@ +/** + * Feather Studio's design tokens. + * + * Two separate jobs, both required, and Studio had neither: + * + * 1. `@theme inline` maps the tokens onto Tailwind's colour utilities, which is + * what makes `bg-card` and `text-muted-foreground` *exist* as classes. The + * tools use them 394 times. Without this block Tailwind never generates them + * and the classes are inert, whatever the values are. + * 2. The `:root` block is the fallback that paints before `ThemeProvider` writes + * the resolved theme onto the root element, so the first frame is not + * unstyled. + * + * Shared by the Studio application and the showcase, which is why it lives in + * its own file: they render the same tools and must not drift apart on what a + * card looks like. + */ + +:root { + --ok: #137245; + --ok-surface: #e2f8ee; + --ok-border: #b1e7ce; + --warn: #85580a; + --warn-surface: #f8f0e2; + --warn-border: #e7d3b1; + --danger: #b4271d; + --danger-surface: #f8e4e2; + --danger-border: #e7b5b1; + --info: #2161ab; + --info-surface: #e2ecf8; + --info-border: #b1cae7; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + + color: #403f53; + background-color: #f0f4f8; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; + --radius: 0.625rem; + --background: #f0f4f8; + --foreground: #403f53; + --card: #f8fbff; + --card-foreground: #403f53; + --popover: #f8fbff; + --popover-foreground: #403f53; + --primary: #ad5b68; + --primary-foreground: #ffffff; + --secondary: #e5ebf3; + --secondary-foreground: #403f53; + --muted: #e5ebf3; + --muted-foreground: #6c6f85; + --accent: #f3dfe4; + --accent-foreground: #873f4d; + --destructive: #d3423e; + --border: #d4dce8; + --input: #c9d3e3; + --ring: #ad5b68; + --chart-1: #ad5b68; + --chart-2: #0c969b; + --chart-3: #994cc3; + --chart-4: #c96765; + --chart-5: #d3423e; + --sidebar: #e8eef6; + --sidebar-foreground: #403f53; + --sidebar-primary: #ad5b68; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f0dce3; + --sidebar-accent-foreground: #873f4d; + --sidebar-border: #d4dce8; + --sidebar-ring: #ad5b68; + --plugin-accent: #8a5a9f; + --plugin-active: #eee1f2; + --plugin-active-foreground: #563965; + --plugin-active-icon: #74498a; + --plugin-active-border: #d5bddf; +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + /* Semantic state colours. Derived per theme by @feather/ui/theme/semantic; + these are the light-theme fallbacks for before a theme is applied. + See V4-STUDIO.md S5 and V4-ENHANCED.md §4 (C0). */ + --color-ok: var(--ok); + --color-ok-surface: var(--ok-surface); + --color-ok-border: var(--ok-border); + --color-warn: var(--warn); + --color-warn-surface: var(--warn-surface); + --color-warn-border: var(--warn-border); + --color-danger: var(--danger); + --color-danger-surface: var(--danger-surface); + --color-danger-border: var(--danger-border); + --color-info: var(--info); + --color-info-surface: var(--info-surface); + --color-info-border: var(--info-border); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +/** + * The workbench surface idiom. + * + * Studio's three tools were built at different times inside Inspector and each + * accumulated its own way of drawing a box: 98 containers across eight distinct + * class combinations — `rounded border`, `rounded-md border border-border/70`, + * `rounded border bg-background`, and so on. The result read as three products + * sharing a window, and every container arrived with a drawn outline. + * + * These are the two things a container can be, named once: + * + * `.surface` a region — a value step up from its parent, no outline. + * `.surface-inset` a region *within* a region — a step the other way. + * + * Neither draws a border, per V4-STUDIO.md **W2b**: a border is a line you have + * to look at, and a value step says "different region" then stops registering. + * Anything you click into keeps its border, because there the edge is an + * affordance rather than decoration. + * + * Changing how a container looks is now one edit here rather than 98. + */ +@layer components { + .surface { + background-color: var(--card); + border-radius: var(--radius-sm, 0.25rem); + } + + .surface-inset { + background-color: color-mix(in srgb, var(--muted) 55%, transparent); + border-radius: var(--radius-sm, 0.25rem); + } + + /* The label above a group of controls. The three tools had written this + eight ways — 10px/11px/xs, semibold/medium, wider/wide — which is the + kind of drift nobody introduces on purpose and everybody notices as + "these screens don't match". Named once, it is now a decision rather + than a copy. */ + .section-label { + font-size: 10px; + line-height: 1.4; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted-foreground); + } +} diff --git a/apps/studio/src/theme.tsx b/apps/studio/src/theme.tsx new file mode 100644 index 00000000..985d33a6 --- /dev/null +++ b/apps/studio/src/theme.tsx @@ -0,0 +1,56 @@ +import { useLayoutEffect, useSyncExternalStore } from 'react'; +import { resolveTheme, type ThemeMode } from '@feather/ui/theme/registry'; + +const prefersDarkQuery = '(prefers-color-scheme: dark)'; + +function getSystemThemeMode(): ThemeMode { + if (typeof window === 'undefined' || !window.matchMedia) return 'light'; + return window.matchMedia(prefersDarkQuery).matches ? 'dark' : 'light'; +} + +function subscribeSystemThemeMode(onChange: () => void) { + if (typeof window === 'undefined' || !window.matchMedia) return () => {}; + const media = window.matchMedia(prefersDarkQuery); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); +} + +export function useSystemThemeMode(): ThemeMode { + return useSyncExternalStore(subscribeSystemThemeMode, getSystemThemeMode, () => 'light'); +} + +/** + * Studio's theme. + * + * This is not decoration: the provider writes every design token onto the root + * element as a CSS custom property. Without it `var(--background)` and friends + * resolve to nothing and the layout collapses — which is exactly what happened + * when the showcase first ran without it. + * + * Studio follows the system preference. It has no theme *setting* yet because + * that lived in Inspector's settings store, which Studio no longer reads. + */ +export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { + const systemMode = useSystemThemeMode(); + const resolvedTheme = resolveTheme('system', systemMode); + + useLayoutEffect(() => { + const root = window.document.documentElement; + if (!root.classList) return; + + root.classList.remove('light', 'dark'); + root.classList.add(resolvedTheme.mode); + root.dataset.theme = resolvedTheme.id; + root.style.colorScheme = resolvedTheme.mode; + + for (const [name, value] of Object.entries(resolvedTheme.variables)) { + root.style.setProperty(`--${name}`, value); + } + }, [resolvedTheme]); + + return <>{children}; +}; + +export function useResolvedTheme() { + return resolveTheme('system', useSystemThemeMode()); +} diff --git a/src/pages/particle-system-playground/components/AccelerationEditor.tsx b/apps/studio/src/tools/particle-system-playground/components/AccelerationEditor.tsx similarity index 88% rename from src/pages/particle-system-playground/components/AccelerationEditor.tsx rename to apps/studio/src/tools/particle-system-playground/components/AccelerationEditor.tsx index 8f5d9791..ad0aef01 100644 --- a/src/pages/particle-system-playground/components/AccelerationEditor.tsx +++ b/apps/studio/src/tools/particle-system-playground/components/AccelerationEditor.tsx @@ -1,5 +1,5 @@ -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@feather/ui/tabs'; +import type { ParticleSystemPlaygroundSystem } from '@studio/types/particle-system-playground'; import { CircularForceGizmo } from './CircularForceGizmo'; import { DampingRangeEditor } from './DampingRangeEditor'; import { LinearAccelPlane } from './LinearAccelPlane'; @@ -13,7 +13,7 @@ type Props = { export function AccelerationEditor({ system, onChange }: Props) { return (
-

Acceleration And Damping

+

Acceleration And Damping

diff --git a/src/pages/particle-system-playground/components/CircularForceGizmo.tsx b/apps/studio/src/tools/particle-system-playground/components/CircularForceGizmo.tsx similarity index 97% rename from src/pages/particle-system-playground/components/CircularForceGizmo.tsx rename to apps/studio/src/tools/particle-system-playground/components/CircularForceGizmo.tsx index 3cc2a7e1..81e67cf9 100644 --- a/src/pages/particle-system-playground/components/CircularForceGizmo.tsx +++ b/apps/studio/src/tools/particle-system-playground/components/CircularForceGizmo.tsx @@ -1,5 +1,5 @@ -import { Label } from '@/components/ui/label'; -import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { Label } from '@feather/ui/label'; +import type { ParticleSystemPlaygroundSystem } from '@studio/types/particle-system-playground'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ParticleNumberInput } from './ParticleNumberInput'; @@ -141,7 +141,7 @@ export function CircularForceGizmo({ system, onChange }: Props) { return (
- + Radial & Tangential
@@ -160,7 +160,7 @@ export function CircularForceGizmo({ system, onChange }: Props) { ref={svgRef} width="100%" height={H} - className="touch-none rounded border bg-muted/10 select-none" + className="surface-inset touch-none select-none" onPointerMove={onPointerMove} onPointerUp={stopDrag} onPointerLeave={stopDrag} diff --git a/src/pages/particle-system-playground/components/ColorGradientEditor.tsx b/apps/studio/src/tools/particle-system-playground/components/ColorGradientEditor.tsx similarity index 97% rename from src/pages/particle-system-playground/components/ColorGradientEditor.tsx rename to apps/studio/src/tools/particle-system-playground/components/ColorGradientEditor.tsx index 79a75435..30d3ec48 100644 --- a/src/pages/particle-system-playground/components/ColorGradientEditor.tsx +++ b/apps/studio/src/tools/particle-system-playground/components/ColorGradientEditor.tsx @@ -1,6 +1,6 @@ -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { Button } from '@feather/ui/button'; +import { Input } from '@feather/ui/input'; +import { Label } from '@feather/ui/label'; import { PlusIcon, Trash2Icon } from 'lucide-react'; import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { catmullRomPath } from './curveUtils'; @@ -107,7 +107,7 @@ export function ColorGradientEditor({ value, onChange }: { value: string; onChan ref={alphaRef} width="100%" height={ALPHA_H} - className="rounded border bg-card touch-none select-none" + className="surface touch-none select-none" onPointerMove={onAlphaPointerMove} onPointerUp={stopAlphaDrag} onPointerLeave={stopAlphaDrag} diff --git a/src/pages/particle-system-playground/components/CompositeSelector.tsx b/apps/studio/src/tools/particle-system-playground/components/CompositeSelector.tsx similarity index 94% rename from src/pages/particle-system-playground/components/CompositeSelector.tsx rename to apps/studio/src/tools/particle-system-playground/components/CompositeSelector.tsx index 102e3fbf..e3565cc6 100644 --- a/src/pages/particle-system-playground/components/CompositeSelector.tsx +++ b/apps/studio/src/tools/particle-system-playground/components/CompositeSelector.tsx @@ -1,6 +1,6 @@ -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Button } from '@feather/ui/button'; +import { Input } from '@feather/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@feather/ui/select'; import { Dialog, DialogContent, @@ -8,10 +8,10 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; +} from '@feather/ui/dialog'; import { PlusIcon, Trash2Icon } from 'lucide-react'; import { useState } from 'react'; -import { PARTICLE_SYSTEM_PLAYGROUND_TEMPLATES, type ParticleSystemPlaygroundTemplate } from '@/types/particle-system-playground'; +import { PARTICLE_SYSTEM_PLAYGROUND_TEMPLATES, type ParticleSystemPlaygroundTemplate } from '@studio/types/particle-system-playground'; type Props = { composites: string[]; diff --git a/src/pages/particle-system-playground/components/DampingRangeEditor.tsx b/apps/studio/src/tools/particle-system-playground/components/DampingRangeEditor.tsx similarity index 93% rename from src/pages/particle-system-playground/components/DampingRangeEditor.tsx rename to apps/studio/src/tools/particle-system-playground/components/DampingRangeEditor.tsx index 8acc92f0..1de07c1b 100644 --- a/src/pages/particle-system-playground/components/DampingRangeEditor.tsx +++ b/apps/studio/src/tools/particle-system-playground/components/DampingRangeEditor.tsx @@ -1,5 +1,5 @@ -import { Label } from '@/components/ui/label'; -import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { Label } from '@feather/ui/label'; +import type { ParticleSystemPlaygroundSystem } from '@studio/types/particle-system-playground'; import { useEffect, useRef, useState } from 'react'; import { ParticleNumberInput } from './ParticleNumberInput'; @@ -70,9 +70,9 @@ export function DampingRangeEditor({ system, onChange }: Props) { return (
- Damping + Damping - + {/* Guide lines at 25%, 50%, 75% velocity */} {[0.25, 0.5, 0.75].map((t) => (
-