From 2b8da765f0e8f69a1ebdbbd8b835cbd982f8753f Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 14:34:50 +0200 Subject: [PATCH 1/2] chore: reconcile current main into develop --- .codex/agents/slice-simplifier.toml | 5 +- .github/workflows/ci.yml | 3 + .../scaleway-backend-access-and-costs.md | 20 +- .../operations/scaleway-backend-deployment.md | 10 +- package-lock.json | 427 +++++++++++------- package.json | 2 +- shared/src/project-data.ts | 33 +- shared/tsconfig.json | 1 + src/commands/selection/transform-commands.ts | 8 +- src/components/app/pf-project-browser.ts | 62 ++- src/components/app/pf-pwa-update-toast.ts | 24 +- src/components/app/pixel-forge-app.ts | 94 ++-- .../canvas/viewport/transform-handlers.ts | 8 +- src/components/menu/pf-menu-bar.ts | 7 +- src/components/ui/pf-dialog.ts | 124 ++++- src/services/auto-save.ts | 82 ++-- src/services/project-library.ts | 12 +- src/services/pwa-file-handling.ts | 2 +- src/services/pwa-registration.ts | 1 + src/services/selection-transform-commit.ts | 26 +- src/stores/pwa.ts | 95 +++- src/stores/selection/store.ts | 10 +- src/stores/workspace.ts | 123 ++++- src/types/selection.ts | 2 + .../components/app/pf-project-browser.test.ts | 102 ++++- .../app/pf-pwa-update-toast.test.ts | 116 ++++- tests/components/app/pixel-forge-app.test.ts | 107 ++++- tests/components/menu/pf-menu-bar.test.ts | 8 +- tests/components/ui/pf-dialog.test.ts | 63 +++ tests/serialization/project-data.test.ts | 24 +- tests/services/auto-save.test.ts | 101 +++++ tests/services/project-library.test.ts | 19 +- tests/services/pwa-file-handling.test.ts | 7 + tests/stores/pwa.test.ts | 67 ++- tests/stores/workspace.test.ts | 334 +++++++++++++- tests/tools/transform-active-context.test.ts | 34 ++ tests/workers/telemetry-worker.test.ts | 51 ++- workers/telemetry/src/index.ts | 8 +- 38 files changed, 1870 insertions(+), 352 deletions(-) diff --git a/.codex/agents/slice-simplifier.toml b/.codex/agents/slice-simplifier.toml index d419288..954c8c8 100644 --- a/.codex/agents/slice-simplifier.toml +++ b/.codex/agents/slice-simplifier.toml @@ -24,8 +24,9 @@ net, and materially improve readability. Otherwise report a future opportunity. Never expand into sibling cleanup outside the PR diff. If code changes, make one focused simplification commit after running affected -tests, npx tsc --noEmit, npm run lint, npm run build, npm run fallow:audit, and -git diff --check. Push the same branch. Do not merge. Return the exact new head +tests, npm run test:run, npx tsc --noEmit, npm run lint, npm run build, +npm run fallow:audit, and git diff --check. Push the same branch. Do not merge. +Return the exact new head to the workflow director so it can mark the green PR ready and hand it to pr-reviewer. """ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13c6320..56286ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@v4 with: @@ -61,6 +62,8 @@ jobs: PIXEL_FORGE_SMOKE_PROJECT_NAME: pixel-forge-container-smoke-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Build and smoke the linux/amd64 server image run: npm run server:container:smoke diff --git a/docs/operations/scaleway-backend-access-and-costs.md b/docs/operations/scaleway-backend-access-and-costs.md index 30a24dd..631ad8a 100644 --- a/docs/operations/scaleway-backend-access-and-costs.md +++ b/docs/operations/scaleway-backend-access-and-costs.md @@ -39,18 +39,26 @@ write, and full-access capabilities. Prefer the narrow sets below over | --- | --- | --- | --- | | Owner | Approvals and exceptional recovery | Human account with strong authentication; temporarily delegates narrowly scoped policies | Routine CI or runtime use | | Read-only auditor | Inventory, cost, and evidence checks | `ContainersReadOnly`, `ContainerRegistryReadOnly`, `ServerlessSQLDatabaseReadOnly` or `RelationalDatabasesReadOnly` for provider metadata, `ObjectStorageBucketsRead`, `ObservabilityReadOnly`, and IAM metadata read-only when required | All mutation and secret-version access | -| Deployment application | Push an approved digest and update the approved container | `ContainerRegistryFullAccess` and `ContainersFullAccess`, project-scoped; add `PrivateNetworksReadOnly` only for the Managed PostgreSQL topology | Database data, migration credentials, runtime object access, website-bucket deployment | +| Deployment application | Push an approved digest and update the approved container | `ContainerRegistryFullAccess` and `ContainersFullAccess`, project-scoped only within a dedicated or otherwise narrowly bounded deployment project; add `PrivateNetworksReadOnly` only for the Managed PostgreSQL topology | Database data, migration credentials, runtime object access, website-bucket deployment | | Provisioning application | One approved resource-creation session | Temporary product-specific full access only for resources named in that approval; bucket-policy access only while installing the reviewed policy | Persistent runtime or ordinary deployment use; revoke after the session | | Migration application/database role | One approved migration | Database DDL on the selected Pixel Forge database only; for Serverless SQL, evaluate `ServerlessSQLDatabaseReadWrite` rather than full resource administration | Resource creation/deletion, container deployment, object storage, unrelated databases | | Runtime application/database role | Serve API requests | `ServerlessSQLDatabaseDataReadWrite` when that IAM path is used, plus a native PostgreSQL DML-only role on the application schema; object `Get`, `Put`, and `Delete`, plus only the bucket-level access required by readiness, restricted to the private project bucket/prefix | DDL, provider administration, bucket creation/deletion/policy changes, registry, deployment, website bucket | | DNS operator | Add or roll back the one approved `api` record | Cloudflare token scoped to DNS edit for the `pixel-forge.app` zone | Other zones, account settings, unrelated records | +`ContainerRegistryFullAccess` and `ContainersFullAccess` reach every matching +resource in their project. Do not grant them in a shared deployment project. +Treat them as safe only when the deployment targets are isolated in a +dedicated project or the project boundary contains no unrelated resources. + Scaleway project-level Object Storage sets can be broader than the one bucket -the runtime needs. Install and review a bucket policy that names the runtime -application, allows only the required actions and prefix, requires TLS, and -explicitly protects the website bucket. The current adapter uses `HeadBucket`, -`GetObject`, `PutObject`, and `DeleteObject`; verify an allowed operation on the -private project bucket and a denied operation against `pixel-forge.app`. +the runtime needs. Install and review a private project-bucket policy that +names the runtime application, allows only the required actions and prefix, +and requires TLS. Protect the `pixel-forge.app` website bucket independently +with its own bucket policy or a separate project or credential boundary; the +private-bucket policy cannot protect another bucket. The current adapter uses +`HeadBucket`, `GetObject`, `PutObject`, and `DeleteObject`. Verify an allowed +operation on the private project bucket, then separately verify that the +runtime credential cannot write a new object to the website bucket. Do not invent a narrower permission name if the live console does not offer it. Stop and ask the owner whether to use a reviewed resource policy, a diff --git a/docs/operations/scaleway-backend-deployment.md b/docs/operations/scaleway-backend-deployment.md index 6647385..1cbdc4e 100644 --- a/docs/operations/scaleway-backend-deployment.md +++ b/docs/operations/scaleway-backend-deployment.md @@ -189,9 +189,13 @@ echo scw registry namespace create \ Create the private project bucket and its bucket policy only from an approved, reviewed policy artifact. The policy must grant the runtime principal only the -object operations used by the server and must explicitly prevent access to the -public website bucket. Verify both an allowed project-object operation and a -denied website-bucket operation before deployment. +object operations used by the server, only within that private bucket. Protect +the public website bucket independently with its own policy or a separate +project or credential boundary; the private-bucket policy cannot protect +another bucket. Verify an allowed private project-bucket operation. Separately, +verify that the runtime credential is denied when it tries to write a new +object to the website bucket, and confirm that no verification object was +created. If Managed PostgreSQL is selected, create its Private Network in this stage and attach the database and future container to that one network. Serverless diff --git a/package-lock.json b/package-lock.json index 00dee07..c864a3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "vite": "^8.1.3", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.0", - "wrangler": "^4.113.0" + "wrangler": "^4.114.0" } }, "node_modules/@aws-sdk/checksums": { @@ -1984,9 +1984,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz", - "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", + "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", "cpu": [ "x64" ], @@ -2001,9 +2001,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz", - "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", + "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", "cpu": [ "arm64" ], @@ -2018,9 +2018,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz", - "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", + "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", "cpu": [ "x64" ], @@ -2035,9 +2035,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz", - "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", + "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", "cpu": [ "arm64" ], @@ -2052,9 +2052,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz", - "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", + "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", "cpu": [ "x64" ], @@ -3340,9 +3340,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", "cpu": [ "arm64" ], @@ -3353,19 +3353,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.1" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", "cpu": [ "x64" ], @@ -3376,19 +3376,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", "cpu": [ "arm64" ], @@ -3403,9 +3423,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", "cpu": [ "x64" ], @@ -3420,13 +3440,16 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3437,13 +3460,16 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3454,13 +3480,16 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3471,13 +3500,16 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3488,13 +3520,16 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3505,13 +3540,16 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3522,13 +3560,16 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3539,13 +3580,16 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3556,213 +3600,254 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.1" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.1" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.1" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.1" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.1" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.2" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", "cpu": [ "arm64" ], @@ -3773,16 +3858,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", "cpu": [ "ia32" ], @@ -3793,16 +3878,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", "cpu": [ "x64" ], @@ -3813,7 +3898,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -8796,16 +8881,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260721.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz", - "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==", + "version": "4.20260722.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.0.tgz", + "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", + "sharp": "0.35.2", "undici": "7.28.0", - "workerd": "1.20260721.1", + "workerd": "1.20260722.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -8860,9 +8945,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -9130,9 +9215,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", "dev": true, "funding": [ { @@ -9150,7 +9235,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9571,48 +9656,48 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" } }, "node_modules/shebang-command": { @@ -11083,9 +11168,9 @@ } }, "node_modules/workerd": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", - "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", + "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -11096,17 +11181,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260721.1", - "@cloudflare/workerd-darwin-arm64": "1.20260721.1", - "@cloudflare/workerd-linux-64": "1.20260721.1", - "@cloudflare/workerd-linux-arm64": "1.20260721.1", - "@cloudflare/workerd-windows-64": "1.20260721.1" + "@cloudflare/workerd-darwin-64": "1.20260722.1", + "@cloudflare/workerd-darwin-arm64": "1.20260722.1", + "@cloudflare/workerd-linux-64": "1.20260722.1", + "@cloudflare/workerd-linux-arm64": "1.20260722.1", + "@cloudflare/workerd-windows-64": "1.20260722.1" } }, "node_modules/wrangler": { - "version": "4.113.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz", - "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==", + "version": "4.114.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.114.0.tgz", + "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -11114,10 +11199,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "4.20260721.0", + "miniflare": "4.20260722.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260721.1" + "workerd": "1.20260722.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -11131,7 +11216,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260721.1" + "@cloudflare/workers-types": "^5.20260722.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index b7f56e4..316e7b6 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,6 @@ "vite": "^8.1.3", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.0", - "wrangler": "^4.113.0" + "wrangler": "^4.114.0" } } diff --git a/shared/src/project-data.ts b/shared/src/project-data.ts index 893fe56..8410633 100644 --- a/shared/src/project-data.ts +++ b/shared/src/project-data.ts @@ -2,6 +2,9 @@ import type { LegacyProjectImageData, ProjectFile, ProjectFileInput } from './pr declare function atob(data: string): string; +const LEGACY_BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?|[A-Za-z0-9+/]{4})?$/; + export function normalizeProjectFileImageData(file: ProjectFileInput): ProjectFile { return { ...file, @@ -64,12 +67,14 @@ function serializedBytesToUint8Array(data: Record): Uint8Array { function decodeLegacyBase64ImageData(data: string): Uint8Array { const base64 = getBase64Payload(data.trim()); - if (!base64) return new Uint8Array(0); + if (!base64) { + throw new TypeError('Legacy project image data is empty'); + } try { return base64ToUint8Array(base64); - } catch { - return new Uint8Array(0); + } catch (error) { + throw new TypeError('Invalid legacy base64 project image data', { cause: error }); } } @@ -78,14 +83,28 @@ function getBase64Payload(data: string): string { if (!data.startsWith('data:')) return data; const commaIndex = data.indexOf(','); - if (commaIndex === -1) return ''; + if (commaIndex === -1) { + throw new TypeError('Invalid legacy base64 data URL'); + } - const metadata = data.slice(0, commaIndex).toLowerCase(); - return metadata.includes(';base64') ? data.slice(commaIndex + 1) : ''; + const metadataTokens = data.slice(5, commaIndex).split(';'); + const hasBase64Token = metadataTokens + .slice(1) + .some((token) => token.toLowerCase() === 'base64'); + if (!hasBase64Token) { + throw new TypeError('Unsupported legacy image data URL: expected base64'); + } + return data.slice(commaIndex + 1); } function base64ToUint8Array(base64: string): Uint8Array { - const binary = atob(base64.replace(/\s/g, '')); + const compactBase64 = base64.replace(/\s/g, ''); + if (!LEGACY_BASE64_PATTERN.test(compactBase64)) { + throw new TypeError('Invalid base64 payload'); + } + + const binary = atob(compactBase64); + if (binary.length === 0) throw new TypeError('Empty base64 payload'); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); diff --git a/shared/tsconfig.json b/shared/tsconfig.json index e586108..a19cea5 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", + "lib": ["ES2022"], "moduleResolution": "bundler", "moduleDetection": "force", "verbatimModuleSyntax": true, diff --git a/src/commands/selection/transform-commands.ts b/src/commands/selection/transform-commands.ts index cb97aae..cd7cc94 100644 --- a/src/commands/selection/transform-commands.ts +++ b/src/commands/selection/transform-commands.ts @@ -16,6 +16,8 @@ import { EditableCelCommand, type EditableCelCommandContext } from './editable-c */ export class TransformSelectionCommand extends EditableCelCommand implements Command { name: string; + private readonly layerId: string; + private readonly frameId: string; // Original state (for undo) private originalImageData: ImageData; @@ -50,6 +52,8 @@ export class TransformSelectionCommand extends EditableCelCommand implements Com context: EditableCelCommandContext = getActiveProjectContext() ) { super(layerId, frameId, context); + this.layerId = layerId; + this.frameId = frameId; // Generate descriptive name based on what changed const hasScale = scale.x !== 1 || scale.y !== 1; @@ -117,7 +121,9 @@ export class TransformSelectionCommand extends EditableCelCommand implements Com this.originalImageData, this.originalBounds, this.originalShape, - this.originalMask + this.originalMask, + this.layerId, + this.frameId ); // Re-apply the transforms for preview diff --git a/src/components/app/pf-project-browser.ts b/src/components/app/pf-project-browser.ts index b4b9e06..c4f1f71 100644 --- a/src/components/app/pf-project-browser.ts +++ b/src/components/app/pf-project-browser.ts @@ -301,9 +301,11 @@ export class PFProjectBrowser extends BaseComponent { @state() private projects: ProjectMeta[] = []; @state() private isLoading = true; @state() private errorMessage = ''; + @state() private deleteErrorMessage = ''; @state() private renamingProjectId: string | null = null; @state() private renameValue = ''; @state() private deleteTarget: ProjectMeta | null = null; + @state() private isDeletingProject = false; @query('.browser-dialog') private browserDialog?: HTMLDialogElement; @query('.delete-dialog') private deleteDialog?: HTMLDialogElement; private thumbnailUrls = new Map(); @@ -461,7 +463,7 @@ export class PFProjectBrowser extends BaseComponent {
-
@@ -477,7 +479,8 @@ export class PFProjectBrowser extends BaseComponent { aria-labelledby="delete-project-title" class="delete-dialog" data-scrollbar="vertical" - closedby="any" + closedby=${this.isDeletingProject ? 'none' : 'any'} + @cancel=${this.handleDeleteDialogCancel} @click=${this.handleDeleteBackdropClick} @close=${this.handleDeleteDialogClose} > @@ -489,12 +492,25 @@ export class PFProjectBrowser extends BaseComponent {

Delete "${project.name}" from this browser?

+ ${this.deleteErrorMessage + ? html`` + : nothing}
- -
` @@ -506,6 +522,7 @@ export class PFProjectBrowser extends BaseComponent { private async loadProjects() { this.isLoading = true; this.errorMessage = ''; + this.deleteErrorMessage = ''; try { const projects = await projectLibrary.listProjects(); @@ -605,6 +622,12 @@ export class PFProjectBrowser extends BaseComponent { this.renameValue = ''; }; + private startDelete(project: ProjectMeta) { + this.errorMessage = ''; + this.deleteErrorMessage = ''; + this.deleteTarget = project; + } + private async duplicateProject(id: string) { this.errorMessage = ''; @@ -622,26 +645,29 @@ export class PFProjectBrowser extends BaseComponent { private confirmDelete = async () => { const project = this.deleteTarget; - if (!project) return; + if (!project || this.isDeletingProject) return; this.errorMessage = ''; - this.deleteTarget = null; + this.deleteErrorMessage = ''; + this.isDeletingProject = true; try { const activeContext = getActiveProjectContext(); const deletedOpenProject = project.id === activeContext.project.id.value; - const projectContext = - workspaceStore.getProjectItem(project.id)?.context ?? activeContext; - await projectLibrary.deleteProject(project.id, { context: projectContext }); + const result = await workspaceStore.deleteProject(project.id); await this.loadProjects(); + this.deleteTarget = null; - if (deletedOpenProject) { + if (deletedOpenProject && result.installedReplacement) { this.dispatchEvent( new CustomEvent('current-project-deleted', { bubbles: true, composed: true }) ); } } catch (error) { - this.errorMessage = this.errorText(error, 'Failed to delete project.'); + const message = this.errorText(error, 'Failed to delete project.'); + this.deleteErrorMessage = message; + } finally { + this.isDeletingProject = false; } }; @@ -742,11 +768,18 @@ export class PFProjectBrowser extends BaseComponent { }; private cancelDelete = () => { + if (this.isDeletingProject) return; this.closeNativeDialog(this.deleteDialog); }; + private handleDeleteDialogCancel = (event: Event) => { + if (this.isDeletingProject) { + event.preventDefault(); + } + }; + private handleDeleteBackdropClick = (event: MouseEvent) => { - if (event.target !== event.currentTarget) return; + if (this.isDeletingProject || event.target !== event.currentTarget) return; const dialog = event.currentTarget as HTMLDialogElement; if (!this.clickIsInsideDialog(dialog, event)) { @@ -755,8 +788,9 @@ export class PFProjectBrowser extends BaseComponent { }; private handleDeleteDialogClose = () => { - if (!this.isDisconnecting) { + if (!this.isDisconnecting && !this.isDeletingProject) { this.deleteTarget = null; + this.deleteErrorMessage = ''; } }; diff --git a/src/components/app/pf-pwa-update-toast.ts b/src/components/app/pf-pwa-update-toast.ts index f5fad4f..ecfa0e8 100644 --- a/src/components/app/pf-pwa-update-toast.ts +++ b/src/components/app/pf-pwa-update-toast.ts @@ -2,8 +2,9 @@ import { css, html, nothing } from 'lit'; import { customElement } from 'lit/decorators.js'; import { BaseComponent } from '../../core/base-component'; import { autoSaveService } from '../../services/auto-save'; -import { getActiveProjectContext } from '../../stores/project-context'; import { pwaStore } from '../../stores/pwa'; +import { workspaceStore } from '../../stores/workspace'; +import type { ProjectContext } from '../../stores/project-context'; @customElement('pf-pwa-update-toast') export class PFPwaUpdateToast extends BaseComponent { @@ -108,10 +109,27 @@ export class PFPwaUpdateToast extends BaseComponent { }; private restart = () => { - const context = getActiveProjectContext(); - void pwaStore.restartWithUpdate(() => autoSaveService.saveNow(context)); + void pwaStore.restartWithUpdate(() => this.saveOpenProjectsUntilClean()); }; + private async saveOpenProjectsUntilClean() { + while (true) { + const openContexts = this.getOpenContexts(); + await Promise.all(openContexts.map((context) => autoSaveService.saveUntilClean(context))); + + const currentContexts = this.getOpenContexts(); + const membershipChanged = currentContexts.length !== openContexts.length + || currentContexts.some((context) => !openContexts.includes(context)); + if (membershipChanged) continue; + if (currentContexts.some((context) => autoSaveService.isDirty(context))) continue; + return; + } + } + + private getOpenContexts(): ProjectContext[] { + return workspaceStore.items.value.map((item) => item.context); + } + render() { if (!pwaStore.updateAvailable.value) return nothing; diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 92e69ee..1fba7f9 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -366,6 +366,8 @@ export class PixelForgeApp extends BaseComponent { @state() private isResizingSidebar = false; @state() private hasLibraryProject = false; @state() private projectSelectionRequired = false; + @state() private isDeletingCurrentProject = false; + @state() private deleteCurrentProjectError: string | null = null; @state() private warningMessage: string | null = null; @state() private fileImportMessage: string | null = null; @@ -518,10 +520,12 @@ export class PixelForgeApp extends BaseComponent { }; private handleProjectFileDrop = (event: DragEvent) => { + if (!hasDraggedFiles(event.dataTransfer)) return; + + event.preventDefault(); const files = supportedProjectFiles(getDataTransferFiles(event.dataTransfer)); if (files.length === 0) return; - event.preventDefault(); void importProjectFiles(files).catch((error) => { log.error("Failed to import dropped project files:", error); }); @@ -596,12 +600,16 @@ export class PixelForgeApp extends BaseComponent { }; private handleDeleteCurrentProject = () => { + if (this.isDeletingCurrentProject) return; + this.deleteCurrentProjectContext = getActiveProjectContext(); + this.deleteCurrentProjectError = null; this.showDeleteCurrentDialog = true; }; private dismissDeleteCurrentProject = () => { this.deleteCurrentProjectContext = null; + this.deleteCurrentProjectError = null; this.showDeleteCurrentDialog = false; }; @@ -642,16 +650,23 @@ export class PixelForgeApp extends BaseComponent { }; private confirmDeleteCurrentProject = async () => { - const context = - this.deleteCurrentProjectContext ?? getActiveProjectContext(); - this.dismissDeleteCurrentProject(); + const context = this.deleteCurrentProjectContext; + if (!context || this.isDeletingCurrentProject) return; + + this.isDeletingCurrentProject = true; + this.deleteCurrentProjectError = null; try { - await projectLibrary.deleteProject(context.project.id.value, { context }); - this.handleCurrentProjectDeleted(); + const result = await workspaceStore.deleteProject(context.project.id.value); + this.dismissDeleteCurrentProject(); + if (result.installedReplacement) { + this.handleCurrentProjectDeleted(); + } } catch (error) { log.error("Failed to delete project:", error); - this.showWarning("Could not delete project"); + this.deleteCurrentProjectError = "Could not delete project. Your project is still available."; + } finally { + this.isDeletingCurrentProject = false; } }; @@ -662,8 +677,7 @@ export class PixelForgeApp extends BaseComponent { } private handleBeforeUnload = (e: BeforeUnloadEvent) => { - // Check if there are unsaved changes (history has items) - const hasUnsavedChanges = historyStore.undoStack.value.length > 0; + const hasUnsavedChanges = autoSaveService.dirtyContexts.value.size > 0; if (hasUnsavedChanges) { // Modern browsers ignore custom messages, but the prompt will still show e.preventDefault(); @@ -989,6 +1003,43 @@ export class PixelForgeApp extends BaseComponent { return project.name.value; } + private renderDeleteCurrentProjectDialog(projectName: string) { + return html` + + Delete Current Project +

Delete "${projectName}" from this browser?

+ ${this.deleteCurrentProjectError + ? html`

${this.deleteCurrentProjectError}

` + : ""} +
+ + +
+
+ `; + } + render() { // Access panel states signal to ensure reactive updates when timeline visibility changes const isTimelineCollapsed = @@ -1103,30 +1154,7 @@ export class PixelForgeApp extends BaseComponent { ` : ""} - - Delete Current Project -

Delete "${deleteProjectName}" from this browser?

-
- - -
-
+ ${this.renderDeleteCurrentProjectDialog(deleteProjectName)} diff --git a/src/components/ui/pf-dialog.ts b/src/components/ui/pf-dialog.ts index d44ac00..ea3c508 100644 --- a/src/components/ui/pf-dialog.ts +++ b/src/components/ui/pf-dialog.ts @@ -1,4 +1,4 @@ -import { html, css, nothing } from "lit"; +import { html, css, nothing, type PropertyValues } from "lit"; import { customElement, property } from "lit/decorators.js"; import { BaseComponent } from "../../core/base-component"; import { scrollbarStyles } from "../../styles/scrollbar-styles"; @@ -27,6 +27,8 @@ import { scrollbarStyles } from "../../styles/scrollbar-styles"; */ @customElement("pf-dialog") export class PFDialog extends BaseComponent { + private previouslyFocused: HTMLElement | null = null; + static styles = css` ${scrollbarStyles} @@ -89,6 +91,11 @@ export class PFDialog extends BaseComponent { background-color: var(--pf-color-bg-hover, #2a2a2a); } + .close-btn:focus-visible { + outline: 1px solid var(--pf-color-accent); + outline-offset: 2px; + } + .content { display: flex; flex-direction: column; @@ -162,17 +169,42 @@ export class PFDialog extends BaseComponent { } disconnectedCallback() { + this.restoreFocus(); super.disconnectedCallback(); document.removeEventListener("keydown", this.handleKeyDown); } + protected willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has("open") && this.open) { + this.previouslyFocused = this.deepestActiveElement(document); + } + } + private handleKeyDown = (e: KeyboardEvent) => { - if (this.open && this.closeOnEscape && e.key === "Escape") { + if (!this.open) return; + + if (this.closeOnEscape && e.key === "Escape") { e.preventDefault(); this.close(); + return; + } + + if (e.key === "Tab") { + this.containFocus(e); } }; + protected updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + if (!changedProperties.has("open")) return; + + if (this.open) { + this.focusDialog(); + } else { + this.restoreFocus(); + } + } + private handleBackdropClick = (e: MouseEvent) => { if (this.closeOnBackdrop && e.target === e.currentTarget) { this.close(); @@ -187,6 +219,7 @@ export class PFDialog extends BaseComponent { /** Close the dialog and dispatch pf-close event */ close() { this.open = false; + this.restoreFocus(); this.dispatchEvent( new CustomEvent("pf-close", { bubbles: true, @@ -195,6 +228,74 @@ export class PFDialog extends BaseComponent { ); } + private focusableElements(): HTMLElement[] { + const closeButton = this.shadowRoot?.querySelector(".close-btn"); + const slottedElements = Array.from( + this.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])' + ) + ); + return closeButton ? [closeButton, ...slottedElements] : slottedElements; + } + + private containFocus(event: KeyboardEvent) { + const focusable = this.focusableElements(); + if (focusable.length === 0) { + event.preventDefault(); + this.dialogSurface()?.focus(); + return; + } + + const activeElement = this.shadowRoot?.activeElement ?? document.activeElement; + const activeIndex = focusable.indexOf(activeElement as HTMLElement); + const isLeavingStart = event.shiftKey && activeIndex <= 0; + const isLeavingEnd = !event.shiftKey && activeIndex === focusable.length - 1; + if (!isLeavingStart && !isLeavingEnd) return; + + event.preventDefault(); + (isLeavingStart ? focusable.at(-1) : focusable[0])?.focus(); + } + + private focusDialog() { + (this.focusableElements()[0] ?? this.dialogSurface())?.focus(); + } + + private ensureDialogFocus = () => { + queueMicrotask(() => { + if (!this.open) return; + + const activeElement = this.deepestActiveElement(document); + const focusIsInside = activeElement + ? this.contains(activeElement) || Boolean(this.shadowRoot?.contains(activeElement)) + : false; + if (focusIsInside && this.focusableElements().length > 0) return; + + this.dialogSurface()?.focus(); + }); + }; + + private dialogSurface() { + return this.shadowRoot?.querySelector(".dialog"); + } + + private deepestActiveElement(root: Document | ShadowRoot): HTMLElement | null { + let activeElement = root.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + return activeElement instanceof HTMLElement ? activeElement : null; + } + + private restoreFocus() { + if (!this.previouslyFocused?.isConnected) { + this.previouslyFocused = null; + return; + } + + this.previouslyFocused.focus(); + this.previouslyFocused = null; + } + render() { if (!this.open) return nothing; @@ -202,23 +303,32 @@ export class PFDialog extends BaseComponent {
diff --git a/src/services/auto-save.ts b/src/services/auto-save.ts index 4b9876f..c9a3236 100644 --- a/src/services/auto-save.ts +++ b/src/services/auto-save.ts @@ -31,26 +31,31 @@ class AutoSaveService { private contextState = new Map(); private dirtyContextSet = new Set(); + private blockedContexts = new WeakSet(); private hasDocumentListeners = false; /** Start observing history changes. Idempotent. */ start(context: ProjectContext = defaultProjectContext) { const state = this.getState(context); - if (state.dispose) return; - - let firstRun = true; - state.dispose = effect(() => { - // Subscribe to history changes (execute/undo/redo/clear all bump this) - context.history.version.get(); - if (firstRun) { - // Don't schedule a save just for booting up - firstRun = false; - return; - } - this.markDirty(context); - }); + this.blockedContexts.delete(context); + if (!state.dispose) { + let firstRun = true; + state.dispose = effect(() => { + // Subscribe to history changes (execute/undo/redo/clear all bump this) + context.history.version.get(); + if (firstRun) { + // Don't schedule a save just for booting up + firstRun = false; + return; + } + this.markDirty(context); + }); + } this.attachDocumentListeners(); + if (state.isDirty && !state.saveTimeout) { + this.scheduleSave(context, state); + } } /** Stop observing and drop pending saves. */ @@ -76,17 +81,14 @@ class AutoSaveService { state.changeRevision++; this.updateDirtyState(context, state); - this.clearSaveTimeout(state); - state.saveTimeout = setTimeout(() => { - state.saveTimeout = null; - void this.performSave(context); - }, AUTO_SAVE_DEBOUNCE_MS); + if (this.blockedContexts.has(context)) return; + this.scheduleSave(context, state); } /** Save immediately if there are unsaved changes (blur/tab-hidden). */ flushIfDirty = () => { for (const [context, state] of this.contextState) { - if (!state.isDirty) continue; + if (!state.isDirty || this.blockedContexts.has(context)) continue; this.clearSaveTimeout(state); void this.performSave(context); } @@ -94,27 +96,33 @@ class AutoSaveService { /** Save the open project now, even if no edit debounce is pending. */ async saveNow(context: ProjectContext = defaultProjectContext) { + this.assertSaveAllowed(context); const state = this.getState(context); this.clearSaveTimeout(state); await this.performSave(context, { force: true, rethrow: true }); } - isDirty(context: ProjectContext = defaultProjectContext): boolean { - return this.dirtyContexts.value.has(context); + /** Save again when an edit lands while the previous write is in flight. */ + async saveUntilClean(context: ProjectContext = defaultProjectContext) { + do { + await this.saveNow(context); + } while (this.isDirty(context)); } - /** - * Drop queued work and wait for a write that has already started. - * Deletion can then run after every older write that could recreate the record. - */ - async clearPendingSave(context: ProjectContext = defaultProjectContext): Promise { - const state = this.getState(context); + /** Block writes while continuing to record edits, then wait for a started write. */ + async pause(context: ProjectContext = defaultProjectContext): Promise { + this.blockedContexts.add(context); + const state = this.contextState.get(context); + if (!state) return; + this.clearSaveTimeout(state); - state.persistedRevision = state.changeRevision; - this.updateDirtyState(context, state); await state.saveQueue; } + isDirty(context: ProjectContext = defaultProjectContext): boolean { + return this.dirtyContexts.value.has(context); + } + /** Run project load/reset work without treating reset signals as user edits. */ async runWithoutSaving( work: () => Promise, @@ -141,6 +149,7 @@ class AutoSaveService { context: ProjectContext, options: { force?: boolean; rethrow?: boolean } = {} ): Promise { + this.assertSaveAllowed(context); const state = this.getState(context); const hadUnsavedChanges = state.isDirty; if (!state.isDirty && !options.force) return; @@ -178,6 +187,7 @@ class AutoSaveService { try { const projectData = await context.project.saveProject(); const thumbnail = await createThumbnailSafely(context); + this.assertSaveAllowed(context); await projectRepository.save(projectId, projectData, { thumbnail, }); @@ -256,6 +266,20 @@ class AutoSaveService { state.saveTimeout = null; } + private scheduleSave(context: ProjectContext, state: AutoSaveContextState) { + this.clearSaveTimeout(state); + state.saveTimeout = setTimeout(() => { + state.saveTimeout = null; + void this.performSave(context); + }, AUTO_SAVE_DEBOUNCE_MS); + } + + private assertSaveAllowed(context: ProjectContext) { + if (this.blockedContexts.has(context)) { + throw new Error('Auto-save is paused for this project.'); + } + } + private setDirtyContext(context: ProjectContext, isDirty: boolean) { if (isDirty) { if (this.dirtyContextSet.has(context)) return; diff --git a/src/services/project-library.ts b/src/services/project-library.ts index 639b0f5..87bf3c0 100644 --- a/src/services/project-library.ts +++ b/src/services/project-library.ts @@ -35,10 +35,6 @@ export type OpenProjectSettings = { context?: ProjectContext; }; -export type DeleteProjectSettings = { - context?: ProjectContext; -}; - export class ProjectLibraryService { private readonly repository: ProjectRepository; @@ -123,12 +119,8 @@ export class ProjectLibraryService { }); } - async deleteProject(id: string, settings: DeleteProjectSettings = {}): Promise { - const context = settings.context ?? defaultProjectContext; - if (id === context.project.id.value) { - await autoSaveService.clearPendingSave(context); - } - + // fallow-ignore-next-line unused-class-member -- Called through workspace and import dependency interfaces. + async deleteProject(id: string): Promise { await this.repository.delete(id); } diff --git a/src/services/pwa-file-handling.ts b/src/services/pwa-file-handling.ts index 5df7dcf..3915746 100644 --- a/src/services/pwa-file-handling.ts +++ b/src/services/pwa-file-handling.ts @@ -54,7 +54,7 @@ export class PwaFileHandlingService { files.push(await handle.getFile()); } catch (error) { unreadableFiles.push(handle.name); - log.warn(`Could not read ${handle.name} from the operating system:`, error); + log.warn('Could not read a launched file from the operating system:', error); } } diff --git a/src/services/pwa-registration.ts b/src/services/pwa-registration.ts index 4976c0e..57fa500 100644 --- a/src/services/pwa-registration.ts +++ b/src/services/pwa-registration.ts @@ -8,6 +8,7 @@ export function registerPwa() { const updateServiceWorker = registerSW({ immediate: true, onNeedRefresh: () => pwaStore.showUpdate(), + onNeedReload: () => void pwaStore.handleUpdateControlling(), onRegisterError: (error) => log.error('PWA registration failed:', error), }); pwaStore.setUpdateHandler(updateServiceWorker); diff --git a/src/services/selection-transform-commit.ts b/src/services/selection-transform-commit.ts index 4e22d76..90c1bf7 100644 --- a/src/services/selection-transform-commit.ts +++ b/src/services/selection-transform-commit.ts @@ -12,8 +12,18 @@ export function commitSelectionTransform(context: SelectionTransformContext): vo const transformState = selection.getTransformState(); if (!transformState) return; - const { imageData, originalBounds, currentBounds, currentOffset, rotation, scale, shape, mask } = - transformState; + const { + imageData, + originalBounds, + currentBounds, + currentOffset, + rotation, + scale, + shape, + mask, + targetLayerId, + targetFrameId, + } = transformState; const hasRotation = rotation !== 0; const hasScale = scale.x !== 1 || scale.y !== 1; const hasMovement = currentOffset.x !== 0 || currentOffset.y !== 0; @@ -23,10 +33,11 @@ export function commitSelectionTransform(context: SelectionTransformContext): vo return; } - const activeLayerId = layers.activeLayerId.value; - const activeLayer = layers.layers.value.find((layer) => layer.id === activeLayerId); - if (!activeLayer?.canvas) { - log.error('Active layer canvas not found'); + const layerId = targetLayerId ?? layers.activeLayerId.value; + const frameId = targetFrameId ?? context.animation.currentFrameId.value; + const targetLayer = layers.layers.value.find((layer) => layer.id === layerId); + if (!targetLayer?.canvas || !frameId) { + log.error('Selection transform target not found'); selection.cancelTransform(); return; } @@ -37,9 +48,8 @@ export function commitSelectionTransform(context: SelectionTransformContext): vo return; } - const frameId = context.animation.currentFrameId.value; const command = new TransformSelectionCommand( - activeLayer.id, + targetLayer.id, frameId, imageData, originalBounds, diff --git a/src/stores/pwa.ts b/src/stores/pwa.ts index 195c50a..3bf45ae 100644 --- a/src/stores/pwa.ts +++ b/src/stores/pwa.ts @@ -10,6 +10,7 @@ export interface BeforeInstallPromptEvent extends Event { type StandaloneNavigator = Navigator & { standalone?: boolean }; type UpdateServiceWorker = (reloadPage?: boolean) => Promise; +type SaveOpenProjects = () => Promise; export class PwaStore { readonly installAvailable = signal(false); @@ -20,6 +21,15 @@ export class PwaStore { private installPrompt: BeforeInstallPromptEvent | null = null; private listeningForInstall = false; private updateServiceWorker: UpdateServiceWorker | null = null; + private updateActivated = false; + private saveOpenProjects: SaveOpenProjects | null = null; + private initialSave: Promise | null = null; + private finalizingUpdate: Promise | null = null; + private readonly reloadPage: () => void; + + constructor(reloadPage = () => window.location.reload()) { + this.reloadPage = reloadPage; + } start() { if (this.listeningForInstall) return; @@ -38,6 +48,10 @@ export class PwaStore { this.listeningForInstall = false; this.clearInstallPrompt(); this.updateServiceWorker = null; + this.updateActivated = false; + this.saveOpenProjects = null; + this.initialSave = null; + this.finalizingUpdate = null; this.updateAvailable.value = false; this.applyingUpdate.value = false; this.updateError.value = null; @@ -58,6 +72,7 @@ export class PwaStore { } showUpdate() { + this.updateActivated = false; this.updateError.value = null; this.updateAvailable.value = true; } @@ -67,36 +82,98 @@ export class PwaStore { this.updateError.value = null; } - async restartWithUpdate(saveCurrentProject: () => Promise) { + async restartWithUpdate(saveOpenProjects: SaveOpenProjects) { if (!this.updateAvailable.value || !this.updateServiceWorker || this.applyingUpdate.value) { return false; } this.applyingUpdate.value = true; this.updateError.value = null; + this.saveOpenProjects = saveOpenProjects; + const initialSave = saveOpenProjects(); + this.initialSave = initialSave; try { - await saveCurrentProject(); + await initialSave; } catch { - this.updateError.value = - 'Pixel Forge could not save before restarting. Your current session stayed open.'; - this.applyingUpdate.value = false; + this.showSaveFailure(); return false; + } finally { + if (this.initialSave === initialSave) { + this.initialSave = null; + } + } + + if (this.updateActivated) { + return this.handleUpdateControlling(); } try { - this.updateAvailable.value = false; - await this.updateServiceWorker(true); + // vite-plugin-pwa ignores this argument in current versions. Passing + // false documents that its controlling callback, not the updater, + // owns the eventual reload. + await this.updateServiceWorker(false); return true; } catch { - this.updateAvailable.value = true; + this.saveOpenProjects = null; + this.applyingUpdate.value = false; this.updateError.value = 'The update could not be started. Your current session stayed open.'; return false; - } finally { + } + } + + async handleUpdateControlling(): Promise { + this.updateActivated = true; + this.updateAvailable.value = true; + + if (this.finalizingUpdate) return this.finalizingUpdate; + + const saveOpenProjects = this.saveOpenProjects; + if (!saveOpenProjects) { this.applyingUpdate.value = false; + return false; + } + + const finalizingUpdate = this.finishActivatedUpdate(saveOpenProjects); + this.finalizingUpdate = finalizingUpdate; + try { + return await finalizingUpdate; + } finally { + if (this.finalizingUpdate === finalizingUpdate) { + this.finalizingUpdate = null; + } } } + private async finishActivatedUpdate(saveOpenProjects: SaveOpenProjects): Promise { + try { + await this.initialSave; + await saveOpenProjects(); + } catch { + this.showSaveFailure(); + return false; + } + + return this.reloadAfterSuccessfulSave(); + } + + private reloadAfterSuccessfulSave(): true { + this.updateAvailable.value = false; + this.updateError.value = null; + this.applyingUpdate.value = false; + this.saveOpenProjects = null; + this.reloadPage(); + return true; + } + + private showSaveFailure() { + this.updateAvailable.value = true; + this.applyingUpdate.value = false; + this.saveOpenProjects = null; + this.updateError.value = + 'Pixel Forge could not save before restarting. Your current session stayed open.'; + } + private handleBeforeInstallPrompt = (event: Event) => { event.preventDefault(); diff --git a/src/stores/selection/store.ts b/src/stores/selection/store.ts index 1bbcd85..1a7f567 100644 --- a/src/stores/selection/store.ts +++ b/src/stores/selection/store.ts @@ -351,7 +351,9 @@ class SelectionStore { imageData: ImageData, bounds: Rect, shape: SelectionShape, - mask?: Uint8Array + mask?: Uint8Array, + targetLayerId: string | null = null, + targetFrameId: string | null = null ) { this.state.value = { type: 'transforming', @@ -364,6 +366,8 @@ class SelectionStore { previewData: null, shape, mask, + targetLayerId, + targetFrameId, }; } @@ -644,6 +648,8 @@ class SelectionStore { scale: { x: number; y: number }; shape: SelectionShape; mask?: Uint8Array; + targetLayerId: string | null; + targetFrameId: string | null; } | null { const s = this.state.value; if (s.type !== 'transforming') return null; @@ -656,6 +662,8 @@ class SelectionStore { scale: s.scale, shape: s.shape, mask: s.mask, + targetLayerId: s.targetLayerId, + targetFrameId: s.targetFrameId, }; } diff --git a/src/stores/workspace.ts b/src/stores/workspace.ts index ae03225..9a61314 100644 --- a/src/stores/workspace.ts +++ b/src/stores/workspace.ts @@ -43,11 +43,11 @@ type WorkspaceLimitFailure = { type WorkspaceProjectLibrary = Pick< ProjectLibraryService, - "openProject" | "createProject" | "createProjectFromFile" + "openProject" | "createProject" | "createProjectFromFile" | "deleteProject" >; type WorkspaceAutoSave = Pick< typeof autoSaveService, - "saveNow" | "start" | "stop" + "pause" | "saveNow" | "start" | "stop" >; type WorkspaceStatePersistence = Pick; @@ -88,6 +88,11 @@ export type WorkspaceProjectResult = } | WorkspaceLimitFailure; +export interface WorkspaceDeleteResult { + activeItem: WorkspaceItem; + installedReplacement: boolean; +} + interface WorkspaceStoreOptions { initialContext?: ProjectContext; initialItemId?: string; @@ -117,6 +122,7 @@ export class WorkspaceStore { private readonly projectLibrary: WorkspaceProjectLibrary; private readonly autoSave: WorkspaceAutoSave; private readonly workspaceState: WorkspaceStatePersistence; + private readonly projectMutations = new Map>(); private isRestoringWorkspace = false; constructor(options: WorkspaceStoreOptions = {}) { @@ -164,6 +170,15 @@ export class WorkspaceStore { async openProject( projectId: string, options: WorkspaceProjectOptions = {}, + ): Promise { + return this.runProjectMutation(projectId, () => + this.openProjectWithoutMutationGate(projectId, options), + ); + } + + private async openProjectWithoutMutationGate( + projectId: string, + options: WorkspaceProjectOptions, ): Promise { const existingItem = this.getProjectItem(projectId); if (existingItem) { @@ -347,6 +362,54 @@ export class WorkspaceStore { return this.close(itemId); } + async deleteProject(projectId: string): Promise { + return this.runProjectMutation(projectId, () => + this.deleteProjectWithoutMutationGate(projectId), + ); + } + + private async deleteProjectWithoutMutationGate( + projectId: string, + ): Promise { + const item = this.getProjectItem(projectId); + if (!item) { + await this.projectLibrary.deleteProject(projectId); + return { + activeItem: this.activeItem, + installedReplacement: false, + }; + } + + await this.autoSave.pause(item.context); + + try { + await this.projectLibrary.deleteProject(projectId); + return this.commitProjectDeletion(item); + } catch (error) { + this.resumeAutoSaveIfOpen(item); + throw error; + } + } + + private runProjectMutation( + projectId: string, + mutate: () => Promise, + ): Promise { + const previousMutation = this.projectMutations.get(projectId) ?? Promise.resolve(); + const mutation = previousMutation.catch(() => {}).then(mutate); + const settledMutation = mutation.then( + () => {}, + () => {}, + ); + this.projectMutations.set(projectId, settledMutation); + void settledMutation.then(() => { + if (this.projectMutations.get(projectId) === settledMutation) { + this.projectMutations.delete(projectId); + } + }); + return mutation; + } + async restoreWorkspace(state: WorkspaceState): Promise { const restorePlan = this.createRestorePlan(state); if (restorePlan.loadProjectIds.length === 0) return false; @@ -388,6 +451,62 @@ export class WorkspaceStore { return this.items.value.find((item) => item.id === itemId); } + private replaceLastDeletedItem(deletedItem: WorkspaceItem): WorkspaceItem { + const deletedIndex = this.items.value.findIndex( + (item) => item.id === deletedItem.id && item.context === deletedItem.context, + ); + if (deletedIndex === -1) { + throw new Error("Deleted workspace item was not found."); + } + + const replacementContext = createProjectContext(); + const replacementItem = { + id: createWorkspaceItemId(), + context: replacementContext, + }; + const nextItems = [...this.items.value]; + nextItems[deletedIndex] = replacementItem; + this.items.value = nextItems; + this.activeItemId.value = replacementItem.id; + setActiveProjectContext(replacementContext); + this.autoSave.stop(deletedItem.context); + deletedItem.context.dispose(); + this.autoSave.start(replacementContext); + this.persistWorkspaceState(); + return replacementItem; + } + + private commitProjectDeletion(item: WorkspaceItem): WorkspaceDeleteResult { + if (this.findItem(item.id)?.context !== item.context) { + return { + activeItem: this.activeItem, + installedReplacement: false, + }; + } + + if (this.items.value.length === 1) { + return { + activeItem: this.replaceLastDeletedItem(item), + installedReplacement: true, + }; + } + + const closeResult = this.close(item.id); + if (!closeResult.ok) { + throw new Error(closeResult.message); + } + return { + activeItem: closeResult.activeItem, + installedReplacement: false, + }; + } + + private resumeAutoSaveIfOpen(item: WorkspaceItem) { + if (this.findItem(item.id)?.context === item.context) { + this.autoSave.start(item.context); + } + } + getProjectItem(projectId: string): WorkspaceItem | undefined { return this.items.value.find((item) => item.context.project.id.value === projectId); } diff --git a/src/types/selection.ts b/src/types/selection.ts index 231a65f..21f2fc0 100644 --- a/src/types/selection.ts +++ b/src/types/selection.ts @@ -50,4 +50,6 @@ export type SelectionState = previewData: ImageData | null; // Nearest-neighbor preview (null until first transform) shape: SelectionShape; mask?: Uint8Array; // Original mask (for freeform) + targetLayerId: string | null; + targetFrameId: string | null; }; diff --git a/tests/components/app/pf-project-browser.test.ts b/tests/components/app/pf-project-browser.test.ts index 046bb9e..2047b23 100644 --- a/tests/components/app/pf-project-browser.test.ts +++ b/tests/components/app/pf-project-browser.test.ts @@ -19,6 +19,7 @@ const activeProjectContextMock = vi.hoisted(() => ({ })); const workspaceStoreMock = vi.hoisted(() => ({ + deleteProject: vi.fn(), openProject: vi.fn(), getProjectItem: vi.fn(), })); @@ -193,6 +194,10 @@ describe('pf-project-browser', () => { item: {}, projectId: 'open-project', }); + workspaceStoreMock.deleteProject.mockResolvedValue({ + activeItem: { context: activeProjectContextMock }, + installedReplacement: false, + }); workspaceStoreMock.getProjectItem.mockImplementation((projectId: string) => projectId === 'open-project' ? { context: activeProjectContextMock } : undefined ); @@ -460,7 +465,7 @@ describe('pf-project-browser', () => { buttonWithText(element.shadowRoot!, 'Delete')?.click(); await element.updateComplete; - expect(projectLibraryMock.deleteProject).not.toHaveBeenCalled(); + expect(workspaceStoreMock.deleteProject).not.toHaveBeenCalled(); expect( element.shadowRoot?.querySelector('.delete-dialog')?.getAttribute('data-scrollbar') ).toBe('vertical'); @@ -468,12 +473,91 @@ describe('pf-project-browser', () => { confirmDeleteButton(element.shadowRoot!)?.click(); await settle(element); - expect(projectLibraryMock.deleteProject).toHaveBeenCalledWith('open-project', { - context: activeProjectContextMock, - }); + expect(workspaceStoreMock.deleteProject).toHaveBeenCalledWith('open-project'); }); - it('clears pending saves through the targeted inactive open context when deleting', async () => { + it('guards project deletion while the request is pending', async () => { + let finishDelete!: () => void; + workspaceStoreMock.deleteProject.mockImplementationOnce( + () => + new Promise((resolve) => { + finishDelete = () => + resolve({ + activeItem: { context: activeProjectContextMock }, + installedReplacement: false, + }); + }) + ); + const element = await createBrowser(); + + buttonWithText(element.shadowRoot!, 'Delete')?.click(); + await element.updateComplete; + const deleteButton = confirmDeleteButton(element.shadowRoot!); + deleteButton?.click(); + deleteButton?.click(); + await element.updateComplete; + + const deleteDialog = element.shadowRoot!.querySelector('.delete-dialog'); + expect(workspaceStoreMock.deleteProject).toHaveBeenCalledOnce(); + expect(deleteDialog?.open).toBe(true); + expect(deleteDialog?.getAttribute('closedby')).toBe('none'); + expect(confirmDeleteButton(element.shadowRoot!)?.disabled).toBe(true); + expect(confirmDeleteButton(element.shadowRoot!)?.textContent).toContain('Deleting...'); + + requestNativeCancel(deleteDialog); + clickNativeBackdrop(deleteDialog); + expect(deleteDialog?.open).toBe(true); + + finishDelete(); + await settle(element); + + expect(deleteDialog?.open).toBe(false); + }); + + it('keeps a delete failure visible and available to retry', async () => { + workspaceStoreMock.deleteProject.mockRejectedValueOnce(new Error('storage failed')); + const element = await createBrowser(); + + buttonWithText(element.shadowRoot!, 'Delete')?.click(); + await element.updateComplete; + confirmDeleteButton(element.shadowRoot!)?.click(); + await settle(element); + + const deleteDialog = element.shadowRoot!.querySelector('.delete-dialog'); + expect(deleteDialog?.open).toBe(true); + expect(deleteDialog?.querySelector('[role="alert"]')?.textContent).toContain( + 'storage failed' + ); + expect(confirmDeleteButton(element.shadowRoot!)?.disabled).toBe(false); + }); + + it('clears a stale delete failure before opening a new confirmation', async () => { + workspaceStoreMock.deleteProject.mockRejectedValueOnce(new Error('first delete failed')); + const element = await createBrowser(); + + buttonWithText(element.shadowRoot!, 'Delete')?.click(); + await element.updateComplete; + confirmDeleteButton(element.shadowRoot!)?.click(); + await settle(element); + expect(element.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain( + 'first delete failed' + ); + + const deleteDialog = element.shadowRoot!.querySelector('.delete-dialog'); + requestNativeCancel(deleteDialog); + await settle(element); + expect( + element.shadowRoot?.querySelector('.browser-dialog [role="alert"]') + ).toBeNull(); + + projectAction(element.shadowRoot!, 'Second Project', 'Delete')?.click(); + await settle(element); + + expect(element.shadowRoot?.querySelector('[role="alert"]')).toBeNull(); + expect(deleteDialog?.open).toBe(true); + }); + + it('routes inactive open project deletion through the workspace lifecycle', async () => { const inactiveProjectContext = { project: { id: { value: 'second-project' }, @@ -492,12 +576,14 @@ describe('pf-project-browser', () => { confirmDeleteButton(element.shadowRoot!)?.click(); await settle(element); - expect(projectLibraryMock.deleteProject).toHaveBeenCalledWith('second-project', { - context: inactiveProjectContext, - }); + expect(workspaceStoreMock.deleteProject).toHaveBeenCalledWith('second-project'); }); it('emits current-project-deleted when the open project is deleted', async () => { + workspaceStoreMock.deleteProject.mockResolvedValue({ + activeItem: { context: {} }, + installedReplacement: true, + }); const element = await createBrowser(); let deletedCurrent = false; element.addEventListener('current-project-deleted', () => { diff --git a/tests/components/app/pf-pwa-update-toast.test.ts b/tests/components/app/pf-pwa-update-toast.test.ts index 20a1125..f7b5ec1 100644 --- a/tests/components/app/pf-pwa-update-toast.test.ts +++ b/tests/components/app/pf-pwa-update-toast.test.ts @@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const autoSaveServiceMock = vi.hoisted(() => ({ saveNow: vi.fn(), + saveUntilClean: vi.fn(), + isDirty: vi.fn(), +})); + +const workspaceStoreMock = vi.hoisted(() => ({ + items: { value: [] as Array<{ context: ProjectContext }> }, })); vi.mock('../../../src/services/auto-save', () => ({ autoSaveService: autoSaveServiceMock, })); +vi.mock('../../../src/stores/workspace', () => ({ + workspaceStore: workspaceStoreMock, +})); + import '../../../src/components/app/pf-pwa-update-toast'; import type { PFPwaUpdateToast } from '../../../src/components/app/pf-pwa-update-toast'; import { pwaStore } from '../../../src/stores/pwa'; import { createProjectContext, + defaultProjectContext, restoreDefaultProjectContext, setActiveProjectContext, type ProjectContext, @@ -38,6 +49,9 @@ describe('pf-pwa-update-toast', () => { document.body.replaceChildren(); vi.clearAllMocks(); autoSaveServiceMock.saveNow.mockResolvedValue(undefined); + autoSaveServiceMock.saveUntilClean.mockResolvedValue(undefined); + autoSaveServiceMock.isDirty.mockReturnValue(false); + workspaceStoreMock.items.value = [{ context: defaultProjectContext }]; pwaStore.stop(); }); @@ -86,18 +100,19 @@ describe('pf-pwa-update-toast', () => { element.shadowRoot?.querySelector('.restart')?.click(); await flushUpdate(element); - expect(autoSaveServiceMock.saveNow).toHaveBeenCalledOnce(); - expect(update).toHaveBeenCalledWith(true); - expect(autoSaveServiceMock.saveNow.mock.invocationCallOrder[0]).toBeLessThan( + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith(false); + expect(autoSaveServiceMock.saveUntilClean.mock.invocationCallOrder[0]).toBeLessThan( update.mock.invocationCallOrder[0] ); }); - it('saves the project active when restart is requested', async () => { + it('saves every open project before restart, including inactive projects', async () => { const contextA = createProjectContext(); const contextB = createProjectContext(); createdContexts.push(contextA, contextB); setActiveProjectContext(contextB); + workspaceStoreMock.items.value = [{ context: contextA }, { context: contextB }]; const update = vi.fn().mockResolvedValue(undefined); pwaStore.setUpdateHandler(update); pwaStore.showUpdate(); @@ -107,13 +122,100 @@ describe('pf-pwa-update-toast', () => { setActiveProjectContext(contextA); await flushUpdate(element); - expect(autoSaveServiceMock.saveNow).toHaveBeenCalledWith(contextB); - expect(update).toHaveBeenCalledWith(true); + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledTimes(2); + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledWith(contextA); + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledWith(contextB); + expect(update).toHaveBeenCalledWith(false); + }); + + it('rechecks every open project when one becomes dirty while another is saving', async () => { + const contextA = createProjectContext(); + const contextB = createProjectContext(); + createdContexts.push(contextA, contextB); + workspaceStoreMock.items.value = [{ context: contextA }, { context: contextB }]; + autoSaveServiceMock.isDirty + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false); + const update = vi.fn().mockResolvedValue(undefined); + pwaStore.setUpdateHandler(update); + pwaStore.showUpdate(); + const element = await createToast(); + + element.shadowRoot?.querySelector('.restart')?.click(); + await flushUpdate(element); + + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledTimes(4); + await vi.waitFor(() => expect(update).toHaveBeenCalledWith(false)); + }); + + it('saves a project opened and dirtied while the first save is in flight', async () => { + const contextA = createProjectContext(); + const contextB = createProjectContext(); + createdContexts.push(contextA, contextB); + workspaceStoreMock.items.value = [{ context: contextA }]; + let finishFirstSave!: () => void; + const firstSave = new Promise((resolve) => { + finishFirstSave = resolve; + }); + let savedContextB = false; + autoSaveServiceMock.saveUntilClean.mockImplementation(async (context) => { + if (context === contextA && !savedContextB) { + await firstSave; + } + if (context === contextB) { + savedContextB = true; + } + }); + autoSaveServiceMock.isDirty.mockImplementation( + (context) => context === contextB && !savedContextB + ); + const update = vi.fn().mockResolvedValue(undefined); + pwaStore.setUpdateHandler(update); + pwaStore.showUpdate(); + const element = await createToast(); + + element.shadowRoot?.querySelector('.restart')?.click(); + await vi.waitFor(() => { + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledWith(contextA); + }); + workspaceStoreMock.items.value = [{ context: contextA }, { context: contextB }]; + finishFirstSave(); + + await vi.waitFor(() => expect(update).toHaveBeenCalledWith(false)); + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledWith(contextB); + }); + + it('keeps the session open when an inactive project cannot be saved', async () => { + const activeContext = createProjectContext(); + const inactiveContext = createProjectContext(); + createdContexts.push(activeContext, inactiveContext); + setActiveProjectContext(activeContext); + workspaceStoreMock.items.value = [ + { context: activeContext }, + { context: inactiveContext }, + ]; + autoSaveServiceMock.saveUntilClean.mockImplementation(async (context) => { + if (context === inactiveContext) throw new Error('storage full'); + }); + const update = vi.fn().mockResolvedValue(undefined); + pwaStore.setUpdateHandler(update); + pwaStore.showUpdate(); + const element = await createToast(); + + element.shadowRoot?.querySelector('.restart')?.click(); + await flushUpdate(element); + + expect(autoSaveServiceMock.saveUntilClean).toHaveBeenCalledWith(inactiveContext); + expect(update).not.toHaveBeenCalled(); + expect(element.shadowRoot?.querySelector('.error')?.textContent).toContain( + 'current session stayed open' + ); }); it('explains a save failure and does not restart', async () => { const update = vi.fn().mockResolvedValue(undefined); - autoSaveServiceMock.saveNow.mockRejectedValue(new Error('storage full')); + autoSaveServiceMock.saveUntilClean.mockRejectedValue(new Error('storage full')); pwaStore.setUpdateHandler(update); pwaStore.showUpdate(); const element = await createToast(); diff --git a/tests/components/app/pixel-forge-app.test.ts b/tests/components/app/pixel-forge-app.test.ts index a7b78dd..891b546 100644 --- a/tests/components/app/pixel-forge-app.test.ts +++ b/tests/components/app/pixel-forge-app.test.ts @@ -24,6 +24,7 @@ const workspaceStoreMock = vi.hoisted(() => ({ activeItemId: { value: '' }, activate: vi.fn(), closeProject: vi.fn(), + deleteProject: vi.fn(), restoreWorkspace: vi.fn(), })); @@ -101,6 +102,10 @@ describe('pixel-forge-app project dialogs', () => { closedItem: null, activeItem: null, }); + workspaceStoreMock.deleteProject.mockResolvedValue({ + activeItem: null, + installedReplacement: false, + }); projectFileHandlingMock.importProjectFiles.mockResolvedValue({ outcomes: [], unreadableFiles: [], @@ -148,9 +153,6 @@ describe('pixel-forge-app project dialogs', () => { contextB.project.id.value = 'project-b'; createdContexts.push(contextA, contextB); setActiveProjectContext(contextB); - const deleteProject = vi - .spyOn(projectLibrary, 'deleteProject') - .mockResolvedValue(); const element = document.createElement('pixel-forge-app') as HTMLElement & { updateComplete: Promise; }; @@ -163,14 +165,103 @@ describe('pixel-forge-app project dialogs', () => { ?.querySelector('pf-dialog button.primary') ?.click(); await vi.waitFor(() => { - expect(deleteProject).toHaveBeenCalled(); + expect(workspaceStoreMock.deleteProject).toHaveBeenCalled(); }); - expect(deleteProject).toHaveBeenCalledWith('project-b', { - context: contextB, + expect(workspaceStoreMock.deleteProject).toHaveBeenCalledWith('project-b'); + }); + + it('guards current-project deletion while the request is pending', async () => { + await import('../../../src/components/app/pixel-forge-app'); + const context = createProjectContext(); + context.project.id.value = 'project-to-delete'; + createdContexts.push(context); + setActiveProjectContext(context); + let finishDelete!: () => void; + workspaceStoreMock.deleteProject.mockImplementationOnce( + () => + new Promise((resolve) => { + finishDelete = () => + resolve({ + activeItem: null, + installedReplacement: true, + }); + }) + ); + const element = document.createElement('pixel-forge-app') as HTMLElement & { + updateComplete: Promise; + }; + document.body.append(element); + + window.dispatchEvent(new CustomEvent('delete-current-project')); + await element.updateComplete; + const deleteButton = element.shadowRoot?.querySelector( + 'pf-dialog button.primary' + ); + + deleteButton?.click(); + deleteButton?.click(); + await element.updateComplete; + + const pendingDialog = element.shadowRoot?.querySelector('pf-dialog'); + const pendingDeleteButton = element.shadowRoot?.querySelector( + 'pf-dialog button.primary' + ); + expect(workspaceStoreMock.deleteProject).toHaveBeenCalledOnce(); + expect(pendingDeleteButton?.disabled).toBe(true); + expect(pendingDeleteButton?.textContent).toContain('Deleting...'); + expect(pendingDialog?.closeOnBackdrop).toBe(false); + expect(pendingDialog?.closeOnEscape).toBe(false); + expect(pendingDialog?.showCloseButton).toBe(false); + + finishDelete(); + await vi.waitFor(() => { + expect(pendingDialog?.open).toBe(false); }); }); + it('keeps a failed current-project deletion inline and announced', async () => { + await import('../../../src/components/app/pixel-forge-app'); + const context = createProjectContext(); + context.project.id.value = 'project-delete-failure'; + createdContexts.push(context); + setActiveProjectContext(context); + workspaceStoreMock.deleteProject.mockRejectedValueOnce(new Error('delete failed')); + const element = document.createElement('pixel-forge-app') as HTMLElement & { + updateComplete: Promise; + }; + document.body.append(element); + + window.dispatchEvent(new CustomEvent('delete-current-project')); + await element.updateComplete; + element.shadowRoot?.querySelector('pf-dialog button.primary')?.click(); + + await vi.waitFor(() => { + const alert = element.shadowRoot?.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Could not delete project'); + }); + expect(element.shadowRoot?.querySelector('pf-dialog')?.open).toBe(true); + }); + + it('warns before unload when any autosaved project is dirty', async () => { + await import('../../../src/components/app/pixel-forge-app'); + const inactiveContext = createProjectContext(); + createdContexts.push(inactiveContext); + autoSaveService.start(inactiveContext); + autoSaveService.markDirty(inactiveContext); + const element = document.createElement('pixel-forge-app') as HTMLElement; + document.body.append(element); + const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent; + + window.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + it('opens export with the project active when the action starts', async () => { await import('../../../src/components/app/pixel-forge-app'); const contextA = createProjectContext(); @@ -307,7 +398,7 @@ describe('pixel-forge-app project dialogs', () => { }); }); - it('imports supported dropped files without intercepting unrelated files', async () => { + it('imports supported dropped files and prevents unsupported drops from navigating away', async () => { await import('../../../src/components/app/pixel-forge-app'); const element = document.createElement('pixel-forge-app') as HTMLElement; document.body.append(element); @@ -330,7 +421,7 @@ describe('pixel-forge-app project dialogs', () => { const unsupportedDrop = fileTransferEvent('drop', [notes]); window.dispatchEvent(unsupportedDrop); - expect(unsupportedDrop.defaultPrevented).toBe(false); + expect(unsupportedDrop.defaultPrevented).toBe(true); expect(projectFileHandlingMock.importProjectFiles).not.toHaveBeenCalled(); }); diff --git a/tests/components/menu/pf-menu-bar.test.ts b/tests/components/menu/pf-menu-bar.test.ts index b1c1ad1..19d74c4 100644 --- a/tests/components/menu/pf-menu-bar.test.ts +++ b/tests/components/menu/pf-menu-bar.test.ts @@ -406,17 +406,19 @@ describe("pf-menu-bar popovers", () => { await element.updateComplete; setActiveProjectContext(contextB); + await element.updateComplete; const input = element.shadowRoot?.querySelector( ".project-name-input" ); expect(input).toBeTruthy(); - input!.value = "Renamed A"; - input!.dispatchEvent(new FocusEvent("blur")); + expect(input!.value).toBe("Context A"); + input!.value += " Revised"; + input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); await vi.waitFor(() => { expect(autoSaveServiceMock.saveNow).toHaveBeenCalled(); }); - expect(contextA.project.name.value).toBe("Renamed A"); + expect(contextA.project.name.value).toBe("Context A Revised"); expect(contextB.project.name.value).toBe("Context B"); expect(autoSaveServiceMock.saveNow).toHaveBeenCalledWith(contextA); }); diff --git a/tests/components/ui/pf-dialog.test.ts b/tests/components/ui/pf-dialog.test.ts index 99f5948..aa1a8bc 100644 --- a/tests/components/ui/pf-dialog.test.ts +++ b/tests/components/ui/pf-dialog.test.ts @@ -41,6 +41,69 @@ describe('pf-dialog', () => { expect(slotText(dialog, 'title')).toContain('Test Dialog'); expect(slotText(dialog)).toContain('Dialog content'); expect(slotText(dialog, 'actions')).toContain('Apply'); + const surface = dialog.shadowRoot?.querySelector('.dialog'); + expect(surface?.getAttribute('role')).toBe('dialog'); + expect(surface?.getAttribute('aria-modal')).toBe('true'); + expect(surface?.getAttribute('aria-labelledby')).toBe('dialog-title'); + expect(dialog.shadowRoot?.querySelector('.close-btn')?.getAttribute('aria-label')).toBe( + 'Close dialog' + ); + }); + + it('contains keyboard focus and restores it after closing', async () => { + const opener = document.createElement('button'); + opener.textContent = 'Open'; + document.body.append(opener); + opener.focus(); + const dialog = createDialog(); + await settle(dialog); + + const close = dialog.shadowRoot?.querySelector('.close-btn'); + const apply = dialog.querySelector('[slot="actions"]'); + expect(dialog.shadowRoot?.activeElement).toBe(close); + + apply?.focus(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + expect(dialog.shadowRoot?.activeElement).toBe(close); + + close?.click(); + await settle(dialog); + expect(document.activeElement).toBe(opener); + }); + + it('restores focus to an opener inside nested shadow roots', async () => { + const outer = document.createElement('div'); + const outerRoot = outer.attachShadow({ mode: 'open' }); + const inner = document.createElement('span'); + const innerRoot = inner.attachShadow({ mode: 'open' }); + const opener = document.createElement('button'); + opener.textContent = 'Open nested dialog'; + innerRoot.append(opener); + outerRoot.append(inner); + document.body.append(outer); + opener.focus(); + + const dialog = createDialog(); + await settle(dialog); + dialog.close(); + await settle(dialog); + + expect(outerRoot.activeElement).toBe(inner); + expect(innerRoot.activeElement).toBe(opener); + }); + + it('keeps focus on the dialog surface when pending controls disappear', async () => { + const dialog = createDialog(); + await settle(dialog); + dialog.querySelector('[slot="actions"]')?.focus(); + + dialog.showCloseButton = false; + dialog.querySelector('[slot="actions"]')?.remove(); + await settle(dialog); + + const surface = dialog.shadowRoot?.querySelector('.dialog'); + expect(surface?.tabIndex).toBe(-1); + expect(dialog.shadowRoot?.activeElement).toBe(surface); }); it('marks the dialog as a vertical scroll surface', async () => { diff --git a/tests/serialization/project-data.test.ts b/tests/serialization/project-data.test.ts index 48b928a..f5423df 100644 --- a/tests/serialization/project-data.test.ts +++ b/tests/serialization/project-data.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { - hasProjectImageData, normalizeProjectFileImageData, normalizeProjectImageData, } from '../../src/serialization/project-data'; @@ -25,15 +24,32 @@ describe('project image data normalization', () => { expect(Array.from(bytes)).toEqual([1, 2, 3, 4]); }); + it('accepts an exact case-insensitive base64 metadata token', () => { + const bytes = normalizeProjectImageData( + 'data:image/png;charset=utf-8;BASE64,AQIDBA==' + ); + + expect(Array.from(bytes)).toEqual([1, 2, 3, 4]); + }); + it('decodes legacy plain Base64 strings to bytes', () => { const bytes = normalizeProjectImageData('BQYH'); expect(Array.from(bytes)).toEqual([5, 6, 7]); }); - it('treats empty or invalid legacy strings as empty data', () => { - expect(hasProjectImageData(normalizeProjectImageData(''))).toBe(false); - expect(hasProjectImageData(normalizeProjectImageData('not base64'))).toBe(false); + it.each([ + ['', 'empty'], + ['not base64', 'base64'], + ['data:image/png,not-base64', 'base64'], + ['data:base64,AQIDBA==', 'base64'], + ['data:image/png;base64url,AQIDBA==', 'base64'], + ['data:image/png;notbase64,AQIDBA==', 'base64'], + ['data:image/png;encoding=base64,AQIDBA==', 'base64'], + ['data:image/png;base64=true,AQIDBA==', 'base64'], + ['data:image/png;base64,%%%%', 'base64'], + ])('rejects malformed or unsupported legacy image data %j', (value, message) => { + expect(() => normalizeProjectImageData(value)).toThrow(message); }); it('normalizes every layer and cel before hydration', () => { diff --git a/tests/services/auto-save.test.ts b/tests/services/auto-save.test.ts index 06e2af2..c675e93 100644 --- a/tests/services/auto-save.test.ts +++ b/tests/services/auto-save.test.ts @@ -138,6 +138,107 @@ describe('AutoSaveService', () => { expect(projectRepository.save).toHaveBeenCalledTimes(1); }); + it('pauses pending saves and observes new edits again after restart', async () => { + const context = createContext('paused-project', 'Paused project'); + autoSaveService.start(context); + await context.history.execute(makeCommand()); + await Promise.resolve(); + + await autoSaveService.pause(context); + await vi.advanceTimersByTimeAsync(2500); + + expect(projectRepository.save).not.toHaveBeenCalled(); + expect(autoSaveService.isDirty(context)).toBe(true); + + autoSaveService.start(context); + await vi.advanceTimersByTimeAsync(2500); + + expect(projectRepository.save).toHaveBeenCalledWith( + 'paused-project', + expect.objectContaining({ name: 'Paused project' }), + expect.any(Object) + ); + expect(autoSaveService.isDirty(context)).toBe(false); + }); + + it('records edits made while paused and saves them after restart', async () => { + const context = createContext('paused-edit-project', 'Before deletion'); + autoSaveService.start(context); + await autoSaveService.pause(context); + + await context.history.execute( + makeCommand(() => { + context.project.name.value = 'Edited while deletion was pending'; + }) + ); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(2500); + + expect(autoSaveService.isDirty(context)).toBe(true); + expect(projectRepository.save).not.toHaveBeenCalled(); + + autoSaveService.start(context); + await vi.advanceTimersByTimeAsync(2500); + + expect(projectRepository.save).toHaveBeenCalledWith( + 'paused-edit-project', + expect.objectContaining({ name: 'Edited while deletion was pending' }), + expect.any(Object) + ); + expect(autoSaveService.isDirty(context)).toBe(false); + }); + + it('keeps saving until an edit made during a write is persisted', async () => { + const context = createContext('reload-project', 'First state'); + const firstWrite = deferred(); + vi.mocked(projectRepository.save) + .mockImplementationOnce(() => firstWrite.promise) + .mockResolvedValueOnce(); + + autoSaveService.start(context); + const stableSave = autoSaveService.saveUntilClean(context); + await settleSaveQueue(); + + await context.history.execute( + makeCommand(() => { + context.project.name.value = 'Edited during save'; + }) + ); + await Promise.resolve(); + firstWrite.resolve(); + await stableSave; + + expect(projectRepository.save).toHaveBeenCalledTimes(2); + expect(vi.mocked(projectRepository.save).mock.calls[1][1].name).toBe('Edited during save'); + expect(autoSaveService.isDirty(context)).toBe(false); + }); + + it('rejects a forced save queued after pause and never writes it', async () => { + const context = createContext('deleted-project', 'Deleted project'); + autoSaveService.start(context); + await autoSaveService.pause(context); + + await expect(autoSaveService.saveNow(context)).rejects.toThrow('paused'); + expect(projectRepository.save).not.toHaveBeenCalled(); + }); + + it('turns pause into a barrier against a forced save already serializing', async () => { + const context = createContext('concurrent-delete-project', 'Concurrent delete'); + const serializedProject = await context.project.saveProject(); + const serialization = deferred(); + vi.spyOn(context.project, 'saveProject').mockReturnValueOnce(serialization.promise); + autoSaveService.start(context); + + const forcedSave = autoSaveService.saveNow(context); + await settleSaveQueue(); + const pause = autoSaveService.pause(context); + serialization.resolve(serializedProject); + + await expect(forcedSave).rejects.toThrow('paused'); + await pause; + expect(projectRepository.save).not.toHaveBeenCalled(); + }); + it('saves after undo', async () => { await historyStore.execute(makeCommand()); await vi.advanceTimersByTimeAsync(2500); diff --git a/tests/services/project-library.test.ts b/tests/services/project-library.test.ts index a5b82d3..57f920e 100644 --- a/tests/services/project-library.test.ts +++ b/tests/services/project-library.test.ts @@ -41,6 +41,7 @@ import { type ProjectContext, } from '../../src/stores/project-context'; import { viewportStore } from '../../src/stores/viewport'; +import { WorkspaceStore } from '../../src/stores/workspace'; import { PROJECT_VERSION } from '../../src/types/project'; const repository = vi.mocked(projectRepository); @@ -323,6 +324,12 @@ describe('ProjectLibraryService', () => { projects.set('open', makeProject('Open')); await openProjectInStore('open', makeProject('Open')); autoSaveService.start(); + const workspace = new WorkspaceStore({ + initialContext: defaultProjectContext, + initialItemId: 'open', + projectLibrary: service, + autoSave: autoSaveService, + }); await historyStore.execute( makeCommand(() => { @@ -331,7 +338,8 @@ describe('ProjectLibraryService', () => { ); await Promise.resolve(); - await service.deleteProject('open'); + await workspace.deleteProject('open'); + createdContexts.push(workspace.activeItem.context); await vi.advanceTimersByTimeAsync(2500); expect(projects.has('open')).toBe(false); @@ -341,6 +349,12 @@ describe('ProjectLibraryService', () => { projects.set('open', makeProject('Open')); await openProjectInStore('open', makeProject('Open')); autoSaveService.start(); + const workspace = new WorkspaceStore({ + initialContext: defaultProjectContext, + initialItemId: 'open', + projectLibrary: service, + autoSave: autoSaveService, + }); let finishWrite!: () => void; const writeGate = new Promise((resolve) => { @@ -359,13 +373,14 @@ describe('ProjectLibraryService', () => { await Promise.resolve(); await vi.advanceTimersByTimeAsync(2000); - const deletion = service.deleteProject('open'); + const deletion = workspace.deleteProject('open'); await Promise.resolve(); expect(repository.delete).not.toHaveBeenCalled(); finishWrite(); await deletion; + createdContexts.push(workspace.activeItem.context); expect(projects.has('open')).toBe(false); expect(repository.delete).toHaveBeenCalledWith('open'); diff --git a/tests/services/pwa-file-handling.test.ts b/tests/services/pwa-file-handling.test.ts index 0c40c66..268be11 100644 --- a/tests/services/pwa-file-handling.test.ts +++ b/tests/services/pwa-file-handling.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { PwaFileHandlingService } from '../../src/services/pwa-file-handling'; +import { log } from '../../src/utils/log'; function fileHandle(file: File): FileSystemFileHandle { return { @@ -59,12 +60,18 @@ describe('PwaFileHandlingService', () => { }), } as unknown as FileSystemFileHandle; + const warning = vi.spyOn(log, 'warn').mockImplementation(() => {}); const result = await service.importLaunch({ files: [unreadable, fileHandle(readable)], }); expect(result.unreadableFiles).toEqual(['private.ase']); expect(importer.importFiles).toHaveBeenCalledWith([readable]); + expect(warning).toHaveBeenCalledWith( + 'Could not read a launched file from the operating system:', + expect.any(DOMException) + ); + expect(warning.mock.calls.flat().join(' ')).not.toContain('private.ase'); }); it('serializes repeated launches before reading their handles', async () => { diff --git a/tests/stores/pwa.test.ts b/tests/stores/pwa.test.ts index a0b0949..c6a812e 100644 --- a/tests/stores/pwa.test.ts +++ b/tests/stores/pwa.test.ts @@ -66,9 +66,11 @@ describe('PwaStore install prompt', () => { describe('PwaStore update flow', () => { let store: PwaStore; + let reload: ReturnType; beforeEach(() => { - store = new PwaStore(); + reload = vi.fn(); + store = new PwaStore(reload); }); afterEach(() => { @@ -90,8 +92,67 @@ describe('PwaStore update flow', () => { expect(await store.restartWithUpdate(save)).toBe(true); expect(calls).toEqual(['save', 'update']); - expect(update).toHaveBeenCalledWith(true); - expect(store.updateAvailable.value).toBe(false); + expect(update).toHaveBeenCalledWith(false); + expect(reload).not.toHaveBeenCalled(); + expect(store.applyingUpdate.value).toBe(true); + + await store.handleUpdateControlling(); + + expect(calls).toEqual(['save', 'update', 'save']); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('waits for the final save when control changes during the initial save', async () => { + let finishInitialSave!: () => void; + let finishFinalSave!: () => void; + const initialSave = new Promise((resolve) => { + finishInitialSave = resolve; + }); + const finalSave = new Promise((resolve) => { + finishFinalSave = resolve; + }); + const save = vi.fn() + .mockReturnValueOnce(initialSave) + .mockReturnValueOnce(finalSave); + const update = vi.fn().mockResolvedValue(undefined); + store.setUpdateHandler(update); + store.showUpdate(); + + const restart = store.restartWithUpdate(save); + await vi.waitFor(() => expect(save).toHaveBeenCalledOnce()); + const controlling = store.handleUpdateControlling(); + + finishInitialSave(); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2)); + expect(reload).not.toHaveBeenCalled(); + + finishFinalSave(); + await expect(controlling).resolves.toBe(true); + await expect(restart).resolves.toBe(true); + expect(update).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('keeps the updated session open and retryable when the final save fails', async () => { + const save = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('storage full')) + .mockResolvedValue(undefined); + const update = vi.fn().mockResolvedValue(undefined); + store.setUpdateHandler(update); + store.showUpdate(); + + expect(await store.restartWithUpdate(save)).toBe(true); + await store.handleUpdateControlling(); + + expect(reload).not.toHaveBeenCalled(); + expect(store.updateAvailable.value).toBe(true); + expect(store.applyingUpdate.value).toBe(false); + expect(store.updateError.value).toContain('could not save'); + + expect(await store.restartWithUpdate(save)).toBe(true); + expect(update).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledOnce(); }); it('dismisses the notice without applying the update', () => { diff --git a/tests/stores/workspace.test.ts b/tests/stores/workspace.test.ts index 88e7031..3898c4c 100644 --- a/tests/stores/workspace.test.ts +++ b/tests/stores/workspace.test.ts @@ -16,7 +16,7 @@ import { PROJECT_VERSION, type ProjectFile } from "../../src/types/project"; type WorkspaceProjectLibrary = Pick< ProjectLibraryService, - "openProject" | "createProject" | "createProjectFromFile" + "openProject" | "createProject" | "createProjectFromFile" | "deleteProject" >; function makeProjectFile(name: string): ProjectFile { @@ -108,6 +108,7 @@ function createProjectLibraryMock() { } return projectId; }), + deleteProject: vi.fn(async () => {}), }; return projectLibrary; @@ -116,6 +117,7 @@ function createProjectLibraryMock() { function createAutoSaveMock() { return { saveNow: vi.fn(async () => {}), + pause: vi.fn(async () => {}), start: vi.fn(), stop: vi.fn(), }; @@ -127,6 +129,14 @@ function createWorkspaceStateMock() { }; } +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + function expectAdded(result: ReturnType) { expect(result.ok).toBe(true); if (!result.ok) { @@ -307,6 +317,328 @@ describe("WorkspaceStore", () => { expect(dispose).not.toHaveBeenCalled(); }); + it("deletes the active project and activates a remaining project", async () => { + const contextA = createTestContext("Project A"); + const contextB = createTestContext("Project B"); + contextA.project.id.value = "project-a"; + contextB.project.id.value = "project-b"; + const projectLibrary = createProjectLibraryMock(); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: contextA, + initialItemId: "project-a", + projectLibrary, + autoSave, + }); + workspace.addContext(contextB, { id: "project-b" }); + const dispose = vi.spyOn(contextB, "dispose"); + + await workspace.deleteProject("project-b"); + + expect(autoSave.pause).toHaveBeenCalledWith(contextB); + expect(projectLibrary.deleteProject).toHaveBeenCalledWith("project-b"); + expect(workspace.items.value.map((item) => item.id)).toEqual(["project-a"]); + expect(workspace.activeItem.context).toBe(contextA); + expect(getActiveProjectContext()).toBe(contextA); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("deletes an inactive project without changing the active project", async () => { + const contextA = createTestContext("Project A"); + const contextB = createTestContext("Project B"); + contextA.project.id.value = "project-a"; + contextB.project.id.value = "project-b"; + const projectLibrary = createProjectLibraryMock(); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: contextA, + initialItemId: "project-a", + projectLibrary, + autoSave, + }); + workspace.addContext(contextB, { id: "project-b", activate: false }); + const dispose = vi.spyOn(contextB, "dispose"); + + await workspace.deleteProject("project-b"); + + expect(workspace.items.value.map((item) => item.id)).toEqual(["project-a"]); + expect(workspace.activeItem.context).toBe(contextA); + expect(getActiveProjectContext()).toBe(contextA); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("replaces the last deleted project with a fresh safe context", async () => { + const deletedContext = createTestContext("Only Project"); + deletedContext.project.id.value = "only-project"; + const projectLibrary = createProjectLibraryMock(); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: deletedContext, + initialItemId: "only-project", + projectLibrary, + autoSave, + }); + const dispose = vi.spyOn(deletedContext, "dispose"); + + await workspace.deleteProject("only-project"); + + const replacement = workspace.activeItem; + rememberContext(replacement.context); + expect(replacement.context).not.toBe(deletedContext); + expect(replacement.context.project.id.value).not.toBe("only-project"); + expect(workspace.items.value).toHaveLength(1); + expect(getActiveProjectContext()).toBe(replacement.context); + expect(autoSave.start).toHaveBeenCalledWith(replacement.context); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("restores observation and keeps the workspace intact when deletion fails", async () => { + const contextA = createTestContext("Project A"); + const contextB = createTestContext("Project B"); + contextA.project.id.value = "project-a"; + contextB.project.id.value = "project-b"; + const projectLibrary = createProjectLibraryMock(); + vi.mocked(projectLibrary.deleteProject).mockRejectedValue(new Error("delete failed")); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: contextA, + initialItemId: "project-a", + projectLibrary, + autoSave, + }); + workspace.addContext(contextB, { id: "project-b" }); + const dispose = vi.spyOn(contextB, "dispose"); + + await expect(workspace.deleteProject("project-b")).rejects.toThrow( + "delete failed", + ); + + expect(autoSave.pause).toHaveBeenCalledWith(contextB); + expect(autoSave.start).toHaveBeenCalledWith(contextB); + expect(workspace.items.value.map((item) => item.id)).toEqual([ + "project-a", + "project-b", + ]); + expect(workspace.activeItem.context).toBe(contextB); + expect(getActiveProjectContext()).toBe(contextB); + expect(dispose).not.toHaveBeenCalled(); + }); + + it("preserves a project opened while the last project is being deleted", async () => { + const deletedContext = createTestContext("Project A"); + const openedContext = createTestContext("Project B"); + deletedContext.project.id.value = "project-a"; + openedContext.project.id.value = "project-b"; + const deletion = createDeferred(); + const projectLibrary = createProjectLibraryMock(); + vi.mocked(projectLibrary.deleteProject).mockReturnValue(deletion.promise); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: deletedContext, + initialItemId: "project-a", + projectLibrary, + autoSave, + }); + const deletedDispose = vi.spyOn(deletedContext, "dispose"); + const openedDispose = vi.spyOn(openedContext, "dispose"); + + const deleteResult = workspace.deleteProject("project-a"); + await vi.waitFor(() => expect(projectLibrary.deleteProject).toHaveBeenCalled()); + workspace.addContext(openedContext, { id: "project-b", activate: false }); + deletion.resolve(); + + const result = await deleteResult; + expect(result.installedReplacement).toBe(false); + expect(workspace.items.value.map((item) => item.id)).toEqual(["project-b"]); + expect(workspace.activeItem.context).toBe(openedContext); + expect(deletedDispose).toHaveBeenCalledOnce(); + expect(openedDispose).not.toHaveBeenCalled(); + }); + + it("lets an in-flight open finish before deleting the same project", async () => { + const initialContext = createTestContext("Initial Project"); + const opening = createDeferred(); + const projectLibrary = createProjectLibraryMock(); + vi.mocked(projectLibrary.openProject).mockImplementationOnce( + async (projectId, settings = {}) => { + await opening.promise; + if (settings.context) settings.context.project.id.value = projectId; + return makeProjectFile(`Project ${projectId}`); + }, + ); + const workspace = new WorkspaceStore({ + initialContext, + initialItemId: "initial-project", + projectLibrary, + autoSave: createAutoSaveMock(), + }); + + const open = workspace.openProject("project-a"); + await vi.waitFor(() => expect(projectLibrary.openProject).toHaveBeenCalledOnce()); + const deletion = workspace.deleteProject("project-a"); + + expect(projectLibrary.deleteProject).not.toHaveBeenCalled(); + opening.resolve(); + await open; + await deletion; + + expect(projectLibrary.deleteProject).toHaveBeenCalledWith("project-a"); + expect(workspace.getProjectItem("project-a")).toBeUndefined(); + }); + + it("waits for same-project deletion before a reopen and keeps other projects concurrent", async () => { + const deletedContext = createTestContext("Project A"); + deletedContext.project.id.value = "project-a"; + const deletion = createDeferred(); + const projectLibrary = createProjectLibraryMock(); + vi.mocked(projectLibrary.deleteProject).mockImplementationOnce(() => deletion.promise); + vi.mocked(projectLibrary.openProject).mockImplementation(async (projectId, settings = {}) => { + if (projectId === "project-a") throw new Error("Project not found"); + if (settings.context) settings.context.project.id.value = projectId; + return makeProjectFile(`Project ${projectId}`); + }); + const workspace = new WorkspaceStore({ + initialContext: deletedContext, + initialItemId: "project-a", + projectLibrary, + autoSave: createAutoSaveMock(), + }); + + const deleteResult = workspace.deleteProject("project-a"); + await vi.waitFor(() => expect(projectLibrary.deleteProject).toHaveBeenCalledOnce()); + const reopen = workspace.openProject("project-a"); + const unrelatedOpen = workspace.openProject("project-b"); + + await expect(unrelatedOpen).resolves.toMatchObject({ ok: true, projectId: "project-b" }); + expect(projectLibrary.openProject).not.toHaveBeenCalledWith( + "project-a", + expect.any(Object), + ); + + deletion.resolve(); + await deleteResult; + await expect(reopen).rejects.toThrow("Project not found"); + }); + + it("does not reopen a project that was closed while its deletion was pending", async () => { + const deletedContext = createTestContext("Project A"); + const remainingContext = createTestContext("Project B"); + deletedContext.project.id.value = "project-a"; + remainingContext.project.id.value = "project-b"; + const deletion = createDeferred(); + const projectLibrary = createProjectLibraryMock(); + vi.mocked(projectLibrary.deleteProject).mockImplementationOnce(() => deletion.promise); + vi.mocked(projectLibrary.openProject).mockRejectedValueOnce(new Error("Project not found")); + const workspace = new WorkspaceStore({ + initialContext: deletedContext, + initialItemId: "project-a", + projectLibrary, + autoSave: createAutoSaveMock(), + }); + workspace.addContext(remainingContext, { id: "project-b", activate: false }); + + const deleteResult = workspace.deleteProject("project-a"); + await vi.waitFor(() => expect(projectLibrary.deleteProject).toHaveBeenCalledOnce()); + expect(workspace.close("project-a").ok).toBe(true); + const reopen = workspace.openProject("project-a"); + + await Promise.resolve(); + await Promise.resolve(); + expect(projectLibrary.openProject).not.toHaveBeenCalled(); + deletion.resolve(); + await deleteResult; + await expect(reopen).rejects.toThrow("Project not found"); + expect(workspace.items.value.map((item) => item.id)).toEqual(["project-b"]); + }); + + it("installs a replacement when another project closes during deletion", async () => { + const deletedContext = createTestContext("Project A"); + const closingContext = createTestContext("Project B"); + deletedContext.project.id.value = "project-a"; + closingContext.project.id.value = "project-b"; + const deletion = createDeferred(); + const projectLibrary = createProjectLibraryMock(); + vi.mocked(projectLibrary.deleteProject).mockReturnValue(deletion.promise); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: deletedContext, + initialItemId: "project-a", + projectLibrary, + autoSave, + }); + workspace.addContext(closingContext, { id: "project-b", activate: false }); + + const deleteResult = workspace.deleteProject("project-a"); + await vi.waitFor(() => expect(projectLibrary.deleteProject).toHaveBeenCalled()); + expect(workspace.close("project-b").ok).toBe(true); + deletion.resolve(); + + const result = await deleteResult; + rememberContext(result.activeItem.context); + expect(result.installedReplacement).toBe(true); + expect(workspace.items.value).toEqual([result.activeItem]); + expect(result.activeItem.context).not.toBe(deletedContext); + expect(autoSave.start).toHaveBeenCalledWith(result.activeItem.context); + }); + + it("finishes cleanly when the deleted project closes during deletion", async () => { + const deletedContext = createTestContext("Project A"); + const remainingContext = createTestContext("Project B"); + deletedContext.project.id.value = "project-a"; + remainingContext.project.id.value = "project-b"; + const deletion = createDeferred(); + const projectLibrary = createProjectLibraryMock(); + vi.mocked(projectLibrary.deleteProject).mockReturnValue(deletion.promise); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: deletedContext, + initialItemId: "project-a", + projectLibrary, + autoSave, + }); + workspace.addContext(remainingContext, { id: "project-b", activate: false }); + + const deleteResult = workspace.deleteProject("project-a"); + await vi.waitFor(() => expect(projectLibrary.deleteProject).toHaveBeenCalled()); + expect(workspace.close("project-a").ok).toBe(true); + deletion.resolve(); + + const result = await deleteResult; + expect(result.installedReplacement).toBe(false); + expect(result.activeItem.context).toBe(remainingContext); + expect(workspace.items.value.map((item) => item.id)).toEqual(["project-b"]); + expect(autoSave.start).not.toHaveBeenCalledWith(deletedContext); + }); + + it("restores observation when deletion commits to storage but not the workspace", async () => { + const deletedContext = createTestContext("Project A"); + const remainingContext = createTestContext("Project B"); + deletedContext.project.id.value = "project-a"; + remainingContext.project.id.value = "project-b"; + const projectLibrary = createProjectLibraryMock(); + const autoSave = createAutoSaveMock(); + const workspace = new WorkspaceStore({ + initialContext: deletedContext, + initialItemId: "project-a", + projectLibrary, + autoSave, + }); + workspace.addContext(remainingContext, { id: "project-b", activate: false }); + vi.spyOn(workspace, "close").mockReturnValue({ + ok: false, + reason: "not-found", + message: "Workspace item was not found.", + }); + + await expect(workspace.deleteProject("project-a")).rejects.toThrow( + "Workspace item was not found.", + ); + + expect(projectLibrary.deleteProject).toHaveBeenCalledWith("project-a"); + expect(autoSave.start).toHaveBeenCalledWith(deletedContext); + expect(workspace.getProjectItem("project-a")?.context).toBe(deletedContext); + }); + it("returns a clear failure when the open item cap is reached", () => { const firstContext = createTestContext("Project 1"); const workspace = new WorkspaceStore({ diff --git a/tests/tools/transform-active-context.test.ts b/tests/tools/transform-active-context.test.ts index beb81da..78900a3 100644 --- a/tests/tools/transform-active-context.test.ts +++ b/tests/tools/transform-active-context.test.ts @@ -14,6 +14,7 @@ import { type ProjectContext, } from '../../src/stores/project-context'; import { TransformTool } from '../../src/tools/transform-tool'; +import { commitSelectionTransform } from '../../src/services/selection-transform-commit'; const createdContexts: ProjectContext[] = []; @@ -121,4 +122,37 @@ describe('TransformTool active project context', () => { expect(projectB.animation.cels.value).toEqual(projectBState); expect(defaultProjectContext.animation.cels.value).toEqual(defaultState); }); + + it('commits a selection transform to the layer and frame where it began', () => { + const context = createContext(); + const originLayer = context.layers.layers.value[0]; + const originFrameId = context.animation.currentFrameId.value; + const otherLayer = context.layers.addLayer('Other layer'); + context.animation.addFrame(false); + const otherFrameId = context.animation.frames.value.at(-1)?.id; + if (!originLayer || !otherFrameId) throw new Error('Expected layers and frames'); + const execute = vi.spyOn(context.history, 'execute').mockResolvedValue(); + const getEditableCelCanvas = vi.spyOn(context.animation, 'getEditableCelCanvas'); + + context.selection.startTransform( + imageData(), + { x: 0, y: 0, width: 2, height: 2 }, + 'rectangle', + undefined, + originLayer.id, + originFrameId + ); + context.selection.moveTransform(1, 0); + context.layers.setActiveLayer(otherLayer.id); + context.animation.goToFrame(otherFrameId); + + commitSelectionTransform(context); + + expect(execute).toHaveBeenCalledOnce(); + const command = execute.mock.calls[0][0]; + command.execute(); + expect(getEditableCelCanvas).toHaveBeenCalledWith(originLayer.id, originFrameId); + expect(getEditableCelCanvas).not.toHaveBeenCalledWith(otherLayer.id, otherFrameId); + expect(context.selection.state.value.type).toBe('none'); + }); }); diff --git a/tests/workers/telemetry-worker.test.ts b/tests/workers/telemetry-worker.test.ts index 8534e01..1f9fd4d 100644 --- a/tests/workers/telemetry-worker.test.ts +++ b/tests/workers/telemetry-worker.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { handleTelemetryRequest } from '../../workers/telemetry/src/index'; +import type { ProductEvent } from '../../shared/src/product-events'; +import { handleTelemetryRequest, writeProductEvent } from '../../workers/telemetry/src/index'; function createEnvironment(options: { rateLimitSuccess?: boolean } = {}) { const writeDataPoint = vi.fn(); @@ -70,6 +71,54 @@ describe('telemetry Worker', () => { }); }); + it('keeps empty dimension placeholders for events without dimensions', () => { + const { env, writeDataPoint } = createEnvironment(); + const event: ProductEvent = { + name: 'playback_started', + dimensions: {}, + }; + + writeProductEvent(env.PRODUCT_EVENTS, event); + + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ['playback_started'], + blobs: ['', ''], + }); + }); + + it('writes one dimension pair in order', () => { + const { env, writeDataPoint } = createEnvironment(); + const event: ProductEvent = { + name: 'project_created', + dimensions: { source: 'guided_drawing' }, + }; + + writeProductEvent(env.PRODUCT_EVENTS, event); + + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ['project_created'], + blobs: ['source', 'guided_drawing'], + }); + }); + + it('writes every dimension pair in order', () => { + const { env, writeDataPoint } = createEnvironment(); + const event = { + name: 'project_created', + dimensions: { + source: 'guided_drawing', + destination: 'local_library', + }, + } as const; + + writeProductEvent(env.PRODUCT_EVENTS, event); + + expect(writeDataPoint).toHaveBeenCalledWith({ + indexes: ['project_created'], + blobs: ['source', 'guided_drawing', 'destination', 'local_library'], + }); + }); + it('writes only the approved event and dimension', async () => { const { env, limit, writeDataPoint } = createEnvironment(); diff --git a/workers/telemetry/src/index.ts b/workers/telemetry/src/index.ts index 473d716..dec8d64 100644 --- a/workers/telemetry/src/index.ts +++ b/workers/telemetry/src/index.ts @@ -93,11 +93,13 @@ function parseJsonProductEvent(body: string): ProductEvent | undefined { } } -function writeProductEvent(dataset: AnalyticsEngineDataset, event: ProductEvent): void { - const [dimensionName, dimensionValue] = Object.entries(event.dimensions)[0] ?? ['', '']; +export function writeProductEvent(dataset: AnalyticsEngineDataset, event: ProductEvent): void { + const dimensionBlobs = Object.entries(event.dimensions).flatMap( + ([dimensionName, dimensionValue]) => [dimensionName, dimensionValue] + ); dataset.writeDataPoint({ indexes: [event.name], - blobs: [dimensionName, dimensionValue], + blobs: dimensionBlobs.length > 0 ? dimensionBlobs : ['', ''], }); } From f053e53c5a55e73f530790de62a71d90f1d93b1a Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 15:18:28 +0200 Subject: [PATCH 2/2] chore: refresh reconciliation to current main --- package-lock.json | 6 +++--- shared/src/project-data.ts | 9 +++++---- tests/serialization/project-data.test.ts | 9 ++++++++- tests/shared/project-contract.test.ts | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index c864a3b..b03f8ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7273,9 +7273,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { diff --git a/shared/src/project-data.ts b/shared/src/project-data.ts index 8410633..f88d790 100644 --- a/shared/src/project-data.ts +++ b/shared/src/project-data.ts @@ -66,10 +66,11 @@ function serializedBytesToUint8Array(data: Record): Uint8Array { } function decodeLegacyBase64ImageData(data: string): Uint8Array { - const base64 = getBase64Payload(data.trim()); - if (!base64) { - throw new TypeError('Legacy project image data is empty'); - } + const trimmedData = data.trim(); + if (!trimmedData) return new Uint8Array(0); + + const base64 = getBase64Payload(trimmedData); + if (!base64) return new Uint8Array(0); try { return base64ToUint8Array(base64); diff --git a/tests/serialization/project-data.test.ts b/tests/serialization/project-data.test.ts index f5423df..ca1ac4c 100644 --- a/tests/serialization/project-data.test.ts +++ b/tests/serialization/project-data.test.ts @@ -39,7 +39,14 @@ describe('project image data normalization', () => { }); it.each([ - ['', 'empty'], + ['empty plain data', ''], + ['whitespace-only plain data', ' \n\t'], + ['empty exact-base64 data URL', 'data:image/png;base64,'], + ])('normalizes %s to empty bytes', (_name, value) => { + expect(normalizeProjectImageData(value)).toEqual(new Uint8Array(0)); + }); + + it.each([ ['not base64', 'base64'], ['data:image/png,not-base64', 'base64'], ['data:base64,AQIDBA==', 'base64'], diff --git a/tests/shared/project-contract.test.ts b/tests/shared/project-contract.test.ts index 5c94bef..4403cb1 100644 --- a/tests/shared/project-contract.test.ts +++ b/tests/shared/project-contract.test.ts @@ -106,6 +106,24 @@ describe('shared ProjectFile contract', () => { expect(decoded.frames[0].cels[0].data).toEqual(Uint8Array.from([7, 8, 9])); }); + // Regression: #425 + it.each([ + ['plain strings', '', ' \n\t'], + ['exact-base64 data URLs', 'data:image/png;base64,', 'data:image/png;charset=utf-8;BASE64,'], + ])('opens legacy projects with empty %s', (_name, layerData, celData) => { + const serialized = serializedProject(); + const layers = serialized.layers as Array>; + const frames = serialized.frames as Array>; + const cels = frames[0].cels as Array>; + layers[0].data = layerData; + cels[0].data = celData; + + const decoded = decodeProjectFile(serialized); + + expect(decoded.layers[0].data).toEqual(new Uint8Array(0)); + expect(decoded.frames[0].cels[0].data).toEqual(new Uint8Array(0)); + }); + it.each([ ['leading-zero', { '01': 1 }], ['sparse', { 0: 1, 2: 3 }],